blob: 3434089e8b5727015fedb8ae23c87934c4cf3eec [file] [log] [blame]
/*
* Copyright (C) 2006 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.providers.media;
import static android.Manifest.permission.ACCESS_CACHE_FILESYSTEM;
import static android.Manifest.permission.READ_EXTERNAL_STORAGE;
import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
import static android.os.ParcelFileDescriptor.MODE_READ_ONLY;
import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
import android.app.SearchManager;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.ContentProvider;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.OperationApplicationException;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.content.UriMatcher;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.MatrixCursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQueryBuilder;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.MediaFile;
import android.media.MediaScanner;
import android.media.MediaScannerConnection;
import android.media.MediaScannerConnection.MediaScannerConnectionClient;
import android.media.MiniThumbFile;
import android.mtp.MtpConstants;
import android.mtp.MtpStorage;
import android.net.Uri;
import android.os.Binder;
import android.os.Bundle;
import android.os.Environment;
import android.os.FileUtils;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.os.ParcelFileDescriptor;
import android.os.Process;
import android.os.RemoteException;
import android.os.SystemClock;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.preference.PreferenceManager;
import android.provider.BaseColumns;
import android.provider.MediaStore;
import android.provider.MediaStore.Audio;
import android.provider.MediaStore.Audio.Playlists;
import android.provider.MediaStore.Files;
import android.provider.MediaStore.Files.FileColumns;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Images.ImageColumns;
import android.provider.MediaStore.MediaColumns;
import android.provider.MediaStore.Video;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.PriorityQueue;
import java.util.Stack;
import libcore.io.ErrnoException;
import libcore.io.Libcore;
/**
* Media content provider. See {@link android.provider.MediaStore} for details.
* Separate databases are kept for each external storage card we see (using the
* card's ID as an index). The content visible at content://media/external/...
* changes with the card.
*/
public class MediaProvider extends ContentProvider {
private static final Uri MEDIA_URI = Uri.parse("content://media");
private static final Uri ALBUMART_URI = Uri.parse("content://media/external/audio/albumart");
private static final int ALBUM_THUMB = 1;
private static final int IMAGE_THUMB = 2;
private static final HashMap<String, String> sArtistAlbumsMap = new HashMap<String, String>();
private static final HashMap<String, String> sFolderArtMap = new HashMap<String, String>();
/** Resolved canonical path to external storage. */
private static final String sExternalPath;
/** Resolved canonical path to cache storage. */
private static final String sCachePath;
static {
try {
sExternalPath = Environment.getExternalStorageDirectory().getCanonicalPath();
sCachePath = Environment.getDownloadCacheDirectory().getCanonicalPath();
} catch (IOException e) {
throw new RuntimeException("Unable to resolve canonical paths", e);
}
}
// In memory cache of path<->id mappings, to speed up inserts during media scan
HashMap<String, Long> mDirectoryCache = new HashMap<String, Long>();
// A HashSet of paths that are pending creation of album art thumbnails.
private HashSet mPendingThumbs = new HashSet();
// A Stack of outstanding thumbnail requests.
private Stack mThumbRequestStack = new Stack();
// The lock of mMediaThumbQueue protects both mMediaThumbQueue and mCurrentThumbRequest.
private MediaThumbRequest mCurrentThumbRequest = null;
private PriorityQueue<MediaThumbRequest> mMediaThumbQueue =
new PriorityQueue<MediaThumbRequest>(MediaThumbRequest.PRIORITY_NORMAL,
MediaThumbRequest.getComparator());
private boolean mCaseInsensitivePaths;
private static String[] mExternalStoragePaths;
// For compatibility with the approximately 0 apps that used mediaprovider search in
// releases 1.0, 1.1 or 1.5
private String[] mSearchColsLegacy = new String[] {
android.provider.BaseColumns._ID,
MediaStore.Audio.Media.MIME_TYPE,
"(CASE WHEN grouporder=1 THEN " + R.drawable.ic_search_category_music_artist +
" ELSE CASE WHEN grouporder=2 THEN " + R.drawable.ic_search_category_music_album +
" ELSE " + R.drawable.ic_search_category_music_song + " END END" +
") AS " + SearchManager.SUGGEST_COLUMN_ICON_1,
"0 AS " + SearchManager.SUGGEST_COLUMN_ICON_2,
"text1 AS " + SearchManager.SUGGEST_COLUMN_TEXT_1,
"text1 AS " + SearchManager.SUGGEST_COLUMN_QUERY,
"CASE when grouporder=1 THEN data1 ELSE artist END AS data1",
"CASE when grouporder=1 THEN data2 ELSE " +
"CASE WHEN grouporder=2 THEN NULL ELSE album END END AS data2",
"match as ar",
SearchManager.SUGGEST_COLUMN_INTENT_DATA,
"grouporder",
"NULL AS itemorder" // We should be sorting by the artist/album/title keys, but that
// column is not available here, and the list is already sorted.
};
private String[] mSearchColsFancy = new String[] {
android.provider.BaseColumns._ID,
MediaStore.Audio.Media.MIME_TYPE,
MediaStore.Audio.Artists.ARTIST,
MediaStore.Audio.Albums.ALBUM,
MediaStore.Audio.Media.TITLE,
"data1",
"data2",
};
// If this array gets changed, please update the constant below to point to the correct item.
private String[] mSearchColsBasic = new String[] {
android.provider.BaseColumns._ID,
MediaStore.Audio.Media.MIME_TYPE,
"(CASE WHEN grouporder=1 THEN " + R.drawable.ic_search_category_music_artist +
" ELSE CASE WHEN grouporder=2 THEN " + R.drawable.ic_search_category_music_album +
" ELSE " + R.drawable.ic_search_category_music_song + " END END" +
") AS " + SearchManager.SUGGEST_COLUMN_ICON_1,
"text1 AS " + SearchManager.SUGGEST_COLUMN_TEXT_1,
"text1 AS " + SearchManager.SUGGEST_COLUMN_QUERY,
"(CASE WHEN grouporder=1 THEN '%1'" + // %1 gets replaced with localized string.
" ELSE CASE WHEN grouporder=3 THEN artist || ' - ' || album" +
" ELSE CASE WHEN text2!='" + MediaStore.UNKNOWN_STRING + "' THEN text2" +
" ELSE NULL END END END) AS " + SearchManager.SUGGEST_COLUMN_TEXT_2,
SearchManager.SUGGEST_COLUMN_INTENT_DATA
};
// Position of the TEXT_2 item in the above array.
private final int SEARCH_COLUMN_BASIC_TEXT2 = 5;
private static final String[] sMediaTableColumns = new String[] {
FileColumns._ID,
FileColumns.MEDIA_TYPE,
};
private static final String[] sIdOnlyColumn = new String[] {
FileColumns._ID
};
private static final String[] sDataOnlyColumn = new String[] {
FileColumns.DATA
};
private static final String[] sMediaTypeDataId = new String[] {
FileColumns.MEDIA_TYPE,
FileColumns.DATA,
FileColumns._ID
};
private static final String[] sPlaylistIdPlayOrder = new String[] {
Playlists.Members.PLAYLIST_ID,
Playlists.Members.PLAY_ORDER
};
private Uri mAlbumArtBaseUri = Uri.parse("content://media/external/audio/albumart");
private BroadcastReceiver mUnmountReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals(Intent.ACTION_MEDIA_EJECT)) {
StorageVolume storage = (StorageVolume)intent.getParcelableExtra(
StorageVolume.EXTRA_STORAGE_VOLUME);
// If primary external storage is ejected, then remove the external volume
// notify all cursors backed by data on that volume.
if (storage.getPath().equals(mExternalStoragePaths[0])) {
detachVolume(Uri.parse("content://media/external"));
sFolderArtMap.clear();
MiniThumbFile.reset();
} else {
// If secondary external storage is ejected, then we delete all database
// entries for that storage from the files table.
synchronized (mDatabases) {
DatabaseHelper database = mDatabases.get(EXTERNAL_VOLUME);
Uri uri = Uri.parse("file://" + storage.getPath());
if (database != null) {
try {
// Send media scanner started and stopped broadcasts for apps that rely
// on these Intents for coarse grained media database notifications.
context.sendBroadcast(
new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, uri));
// don't send objectRemoved events - MTP be sending StorageRemoved anyway
mDisableMtpObjectCallbacks = true;
Log.d(TAG, "deleting all entries for storage " + storage);
SQLiteDatabase db = database.getWritableDatabase();
// First clear the file path to disable the _DELETE_FILE database hook.
// We do this to avoid deleting files if the volume is remounted while
// we are still processing the unmount event.
ContentValues values = new ContentValues();
values.put(Files.FileColumns.DATA, "");
String where = FileColumns.STORAGE_ID + "=?";
String[] whereArgs = new String[] { Integer.toString(storage.getStorageId()) };
db.update("files", values, where, whereArgs);
// now delete the records
db.delete("files", where, whereArgs);
// notify on media Uris as well as the files Uri
context.getContentResolver().notifyChange(
Audio.Media.getContentUri(EXTERNAL_VOLUME), null);
context.getContentResolver().notifyChange(
Images.Media.getContentUri(EXTERNAL_VOLUME), null);
context.getContentResolver().notifyChange(
Video.Media.getContentUri(EXTERNAL_VOLUME), null);
context.getContentResolver().notifyChange(
Files.getContentUri(EXTERNAL_VOLUME), null);
} catch (Exception e) {
Log.e(TAG, "exception deleting storage entries", e);
} finally {
context.sendBroadcast(
new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, uri));
mDisableMtpObjectCallbacks = false;
}
}
}
}
}
}
};
// set to disable sending events when the operation originates from MTP
private boolean mDisableMtpObjectCallbacks;
private final SQLiteDatabase.CustomFunction mObjectRemovedCallback =
new SQLiteDatabase.CustomFunction() {
public void callback(String[] args) {
// We could remove only the deleted entry from the cache, but that
// requires the path, which we don't have here, so instead we just
// clear the entire cache.
// TODO: include the path in the callback and only remove the affected
// entry from the cache
mDirectoryCache.clear();
// do nothing if the operation originated from MTP
if (mDisableMtpObjectCallbacks) return;
Log.d(TAG, "object removed " + args[0]);
IMtpService mtpService = mMtpService;
if (mtpService != null) {
try {
sendObjectRemoved(Integer.parseInt(args[0]));
} catch (NumberFormatException e) {
Log.e(TAG, "NumberFormatException in mObjectRemovedCallback", e);
}
}
}
};
/**
* Wrapper class for a specific database (associated with one particular
* external card, or with internal storage). Can open the actual database
* on demand, create and upgrade the schema, etc.
*/
static final class DatabaseHelper extends SQLiteOpenHelper {
final Context mContext;
final String mName;
final boolean mInternal; // True if this is the internal database
final boolean mEarlyUpgrade;
final SQLiteDatabase.CustomFunction mObjectRemovedCallback;
boolean mUpgradeAttempted; // Used for upgrade error handling
int mNumQueries;
int mNumUpdates;
int mNumInserts;
int mNumDeletes;
long mScanStartTime;
long mScanStopTime;
// In memory caches of artist and album data.
HashMap<String, Long> mArtistCache = new HashMap<String, Long>();
HashMap<String, Long> mAlbumCache = new HashMap<String, Long>();
public DatabaseHelper(Context context, String name, boolean internal,
boolean earlyUpgrade,
SQLiteDatabase.CustomFunction objectRemovedCallback) {
super(context, name, null, getDatabaseVersion(context));
mContext = context;
mName = name;
mInternal = internal;
mEarlyUpgrade = earlyUpgrade;
mObjectRemovedCallback = objectRemovedCallback;
setWriteAheadLoggingEnabled(true);
}
/**
* Creates database the first time we try to open it.
*/
@Override
public void onCreate(final SQLiteDatabase db) {
updateDatabase(mContext, db, mInternal, 0, getDatabaseVersion(mContext));
}
/**
* Updates the database format when a new content provider is used
* with an older database format.
*/
@Override
public void onUpgrade(final SQLiteDatabase db, final int oldV, final int newV) {
mUpgradeAttempted = true;
updateDatabase(mContext, db, mInternal, oldV, newV);
}
@Override
public synchronized SQLiteDatabase getWritableDatabase() {
SQLiteDatabase result = null;
mUpgradeAttempted = false;
try {
result = super.getWritableDatabase();
} catch (Exception e) {
if (!mUpgradeAttempted) {
Log.e(TAG, "failed to open database " + mName, e);
return null;
}
}
// If we failed to open the database during an upgrade, delete the file and try again.
// This will result in the creation of a fresh database, which will be repopulated
// when the media scanner runs.
if (result == null && mUpgradeAttempted) {
mContext.getDatabasePath(mName).delete();
result = super.getWritableDatabase();
}
return result;
}
/**
* For devices that have removable storage, we support keeping multiple databases
* to allow users to switch between a number of cards.
* On such devices, touch this particular database and garbage collect old databases.
* An LRU cache system is used to clean up databases for old external
* storage volumes.
*/
@Override
public void onOpen(SQLiteDatabase db) {
if (mInternal) return; // The internal database is kept separately.
if (mEarlyUpgrade) return; // Doing early upgrade.
if (mObjectRemovedCallback != null) {
db.addCustomFunction("_OBJECT_REMOVED", 1, mObjectRemovedCallback);
}
// the code below is only needed on devices with removable storage
if (!Environment.isExternalStorageRemovable()) return;
// touch the database file to show it is most recently used
File file = new File(db.getPath());
long now = System.currentTimeMillis();
file.setLastModified(now);
// delete least recently used databases if we are over the limit
String[] databases = mContext.databaseList();
int count = databases.length;
int limit = MAX_EXTERNAL_DATABASES;
// delete external databases that have not been used in the past two months
long twoMonthsAgo = now - OBSOLETE_DATABASE_DB;
for (int i = 0; i < databases.length; i++) {
File other = mContext.getDatabasePath(databases[i]);
if (INTERNAL_DATABASE_NAME.equals(databases[i]) || file.equals(other)) {
databases[i] = null;
count--;
if (file.equals(other)) {
// reduce limit to account for the existence of the database we
// are about to open, which we removed from the list.
limit--;
}
} else {
long time = other.lastModified();
if (time < twoMonthsAgo) {
if (LOCAL_LOGV) Log.v(TAG, "Deleting old database " + databases[i]);
mContext.deleteDatabase(databases[i]);
databases[i] = null;
count--;
}
}
}
// delete least recently used databases until
// we are no longer over the limit
while (count > limit) {
int lruIndex = -1;
long lruTime = 0;
for (int i = 0; i < databases.length; i++) {
if (databases[i] != null) {
long time = mContext.getDatabasePath(databases[i]).lastModified();
if (lruTime == 0 || time < lruTime) {
lruIndex = i;
lruTime = time;
}
}
}
// delete least recently used database
if (lruIndex != -1) {
if (LOCAL_LOGV) Log.v(TAG, "Deleting old database " + databases[lruIndex]);
mContext.deleteDatabase(databases[lruIndex]);
databases[lruIndex] = null;
count--;
}
}
}
}
// synchronize on mMtpServiceConnection when accessing mMtpService
private IMtpService mMtpService;
private final ServiceConnection mMtpServiceConnection = new ServiceConnection() {
public void onServiceConnected(ComponentName className, android.os.IBinder service) {
synchronized (this) {
mMtpService = IMtpService.Stub.asInterface(service);
}
}
public void onServiceDisconnected(ComponentName className) {
synchronized (this) {
mMtpService = null;
}
}
};
private static final String[] sDefaultFolderNames = {
Environment.DIRECTORY_MUSIC,
Environment.DIRECTORY_PODCASTS,
Environment.DIRECTORY_RINGTONES,
Environment.DIRECTORY_ALARMS,
Environment.DIRECTORY_NOTIFICATIONS,
Environment.DIRECTORY_PICTURES,
Environment.DIRECTORY_MOVIES,
Environment.DIRECTORY_DOWNLOADS,
Environment.DIRECTORY_DCIM,
};
// creates default folders (Music, Downloads, etc)
private void createDefaultFolders(DatabaseHelper helper, SQLiteDatabase db) {
// Use a SharedPreference to ensure we only do this once.
// We don't want to annoy the user by recreating the directories
// after she has deleted them.
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext());
if (prefs.getInt("created_default_folders", 0) == 0) {
for (String folderName : sDefaultFolderNames) {
File file = Environment.getExternalStoragePublicDirectory(folderName);
if (!file.exists()) {
file.mkdirs();
insertDirectory(helper, db, file.getAbsolutePath());
}
}
SharedPreferences.Editor e = prefs.edit();
e.clear();
e.putInt("created_default_folders", 1);
e.commit();
}
}
public static int getDatabaseVersion(Context context) {
try {
return context.getPackageManager().getPackageInfo(
context.getPackageName(), 0).versionCode;
} catch (NameNotFoundException e) {
throw new RuntimeException("couldn't get version code for " + context);
}
}
@Override
public boolean onCreate() {
final Context context = getContext();
sArtistAlbumsMap.put(MediaStore.Audio.Albums._ID, "audio.album_id AS " +
MediaStore.Audio.Albums._ID);
sArtistAlbumsMap.put(MediaStore.Audio.Albums.ALBUM, "album");
sArtistAlbumsMap.put(MediaStore.Audio.Albums.ALBUM_KEY, "album_key");
sArtistAlbumsMap.put(MediaStore.Audio.Albums.FIRST_YEAR, "MIN(year) AS " +
MediaStore.Audio.Albums.FIRST_YEAR);
sArtistAlbumsMap.put(MediaStore.Audio.Albums.LAST_YEAR, "MAX(year) AS " +
MediaStore.Audio.Albums.LAST_YEAR);
sArtistAlbumsMap.put(MediaStore.Audio.Media.ARTIST, "artist");
sArtistAlbumsMap.put(MediaStore.Audio.Media.ARTIST_ID, "artist");
sArtistAlbumsMap.put(MediaStore.Audio.Media.ARTIST_KEY, "artist_key");
sArtistAlbumsMap.put(MediaStore.Audio.Albums.NUMBER_OF_SONGS, "count(*) AS " +
MediaStore.Audio.Albums.NUMBER_OF_SONGS);
sArtistAlbumsMap.put(MediaStore.Audio.Albums.ALBUM_ART, "album_art._data AS " +
MediaStore.Audio.Albums.ALBUM_ART);
mSearchColsBasic[SEARCH_COLUMN_BASIC_TEXT2] =
mSearchColsBasic[SEARCH_COLUMN_BASIC_TEXT2].replaceAll(
"%1", context.getString(R.string.artist_label));
mDatabases = new HashMap<String, DatabaseHelper>();
attachVolume(INTERNAL_VOLUME);
IntentFilter iFilter = new IntentFilter(Intent.ACTION_MEDIA_EJECT);
iFilter.addDataScheme("file");
context.registerReceiver(mUnmountReceiver, iFilter);
mCaseInsensitivePaths = true;
StorageManager storageManager =
(StorageManager)context.getSystemService(Context.STORAGE_SERVICE);
mExternalStoragePaths = storageManager.getVolumePaths();
// open external database if external storage is mounted
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state) ||
Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
attachVolume(EXTERNAL_VOLUME);
}
HandlerThread ht = new HandlerThread("thumbs thread", Process.THREAD_PRIORITY_BACKGROUND);
ht.start();
mThumbHandler = new Handler(ht.getLooper()) {
@Override
public void handleMessage(Message msg) {
if (msg.what == IMAGE_THUMB) {
synchronized (mMediaThumbQueue) {
mCurrentThumbRequest = mMediaThumbQueue.poll();
}
if (mCurrentThumbRequest == null) {
Log.w(TAG, "Have message but no request?");
} else {
try {
File origFile = new File(mCurrentThumbRequest.mPath);
if (origFile.exists() && origFile.length() > 0) {
mCurrentThumbRequest.execute();
} else {
// original file hasn't been stored yet
synchronized (mMediaThumbQueue) {
Log.w(TAG, "original file hasn't been stored yet: " + mCurrentThumbRequest.mPath);
}
}
} catch (IOException ex) {
Log.w(TAG, ex);
} catch (UnsupportedOperationException ex) {
// This could happen if we unplug the sd card during insert/update/delete
// See getDatabaseForUri.
Log.w(TAG, ex);
} catch (OutOfMemoryError err) {
/*
* Note: Catching Errors is in most cases considered
* bad practice. However, in this case it is
* motivated by the fact that corrupt or very large
* images may cause a huge allocation to be
* requested and denied. The bitmap handling API in
* Android offers no other way to guard against
* these problems than by catching OutOfMemoryError.
*/
Log.w(TAG, err);
} finally {
synchronized (mCurrentThumbRequest) {
mCurrentThumbRequest.mState = MediaThumbRequest.State.DONE;
mCurrentThumbRequest.notifyAll();
}
}
}
} else if (msg.what == ALBUM_THUMB) {
ThumbData d;
synchronized (mThumbRequestStack) {
d = (ThumbData)mThumbRequestStack.pop();
}
makeThumbInternal(d);
synchronized (mPendingThumbs) {
mPendingThumbs.remove(d.path);
}
}
}
};
return true;
}
private static final String IMAGE_COLUMNS =
"_data,_size,_display_name,mime_type,title,date_added," +
"date_modified,description,picasa_id,isprivate,latitude,longitude," +
"datetaken,orientation,mini_thumb_magic,bucket_id,bucket_display_name," +
"width,height";
private static final String IMAGE_COLUMNSv407 =
"_data,_size,_display_name,mime_type,title,date_added," +
"date_modified,description,picasa_id,isprivate,latitude,longitude," +
"datetaken,orientation,mini_thumb_magic,bucket_id,bucket_display_name";
private static final String AUDIO_COLUMNSv99 =
"_data,_display_name,_size,mime_type,date_added," +
"date_modified,title,title_key,duration,artist_id,composer,album_id," +
"track,year,is_ringtone,is_music,is_alarm,is_notification,is_podcast," +
"bookmark";
private static final String AUDIO_COLUMNSv100 =
"_data,_display_name,_size,mime_type,date_added," +
"date_modified,title,title_key,duration,artist_id,composer,album_id," +
"track,year,is_ringtone,is_music,is_alarm,is_notification,is_podcast," +
"bookmark,album_artist";
private static final String AUDIO_COLUMNSv405 =
"_data,_display_name,_size,mime_type,date_added,is_drm," +
"date_modified,title,title_key,duration,artist_id,composer,album_id," +
"track,year,is_ringtone,is_music,is_alarm,is_notification,is_podcast," +
"bookmark,album_artist";
private static final String VIDEO_COLUMNS =
"_data,_display_name,_size,mime_type,date_added,date_modified," +
"title,duration,artist,album,resolution,description,isprivate,tags," +
"category,language,mini_thumb_data,latitude,longitude,datetaken," +
"mini_thumb_magic,bucket_id,bucket_display_name,bookmark,width," +
"height";
private static final String VIDEO_COLUMNSv407 =
"_data,_display_name,_size,mime_type,date_added,date_modified," +
"title,duration,artist,album,resolution,description,isprivate,tags," +
"category,language,mini_thumb_data,latitude,longitude,datetaken," +
"mini_thumb_magic,bucket_id,bucket_display_name, bookmark";
private static final String PLAYLIST_COLUMNS = "_data,name,date_added,date_modified";
/**
* This method takes care of updating all the tables in the database to the
* current version, creating them if necessary.
* This method can only update databases at schema 63 or higher, which was
* created August 1, 2008. Older database will be cleared and recreated.
* @param db Database
* @param internal True if this is the internal media database
*/
private static void updateDatabase(Context context, SQLiteDatabase db, boolean internal,
int fromVersion, int toVersion) {
// sanity checks
int dbversion = getDatabaseVersion(context);
if (toVersion != dbversion) {
Log.e(TAG, "Illegal update request. Got " + toVersion + ", expected " + dbversion);
throw new IllegalArgumentException();
} else if (fromVersion > toVersion) {
Log.e(TAG, "Illegal update request: can't downgrade from " + fromVersion +
" to " + toVersion + ". Did you forget to wipe data?");
throw new IllegalArgumentException();
}
long startTime = SystemClock.currentTimeMicro();
// Revisions 84-86 were a failed attempt at supporting the "album artist" id3 tag.
// We can't downgrade from those revisions, so start over.
// (the initial change to do this was wrong, so now we actually need to start over
// if the database version is 84-89)
// Post-gingerbread, revisions 91-94 were broken in a way that is not easy to repair.
// However version 91 was reused in a divergent development path for gingerbread,
// so we need to support upgrades from 91.
// Therefore we will only force a reset for versions 92 - 94.
if (fromVersion < 63 || (fromVersion >= 84 && fromVersion <= 89) ||
(fromVersion >= 92 && fromVersion <= 94)) {
// Drop everything and start over.
Log.i(TAG, "Upgrading media database from version " +
fromVersion + " to " + toVersion + ", which will destroy all old data");
fromVersion = 63;
db.execSQL("DROP TABLE IF EXISTS images");
db.execSQL("DROP TRIGGER IF EXISTS images_cleanup");
db.execSQL("DROP TABLE IF EXISTS thumbnails");
db.execSQL("DROP TRIGGER IF EXISTS thumbnails_cleanup");
db.execSQL("DROP TABLE IF EXISTS audio_meta");
db.execSQL("DROP TABLE IF EXISTS artists");
db.execSQL("DROP TABLE IF EXISTS albums");
db.execSQL("DROP TABLE IF EXISTS album_art");
db.execSQL("DROP VIEW IF EXISTS artist_info");
db.execSQL("DROP VIEW IF EXISTS album_info");
db.execSQL("DROP VIEW IF EXISTS artists_albums_map");
db.execSQL("DROP TRIGGER IF EXISTS audio_meta_cleanup");
db.execSQL("DROP TABLE IF EXISTS audio_genres");
db.execSQL("DROP TABLE IF EXISTS audio_genres_map");
db.execSQL("DROP TRIGGER IF EXISTS audio_genres_cleanup");
db.execSQL("DROP TABLE IF EXISTS audio_playlists");
db.execSQL("DROP TABLE IF EXISTS audio_playlists_map");
db.execSQL("DROP TRIGGER IF EXISTS audio_playlists_cleanup");
db.execSQL("DROP TRIGGER IF EXISTS albumart_cleanup1");
db.execSQL("DROP TRIGGER IF EXISTS albumart_cleanup2");
db.execSQL("DROP TABLE IF EXISTS video");
db.execSQL("DROP TRIGGER IF EXISTS video_cleanup");
db.execSQL("DROP TABLE IF EXISTS objects");
db.execSQL("DROP TRIGGER IF EXISTS images_objects_cleanup");
db.execSQL("DROP TRIGGER IF EXISTS audio_objects_cleanup");
db.execSQL("DROP TRIGGER IF EXISTS video_objects_cleanup");
db.execSQL("DROP TRIGGER IF EXISTS playlists_objects_cleanup");
db.execSQL("CREATE TABLE IF NOT EXISTS images (" +
"_id INTEGER PRIMARY KEY," +
"_data TEXT," +
"_size INTEGER," +
"_display_name TEXT," +
"mime_type TEXT," +
"title TEXT," +
"date_added INTEGER," +
"date_modified INTEGER," +
"description TEXT," +
"picasa_id TEXT," +
"isprivate INTEGER," +
"latitude DOUBLE," +
"longitude DOUBLE," +
"datetaken INTEGER," +
"orientation INTEGER," +
"mini_thumb_magic INTEGER," +
"bucket_id TEXT," +
"bucket_display_name TEXT" +
");");
db.execSQL("CREATE INDEX IF NOT EXISTS mini_thumb_magic_index on images(mini_thumb_magic);");
db.execSQL("CREATE TRIGGER IF NOT EXISTS images_cleanup DELETE ON images " +
"BEGIN " +
"DELETE FROM thumbnails WHERE image_id = old._id;" +
"SELECT _DELETE_FILE(old._data);" +
"END");
// create image thumbnail table
db.execSQL("CREATE TABLE IF NOT EXISTS thumbnails (" +
"_id INTEGER PRIMARY KEY," +
"_data TEXT," +
"image_id INTEGER," +
"kind INTEGER," +
"width INTEGER," +
"height INTEGER" +
");");
db.execSQL("CREATE INDEX IF NOT EXISTS image_id_index on thumbnails(image_id);");
db.execSQL("CREATE TRIGGER IF NOT EXISTS thumbnails_cleanup DELETE ON thumbnails " +
"BEGIN " +
"SELECT _DELETE_FILE(old._data);" +
"END");
// Contains meta data about audio files
db.execSQL("CREATE TABLE IF NOT EXISTS audio_meta (" +
"_id INTEGER PRIMARY KEY," +
"_data TEXT UNIQUE NOT NULL," +
"_display_name TEXT," +
"_size INTEGER," +
"mime_type TEXT," +
"date_added INTEGER," +
"date_modified INTEGER," +
"title TEXT NOT NULL," +
"title_key TEXT NOT NULL," +
"duration INTEGER," +
"artist_id INTEGER," +
"composer TEXT," +
"album_id INTEGER," +
"track INTEGER," + // track is an integer to allow proper sorting
"year INTEGER CHECK(year!=0)," +
"is_ringtone INTEGER," +
"is_music INTEGER," +
"is_alarm INTEGER," +
"is_notification INTEGER" +
");");
// Contains a sort/group "key" and the preferred display name for artists
db.execSQL("CREATE TABLE IF NOT EXISTS artists (" +
"artist_id INTEGER PRIMARY KEY," +
"artist_key TEXT NOT NULL UNIQUE," +
"artist TEXT NOT NULL" +
");");
// Contains a sort/group "key" and the preferred display name for albums
db.execSQL("CREATE TABLE IF NOT EXISTS albums (" +
"album_id INTEGER PRIMARY KEY," +
"album_key TEXT NOT NULL UNIQUE," +
"album TEXT NOT NULL" +
");");
db.execSQL("CREATE TABLE IF NOT EXISTS album_art (" +
"album_id INTEGER PRIMARY KEY," +
"_data TEXT" +
");");
recreateAudioView(db);
// Provides some extra info about artists, like the number of tracks
// and albums for this artist
db.execSQL("CREATE VIEW IF NOT EXISTS artist_info AS " +
"SELECT artist_id AS _id, artist, artist_key, " +
"COUNT(DISTINCT album) AS number_of_albums, " +
"COUNT(*) AS number_of_tracks FROM audio WHERE is_music=1 "+
"GROUP BY artist_key;");
// Provides extra info albums, such as the number of tracks
db.execSQL("CREATE VIEW IF NOT EXISTS album_info AS " +
"SELECT audio.album_id AS _id, album, album_key, " +
"MIN(year) AS minyear, " +
"MAX(year) AS maxyear, artist, artist_id, artist_key, " +
"count(*) AS " + MediaStore.Audio.Albums.NUMBER_OF_SONGS +
",album_art._data AS album_art" +
" FROM audio LEFT OUTER JOIN album_art ON audio.album_id=album_art.album_id" +
" WHERE is_music=1 GROUP BY audio.album_id;");
// For a given artist_id, provides the album_id for albums on
// which the artist appears.
db.execSQL("CREATE VIEW IF NOT EXISTS artists_albums_map AS " +
"SELECT DISTINCT artist_id, album_id FROM audio_meta;");
/*
* Only external media volumes can handle genres, playlists, etc.
*/
if (!internal) {
// Cleans up when an audio file is deleted
db.execSQL("CREATE TRIGGER IF NOT EXISTS audio_meta_cleanup DELETE ON audio_meta " +
"BEGIN " +
"DELETE FROM audio_genres_map WHERE audio_id = old._id;" +
"DELETE FROM audio_playlists_map WHERE audio_id = old._id;" +
"END");
// Contains audio genre definitions
db.execSQL("CREATE TABLE IF NOT EXISTS audio_genres (" +
"_id INTEGER PRIMARY KEY," +
"name TEXT NOT NULL" +
");");
// Contains mappings between audio genres and audio files
db.execSQL("CREATE TABLE IF NOT EXISTS audio_genres_map (" +
"_id INTEGER PRIMARY KEY," +
"audio_id INTEGER NOT NULL," +
"genre_id INTEGER NOT NULL" +
");");
// Cleans up when an audio genre is delete
db.execSQL("CREATE TRIGGER IF NOT EXISTS audio_genres_cleanup DELETE ON audio_genres " +
"BEGIN " +
"DELETE FROM audio_genres_map WHERE genre_id = old._id;" +
"END");
// Contains audio playlist definitions
db.execSQL("CREATE TABLE IF NOT EXISTS audio_playlists (" +
"_id INTEGER PRIMARY KEY," +
"_data TEXT," + // _data is path for file based playlists, or null
"name TEXT NOT NULL," +
"date_added INTEGER," +
"date_modified INTEGER" +
");");
// Contains mappings between audio playlists and audio files
db.execSQL("CREATE TABLE IF NOT EXISTS audio_playlists_map (" +
"_id INTEGER PRIMARY KEY," +
"audio_id INTEGER NOT NULL," +
"playlist_id INTEGER NOT NULL," +
"play_order INTEGER NOT NULL" +
");");
// Cleans up when an audio playlist is deleted
db.execSQL("CREATE TRIGGER IF NOT EXISTS audio_playlists_cleanup DELETE ON audio_playlists " +
"BEGIN " +
"DELETE FROM audio_playlists_map WHERE playlist_id = old._id;" +
"SELECT _DELETE_FILE(old._data);" +
"END");
// Cleans up album_art table entry when an album is deleted
db.execSQL("CREATE TRIGGER IF NOT EXISTS albumart_cleanup1 DELETE ON albums " +
"BEGIN " +
"DELETE FROM album_art WHERE album_id = old.album_id;" +
"END");
// Cleans up album_art when an album is deleted
db.execSQL("CREATE TRIGGER IF NOT EXISTS albumart_cleanup2 DELETE ON album_art " +
"BEGIN " +
"SELECT _DELETE_FILE(old._data);" +
"END");
}
// Contains meta data about video files
db.execSQL("CREATE TABLE IF NOT EXISTS video (" +
"_id INTEGER PRIMARY KEY," +
"_data TEXT NOT NULL," +
"_display_name TEXT," +
"_size INTEGER," +
"mime_type TEXT," +
"date_added INTEGER," +
"date_modified INTEGER," +
"title TEXT," +
"duration INTEGER," +
"artist TEXT," +
"album TEXT," +
"resolution TEXT," +
"description TEXT," +
"isprivate INTEGER," + // for YouTube videos
"tags TEXT," + // for YouTube videos
"category TEXT," + // for YouTube videos
"language TEXT," + // for YouTube videos
"mini_thumb_data TEXT," +
"latitude DOUBLE," +
"longitude DOUBLE," +
"datetaken INTEGER," +
"mini_thumb_magic INTEGER" +
");");
db.execSQL("CREATE TRIGGER IF NOT EXISTS video_cleanup DELETE ON video " +
"BEGIN " +
"SELECT _DELETE_FILE(old._data);" +
"END");
}
// At this point the database is at least at schema version 63 (it was
// either created at version 63 by the code above, or was already at
// version 63 or later)
if (fromVersion < 64) {
// create the index that updates the database to schema version 64
db.execSQL("CREATE INDEX IF NOT EXISTS sort_index on images(datetaken ASC, _id ASC);");
}
/*
* Android 1.0 shipped with database version 64
*/
if (fromVersion < 65) {
// create the index that updates the database to schema version 65
db.execSQL("CREATE INDEX IF NOT EXISTS titlekey_index on audio_meta(title_key);");
}
// In version 66, originally we updateBucketNames(db, "images"),
// but we need to do it in version 89 and therefore save the update here.
if (fromVersion < 67) {
// create the indices that update the database to schema version 67
db.execSQL("CREATE INDEX IF NOT EXISTS albumkey_index on albums(album_key);");
db.execSQL("CREATE INDEX IF NOT EXISTS artistkey_index on artists(artist_key);");
}
if (fromVersion < 68) {
// Create bucket_id and bucket_display_name columns for the video table.
db.execSQL("ALTER TABLE video ADD COLUMN bucket_id TEXT;");
db.execSQL("ALTER TABLE video ADD COLUMN bucket_display_name TEXT");
// In version 68, originally we updateBucketNames(db, "video"),
// but we need to do it in version 89 and therefore save the update here.
}
if (fromVersion < 69) {
updateDisplayName(db, "images");
}
if (fromVersion < 70) {
// Create bookmark column for the video table.
db.execSQL("ALTER TABLE video ADD COLUMN bookmark INTEGER;");
}
if (fromVersion < 71) {
// There is no change to the database schema, however a code change
// fixed parsing of metadata for certain files bought from the
// iTunes music store, so we want to rescan files that might need it.
// We do this by clearing the modification date in the database for
// those files, so that the media scanner will see them as updated
// and rescan them.
db.execSQL("UPDATE audio_meta SET date_modified=0 WHERE _id IN (" +
"SELECT _id FROM audio where mime_type='audio/mp4' AND " +
"artist='" + MediaStore.UNKNOWN_STRING + "' AND " +
"album='" + MediaStore.UNKNOWN_STRING + "'" +
");");
}
if (fromVersion < 72) {
// Create is_podcast and bookmark columns for the audio table.
db.execSQL("ALTER TABLE audio_meta ADD COLUMN is_podcast INTEGER;");
db.execSQL("UPDATE audio_meta SET is_podcast=1 WHERE _data LIKE '%/podcasts/%';");
db.execSQL("UPDATE audio_meta SET is_music=0 WHERE is_podcast=1" +
" AND _data NOT LIKE '%/music/%';");
db.execSQL("ALTER TABLE audio_meta ADD COLUMN bookmark INTEGER;");
// New columns added to tables aren't visible in views on those tables
// without opening and closing the database (or using the 'vacuum' command,
// which we can't do here because all this code runs inside a transaction).
// To work around this, we drop and recreate the affected view and trigger.
recreateAudioView(db);
}
/*
* Android 1.5 shipped with database version 72
*/
if (fromVersion < 73) {
// There is no change to the database schema, but we now do case insensitive
// matching of folder names when determining whether something is music, a
// ringtone, podcast, etc, so we might need to reclassify some files.
db.execSQL("UPDATE audio_meta SET is_music=1 WHERE is_music=0 AND " +
"_data LIKE '%/music/%';");
db.execSQL("UPDATE audio_meta SET is_ringtone=1 WHERE is_ringtone=0 AND " +
"_data LIKE '%/ringtones/%';");
db.execSQL("UPDATE audio_meta SET is_notification=1 WHERE is_notification=0 AND " +
"_data LIKE '%/notifications/%';");
db.execSQL("UPDATE audio_meta SET is_alarm=1 WHERE is_alarm=0 AND " +
"_data LIKE '%/alarms/%';");
db.execSQL("UPDATE audio_meta SET is_podcast=1 WHERE is_podcast=0 AND " +
"_data LIKE '%/podcasts/%';");
}
if (fromVersion < 74) {
// This view is used instead of the audio view by the union below, to force
// sqlite to use the title_key index. This greatly reduces memory usage
// (no separate copy pass needed for sorting, which could cause errors on
// large datasets) and improves speed (by about 35% on a large dataset)
db.execSQL("CREATE VIEW IF NOT EXISTS searchhelpertitle AS SELECT * FROM audio " +
"ORDER BY title_key;");
db.execSQL("CREATE VIEW IF NOT EXISTS search AS " +
"SELECT _id," +
"'artist' AS mime_type," +
"artist," +
"NULL AS album," +
"NULL AS title," +
"artist AS text1," +
"NULL AS text2," +
"number_of_albums AS data1," +
"number_of_tracks AS data2," +
"artist_key AS match," +
"'content://media/external/audio/artists/'||_id AS suggest_intent_data," +
"1 AS grouporder " +
"FROM artist_info WHERE (artist!='" + MediaStore.UNKNOWN_STRING + "') " +
"UNION ALL " +
"SELECT _id," +
"'album' AS mime_type," +
"artist," +
"album," +
"NULL AS title," +
"album AS text1," +
"artist AS text2," +
"NULL AS data1," +
"NULL AS data2," +
"artist_key||' '||album_key AS match," +
"'content://media/external/audio/albums/'||_id AS suggest_intent_data," +
"2 AS grouporder " +
"FROM album_info WHERE (album!='" + MediaStore.UNKNOWN_STRING + "') " +
"UNION ALL " +
"SELECT searchhelpertitle._id AS _id," +
"mime_type," +
"artist," +
"album," +
"title," +
"title AS text1," +
"artist AS text2," +
"NULL AS data1," +
"NULL AS data2," +
"artist_key||' '||album_key||' '||title_key AS match," +
"'content://media/external/audio/media/'||searchhelpertitle._id AS " +
"suggest_intent_data," +
"3 AS grouporder " +
"FROM searchhelpertitle WHERE (title != '') "
);
}
if (fromVersion < 75) {
// Force a rescan of the audio entries so we can apply the new logic to
// distinguish same-named albums.
db.execSQL("UPDATE audio_meta SET date_modified=0;");
db.execSQL("DELETE FROM albums");
}
if (fromVersion < 76) {
// We now ignore double quotes when building the key, so we have to remove all of them
// from existing keys.
db.execSQL("UPDATE audio_meta SET title_key=" +
"REPLACE(title_key,x'081D08C29F081D',x'081D') " +
"WHERE title_key LIKE '%'||x'081D08C29F081D'||'%';");
db.execSQL("UPDATE albums SET album_key=" +
"REPLACE(album_key,x'081D08C29F081D',x'081D') " +
"WHERE album_key LIKE '%'||x'081D08C29F081D'||'%';");
db.execSQL("UPDATE artists SET artist_key=" +
"REPLACE(artist_key,x'081D08C29F081D',x'081D') " +
"WHERE artist_key LIKE '%'||x'081D08C29F081D'||'%';");
}
/*
* Android 1.6 shipped with database version 76
*/
if (fromVersion < 77) {
// create video thumbnail table
db.execSQL("CREATE TABLE IF NOT EXISTS videothumbnails (" +
"_id INTEGER PRIMARY KEY," +
"_data TEXT," +
"video_id INTEGER," +
"kind INTEGER," +
"width INTEGER," +
"height INTEGER" +
");");
db.execSQL("CREATE INDEX IF NOT EXISTS video_id_index on videothumbnails(video_id);");
db.execSQL("CREATE TRIGGER IF NOT EXISTS videothumbnails_cleanup DELETE ON videothumbnails " +
"BEGIN " +
"SELECT _DELETE_FILE(old._data);" +
"END");
}
/*
* Android 2.0 and 2.0.1 shipped with database version 77
*/
if (fromVersion < 78) {
// Force a rescan of the video entries so we can update
// latest changed DATE_TAKEN units (in milliseconds).
db.execSQL("UPDATE video SET date_modified=0;");
}
/*
* Android 2.1 shipped with database version 78
*/
if (fromVersion < 79) {
// move /sdcard/albumthumbs to
// /sdcard/Android/data/com.android.providers.media/albumthumbs,
// and update the database accordingly
String oldthumbspath = mExternalStoragePaths[0] + "/albumthumbs";
String newthumbspath = mExternalStoragePaths[0] + "/" + ALBUM_THUMB_FOLDER;
File thumbsfolder = new File(oldthumbspath);
if (thumbsfolder.exists()) {
// move folder to its new location
File newthumbsfolder = new File(newthumbspath);
newthumbsfolder.getParentFile().mkdirs();
if(thumbsfolder.renameTo(newthumbsfolder)) {
// update the database
db.execSQL("UPDATE album_art SET _data=REPLACE(_data, '" +
oldthumbspath + "','" + newthumbspath + "');");
}
}
}
if (fromVersion < 80) {
// Force rescan of image entries to update DATE_TAKEN as UTC timestamp.
db.execSQL("UPDATE images SET date_modified=0;");
}
if (fromVersion < 81 && !internal) {
// Delete entries starting with /mnt/sdcard. This is for the benefit
// of users running builds between 2.0.1 and 2.1 final only, since
// users updating from 2.0 or earlier will not have such entries.
// First we need to update the _data fields in the affected tables, since
// otherwise deleting the entries will also delete the underlying files
// (via a trigger), and we want to keep them.
db.execSQL("UPDATE audio_playlists SET _data='////' WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE images SET _data='////' WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE video SET _data='////' WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE videothumbnails SET _data='////' WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE thumbnails SET _data='////' WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE album_art SET _data='////' WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE audio_meta SET _data='////' WHERE _data LIKE '/mnt/sdcard/%';");
// Once the paths have been renamed, we can safely delete the entries
db.execSQL("DELETE FROM audio_playlists WHERE _data IS '////';");
db.execSQL("DELETE FROM images WHERE _data IS '////';");
db.execSQL("DELETE FROM video WHERE _data IS '////';");
db.execSQL("DELETE FROM videothumbnails WHERE _data IS '////';");
db.execSQL("DELETE FROM thumbnails WHERE _data IS '////';");
db.execSQL("DELETE FROM audio_meta WHERE _data IS '////';");
db.execSQL("DELETE FROM album_art WHERE _data IS '////';");
// rename existing entries starting with /sdcard to /mnt/sdcard
db.execSQL("UPDATE audio_meta" +
" SET _data='/mnt/sdcard'||SUBSTR(_data,8) WHERE _data LIKE '/sdcard/%';");
db.execSQL("UPDATE audio_playlists" +
" SET _data='/mnt/sdcard'||SUBSTR(_data,8) WHERE _data LIKE '/sdcard/%';");
db.execSQL("UPDATE images" +
" SET _data='/mnt/sdcard'||SUBSTR(_data,8) WHERE _data LIKE '/sdcard/%';");
db.execSQL("UPDATE video" +
" SET _data='/mnt/sdcard'||SUBSTR(_data,8) WHERE _data LIKE '/sdcard/%';");
db.execSQL("UPDATE videothumbnails" +
" SET _data='/mnt/sdcard'||SUBSTR(_data,8) WHERE _data LIKE '/sdcard/%';");
db.execSQL("UPDATE thumbnails" +
" SET _data='/mnt/sdcard'||SUBSTR(_data,8) WHERE _data LIKE '/sdcard/%';");
db.execSQL("UPDATE album_art" +
" SET _data='/mnt/sdcard'||SUBSTR(_data,8) WHERE _data LIKE '/sdcard/%';");
// Delete albums and artists, then clear the modification time on songs, which
// will cause the media scanner to rescan everything, rebuilding the artist and
// album tables along the way, while preserving playlists.
// We need this rescan because ICU also changed, and now generates different
// collation keys
db.execSQL("DELETE from albums");
db.execSQL("DELETE from artists");
db.execSQL("UPDATE audio_meta SET date_modified=0;");
}
if (fromVersion < 82) {
// recreate this view with the correct "group by" specifier
db.execSQL("DROP VIEW IF EXISTS artist_info");
db.execSQL("CREATE VIEW IF NOT EXISTS artist_info AS " +
"SELECT artist_id AS _id, artist, artist_key, " +
"COUNT(DISTINCT album_key) AS number_of_albums, " +
"COUNT(*) AS number_of_tracks FROM audio WHERE is_music=1 "+
"GROUP BY artist_key;");
}
/* we skipped over version 83, and reverted versions 84, 85 and 86 */
if (fromVersion < 87) {
// The fastscroll thumb needs an index on the strings being displayed,
// otherwise the queries it does to determine the correct position
// becomes really inefficient
db.execSQL("CREATE INDEX IF NOT EXISTS title_idx on audio_meta(title);");
db.execSQL("CREATE INDEX IF NOT EXISTS artist_idx on artists(artist);");
db.execSQL("CREATE INDEX IF NOT EXISTS album_idx on albums(album);");
}
if (fromVersion < 88) {
// Clean up a few more things from versions 84/85/86, and recreate
// the few things worth keeping from those changes.
db.execSQL("DROP TRIGGER IF EXISTS albums_update1;");
db.execSQL("DROP TRIGGER IF EXISTS albums_update2;");
db.execSQL("DROP TRIGGER IF EXISTS albums_update3;");
db.execSQL("DROP TRIGGER IF EXISTS albums_update4;");
db.execSQL("DROP TRIGGER IF EXISTS artist_update1;");
db.execSQL("DROP TRIGGER IF EXISTS artist_update2;");
db.execSQL("DROP TRIGGER IF EXISTS artist_update3;");
db.execSQL("DROP TRIGGER IF EXISTS artist_update4;");
db.execSQL("DROP VIEW IF EXISTS album_artists;");
db.execSQL("CREATE INDEX IF NOT EXISTS album_id_idx on audio_meta(album_id);");
db.execSQL("CREATE INDEX IF NOT EXISTS artist_id_idx on audio_meta(artist_id);");
// For a given artist_id, provides the album_id for albums on
// which the artist appears.
db.execSQL("CREATE VIEW IF NOT EXISTS artists_albums_map AS " +
"SELECT DISTINCT artist_id, album_id FROM audio_meta;");
}
// In version 89, originally we updateBucketNames(db, "images") and
// updateBucketNames(db, "video"), but in version 101 we now updateBucketNames
// for all files and therefore can save the update here.
if (fromVersion < 91) {
// Never query by mini_thumb_magic_index
db.execSQL("DROP INDEX IF EXISTS mini_thumb_magic_index");
// sort the items by taken date in each bucket
db.execSQL("CREATE INDEX IF NOT EXISTS image_bucket_index ON images(bucket_id, datetaken)");
db.execSQL("CREATE INDEX IF NOT EXISTS video_bucket_index ON video(bucket_id, datetaken)");
}
// Gingerbread ended up going to version 100, but didn't yet have the "files"
// table, so we need to create that if we're at 100 or lower. This means
// we won't be able to upgrade pre-release Honeycomb.
if (fromVersion <= 100) {
// Remove various stages of work in progress for MTP support
db.execSQL("DROP TABLE IF EXISTS objects");
db.execSQL("DROP TABLE IF EXISTS files");
db.execSQL("DROP TRIGGER IF EXISTS images_objects_cleanup;");
db.execSQL("DROP TRIGGER IF EXISTS audio_objects_cleanup;");
db.execSQL("DROP TRIGGER IF EXISTS video_objects_cleanup;");
db.execSQL("DROP TRIGGER IF EXISTS playlists_objects_cleanup;");
db.execSQL("DROP TRIGGER IF EXISTS files_cleanup_images;");
db.execSQL("DROP TRIGGER IF EXISTS files_cleanup_audio;");
db.execSQL("DROP TRIGGER IF EXISTS files_cleanup_video;");
db.execSQL("DROP TRIGGER IF EXISTS files_cleanup_playlists;");
db.execSQL("DROP TRIGGER IF EXISTS media_cleanup;");
// Create a new table to manage all files in our storage.
// This contains a union of all the columns from the old
// images, audio_meta, videos and audio_playlist tables.
db.execSQL("CREATE TABLE files (" +
"_id INTEGER PRIMARY KEY AUTOINCREMENT," +
"_data TEXT," + // this can be null for playlists
"_size INTEGER," +
"format INTEGER," +
"parent INTEGER," +
"date_added INTEGER," +
"date_modified INTEGER," +
"mime_type TEXT," +
"title TEXT," +
"description TEXT," +
"_display_name TEXT," +
// for images
"picasa_id TEXT," +
"orientation INTEGER," +
// for images and video
"latitude DOUBLE," +
"longitude DOUBLE," +
"datetaken INTEGER," +
"mini_thumb_magic INTEGER," +
"bucket_id TEXT," +
"bucket_display_name TEXT," +
"isprivate INTEGER," +
// for audio
"title_key TEXT," +
"artist_id INTEGER," +
"album_id INTEGER," +
"composer TEXT," +
"track INTEGER," +
"year INTEGER CHECK(year!=0)," +
"is_ringtone INTEGER," +
"is_music INTEGER," +
"is_alarm INTEGER," +
"is_notification INTEGER," +
"is_podcast INTEGER," +
"album_artist TEXT," +
// for audio and video
"duration INTEGER," +
"bookmark INTEGER," +
// for video
"artist TEXT," +
"album TEXT," +
"resolution TEXT," +
"tags TEXT," +
"category TEXT," +
"language TEXT," +
"mini_thumb_data TEXT," +
// for playlists
"name TEXT," +
// media_type is used by the views to emulate the old
// images, audio_meta, videos and audio_playlist tables.
"media_type INTEGER," +
// Value of _id from the old media table.
// Used only for updating other tables during database upgrade.
"old_id INTEGER" +
");");
db.execSQL("CREATE INDEX path_index ON files(_data);");
db.execSQL("CREATE INDEX media_type_index ON files(media_type);");
// Copy all data from our obsolete tables to the new files table
// Copy audio records first, preserving the _id column.
// We do this to maintain compatibility for content Uris for ringtones.
// Unfortunately we cannot do this for images and videos as well.
// We choose to do this for the audio table because the fragility of Uris
// for ringtones are the most common problem we need to avoid.
db.execSQL("INSERT INTO files (_id," + AUDIO_COLUMNSv99 + ",old_id,media_type)" +
" SELECT _id," + AUDIO_COLUMNSv99 + ",_id," + FileColumns.MEDIA_TYPE_AUDIO +
" FROM audio_meta;");
db.execSQL("INSERT INTO files (" + IMAGE_COLUMNSv407 + ",old_id,media_type) SELECT "
+ IMAGE_COLUMNSv407 + ",_id," + FileColumns.MEDIA_TYPE_IMAGE + " FROM images;");
db.execSQL("INSERT INTO files (" + VIDEO_COLUMNSv407 + ",old_id,media_type) SELECT "
+ VIDEO_COLUMNSv407 + ",_id," + FileColumns.MEDIA_TYPE_VIDEO + " FROM video;");
if (!internal) {
db.execSQL("INSERT INTO files (" + PLAYLIST_COLUMNS + ",old_id,media_type) SELECT "
+ PLAYLIST_COLUMNS + ",_id," + FileColumns.MEDIA_TYPE_PLAYLIST
+ " FROM audio_playlists;");
}
// Delete the old tables
db.execSQL("DROP TABLE IF EXISTS images");
db.execSQL("DROP TABLE IF EXISTS audio_meta");
db.execSQL("DROP TABLE IF EXISTS video");
db.execSQL("DROP TABLE IF EXISTS audio_playlists");
// Create views to replace our old tables
db.execSQL("CREATE VIEW images AS SELECT _id," + IMAGE_COLUMNSv407 +
" FROM files WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_IMAGE + ";");
db.execSQL("CREATE VIEW audio_meta AS SELECT _id," + AUDIO_COLUMNSv100 +
" FROM files WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_AUDIO + ";");
db.execSQL("CREATE VIEW video AS SELECT _id," + VIDEO_COLUMNSv407 +
" FROM files WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_VIDEO + ";");
if (!internal) {
db.execSQL("CREATE VIEW audio_playlists AS SELECT _id," + PLAYLIST_COLUMNS +
" FROM files WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_PLAYLIST + ";");
}
// create temporary index to make the updates go faster
db.execSQL("CREATE INDEX tmp ON files(old_id);");
// update the image_id column in the thumbnails table.
db.execSQL("UPDATE thumbnails SET image_id = (SELECT _id FROM files "
+ "WHERE files.old_id = thumbnails.image_id AND files.media_type = "
+ FileColumns.MEDIA_TYPE_IMAGE + ");");
if (!internal) {
// update audio_id in the audio_genres_map table, and
// audio_playlists_map tables and playlist_id in the audio_playlists_map table
db.execSQL("UPDATE audio_genres_map SET audio_id = (SELECT _id FROM files "
+ "WHERE files.old_id = audio_genres_map.audio_id AND files.media_type = "
+ FileColumns.MEDIA_TYPE_AUDIO + ");");
db.execSQL("UPDATE audio_playlists_map SET audio_id = (SELECT _id FROM files "
+ "WHERE files.old_id = audio_playlists_map.audio_id "
+ "AND files.media_type = " + FileColumns.MEDIA_TYPE_AUDIO + ");");
db.execSQL("UPDATE audio_playlists_map SET playlist_id = (SELECT _id FROM files "
+ "WHERE files.old_id = audio_playlists_map.playlist_id "
+ "AND files.media_type = " + FileColumns.MEDIA_TYPE_PLAYLIST + ");");
}
// update video_id in the videothumbnails table.
db.execSQL("UPDATE videothumbnails SET video_id = (SELECT _id FROM files "
+ "WHERE files.old_id = videothumbnails.video_id AND files.media_type = "
+ FileColumns.MEDIA_TYPE_VIDEO + ");");
// we don't need this index anymore now
db.execSQL("DROP INDEX tmp;");
// update indices to work on the files table
db.execSQL("DROP INDEX IF EXISTS title_idx");
db.execSQL("DROP INDEX IF EXISTS album_id_idx");
db.execSQL("DROP INDEX IF EXISTS image_bucket_index");
db.execSQL("DROP INDEX IF EXISTS video_bucket_index");
db.execSQL("DROP INDEX IF EXISTS sort_index");
db.execSQL("DROP INDEX IF EXISTS titlekey_index");
db.execSQL("DROP INDEX IF EXISTS artist_id_idx");
db.execSQL("CREATE INDEX title_idx ON files(title);");
db.execSQL("CREATE INDEX album_id_idx ON files(album_id);");
db.execSQL("CREATE INDEX bucket_index ON files(bucket_id, datetaken);");
db.execSQL("CREATE INDEX sort_index ON files(datetaken ASC, _id ASC);");
db.execSQL("CREATE INDEX titlekey_index ON files(title_key);");
db.execSQL("CREATE INDEX artist_id_idx ON files(artist_id);");
// Recreate triggers for our obsolete tables on the new files table
db.execSQL("DROP TRIGGER IF EXISTS images_cleanup");
db.execSQL("DROP TRIGGER IF EXISTS audio_meta_cleanup");
db.execSQL("DROP TRIGGER IF EXISTS video_cleanup");
db.execSQL("DROP TRIGGER IF EXISTS audio_playlists_cleanup");
db.execSQL("DROP TRIGGER IF EXISTS audio_delete");
db.execSQL("CREATE TRIGGER IF NOT EXISTS images_cleanup DELETE ON files " +
"WHEN old.media_type = " + FileColumns.MEDIA_TYPE_IMAGE + " " +
"BEGIN " +
"DELETE FROM thumbnails WHERE image_id = old._id;" +
"SELECT _DELETE_FILE(old._data);" +
"END");
db.execSQL("CREATE TRIGGER IF NOT EXISTS video_cleanup DELETE ON files " +
"WHEN old.media_type = " + FileColumns.MEDIA_TYPE_VIDEO + " " +
"BEGIN " +
"SELECT _DELETE_FILE(old._data);" +
"END");
if (!internal) {
db.execSQL("CREATE TRIGGER IF NOT EXISTS audio_meta_cleanup DELETE ON files " +
"WHEN old.media_type = " + FileColumns.MEDIA_TYPE_AUDIO + " " +
"BEGIN " +
"DELETE FROM audio_genres_map WHERE audio_id = old._id;" +
"DELETE FROM audio_playlists_map WHERE audio_id = old._id;" +
"END");
db.execSQL("CREATE TRIGGER IF NOT EXISTS audio_playlists_cleanup DELETE ON files " +
"WHEN old.media_type = " + FileColumns.MEDIA_TYPE_PLAYLIST + " " +
"BEGIN " +
"DELETE FROM audio_playlists_map WHERE playlist_id = old._id;" +
"SELECT _DELETE_FILE(old._data);" +
"END");
db.execSQL("CREATE TRIGGER IF NOT EXISTS audio_delete INSTEAD OF DELETE ON audio " +
"BEGIN " +
"DELETE from files where _id=old._id;" +
"DELETE from audio_playlists_map where audio_id=old._id;" +
"DELETE from audio_genres_map where audio_id=old._id;" +
"END");
}
}
if (fromVersion < 301) {
db.execSQL("DROP INDEX IF EXISTS bucket_index");
db.execSQL("CREATE INDEX bucket_index on files(bucket_id, media_type, datetaken, _id)");
db.execSQL("CREATE INDEX bucket_name on files(bucket_id, media_type, bucket_display_name)");
}
if (fromVersion < 302) {
db.execSQL("CREATE INDEX parent_index ON files(parent);");
db.execSQL("CREATE INDEX format_index ON files(format);");
}
if (fromVersion < 303) {
// the album disambiguator hash changed, so rescan songs and force
// albums to be updated. Artists are unaffected.
db.execSQL("DELETE from albums");
db.execSQL("UPDATE files SET date_modified=0 WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_AUDIO + ";");
}
if (fromVersion < 304 && !internal) {
// notifies host when files are deleted
db.execSQL("CREATE TRIGGER IF NOT EXISTS files_cleanup DELETE ON files " +
"BEGIN " +
"SELECT _OBJECT_REMOVED(old._id);" +
"END");
}
if (fromVersion < 305 && internal) {
// version 304 erroneously added this trigger to the internal database
db.execSQL("DROP TRIGGER IF EXISTS files_cleanup");
}
if (fromVersion < 306 && !internal) {
// The genre list was expanded and genre string parsing was tweaked, so
// rebuild the genre list
db.execSQL("UPDATE files SET date_modified=0 WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_AUDIO + ";");
db.execSQL("DELETE FROM audio_genres_map");
db.execSQL("DELETE FROM audio_genres");
}
if (fromVersion < 307 && !internal) {
// Force rescan of image entries to update DATE_TAKEN by either GPSTimeStamp or
// EXIF local time.
db.execSQL("UPDATE files SET date_modified=0 WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_IMAGE + ";");
}
// Database version 401 did not add storage_id to the internal database.
// We need it there too, so add it in version 402
if (fromVersion < 401 || (fromVersion == 401 && internal)) {
// Add column for MTP storage ID
db.execSQL("ALTER TABLE files ADD COLUMN storage_id INTEGER;");
// Anything in the database before this upgrade step will be in the primary storage
db.execSQL("UPDATE files SET storage_id=" + MtpStorage.getStorageId(0) + ";");
}
if (fromVersion < 403 && !internal) {
db.execSQL("CREATE VIEW audio_genres_map_noid AS " +
"SELECT audio_id,genre_id from audio_genres_map;");
}
if (fromVersion < 404) {
// There was a bug that could cause distinct same-named albums to be
// combined again. Delete albums and force a rescan.
db.execSQL("DELETE from albums");
db.execSQL("UPDATE files SET date_modified=0 WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_AUDIO + ";");
}
if (fromVersion < 405) {
// Add is_drm column.
db.execSQL("ALTER TABLE files ADD COLUMN is_drm INTEGER;");
db.execSQL("DROP VIEW IF EXISTS audio_meta");
db.execSQL("CREATE VIEW audio_meta AS SELECT _id," + AUDIO_COLUMNSv405 +
" FROM files WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_AUDIO + ";");
recreateAudioView(db);
}
if (fromVersion < 407) {
// Rescan files in the media database because a new column has been added
// in table files in version 405 and to recover from problems populating
// the genre tables
db.execSQL("UPDATE files SET date_modified=0;");
}
if (fromVersion < 408) {
// Add the width/height columns for images and video
db.execSQL("ALTER TABLE files ADD COLUMN width INTEGER;");
db.execSQL("ALTER TABLE files ADD COLUMN height INTEGER;");
// Rescan files to fill the columns
db.execSQL("UPDATE files SET date_modified=0;");
// Update images and video views to contain the width/height columns
db.execSQL("DROP VIEW IF EXISTS images");
db.execSQL("DROP VIEW IF EXISTS video");
db.execSQL("CREATE VIEW images AS SELECT _id," + IMAGE_COLUMNS +
" FROM files WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_IMAGE + ";");
db.execSQL("CREATE VIEW video AS SELECT _id," + VIDEO_COLUMNS +
" FROM files WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_VIDEO + ";");
}
if (fromVersion < 409 && !internal) {
// A bug that prevented numeric genres from being parsed was fixed, so
// rebuild the genre list
db.execSQL("UPDATE files SET date_modified=0 WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_AUDIO + ";");
db.execSQL("DELETE FROM audio_genres_map");
db.execSQL("DELETE FROM audio_genres");
}
if (fromVersion < 500) {
// we're now deleting the file in mediaprovider code, rather than via a trigger
db.execSQL("DROP TRIGGER IF EXISTS videothumbnails_cleanup;");
}
if (fromVersion < 501) {
// we're now deleting the file in mediaprovider code, rather than via a trigger
// the images_cleanup trigger would delete the image file and the entry
// in the thumbnail table, which in turn would trigger thumbnails_cleanup
// to delete the thumbnail image
db.execSQL("DROP TRIGGER IF EXISTS images_cleanup;");
db.execSQL("DROP TRIGGER IF EXISTS thumbnails_cleanup;");
}
if (fromVersion < 502) {
// we're now deleting the file in mediaprovider code, rather than via a trigger
db.execSQL("DROP TRIGGER IF EXISTS video_cleanup;");
}
if (fromVersion < 503) {
// genre and playlist cleanup now done in mediaprovider code, instead of in a trigger
db.execSQL("DROP TRIGGER IF EXISTS audio_delete");
db.execSQL("DROP TRIGGER IF EXISTS audio_meta_cleanup");
}
if (fromVersion < 504) {
// add an index to help with case-insensitive matching of paths
db.execSQL(
"CREATE INDEX IF NOT EXISTS path_index_lower ON files(_data COLLATE NOCASE);");
}
if (fromVersion < 505) {
// Starting with schema 505 we fill in the width/height/resolution columns for videos,
// so force a rescan of videos to fill in the blanks
db.execSQL("UPDATE files SET date_modified=0 WHERE " + FileColumns.MEDIA_TYPE + "="
+ FileColumns.MEDIA_TYPE_VIDEO + ";");
}
if (fromVersion < 506) {
// sd card storage got moved to /storage/sdcard0
// first delete everything that already got scanned in /storage before this
// update step was added
db.execSQL("DROP TRIGGER IF EXISTS files_cleanup");
db.execSQL("DELETE FROM files WHERE _data LIKE '/storage/%';");
db.execSQL("DELETE FROM album_art WHERE _data LIKE '/storage/%';");
db.execSQL("DELETE FROM thumbnails WHERE _data LIKE '/storage/%';");
db.execSQL("DELETE FROM videothumbnails WHERE _data LIKE '/storage/%';");
// then rename everything from /mnt/sdcard/ to /storage/sdcard0,
// and from /mnt/external1 to /storage/sdcard1
db.execSQL("UPDATE files SET " +
"_data='/storage/sdcard0'||SUBSTR(_data,12) WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE files SET " +
"_data='/storage/sdcard1'||SUBSTR(_data,15) WHERE _data LIKE '/mnt/external1/%';");
db.execSQL("UPDATE album_art SET " +
"_data='/storage/sdcard0'||SUBSTR(_data,12) WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE album_art SET " +
"_data='/storage/sdcard1'||SUBSTR(_data,15) WHERE _data LIKE '/mnt/external1/%';");
db.execSQL("UPDATE thumbnails SET " +
"_data='/storage/sdcard0'||SUBSTR(_data,12) WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE thumbnails SET " +
"_data='/storage/sdcard1'||SUBSTR(_data,15) WHERE _data LIKE '/mnt/external1/%';");
db.execSQL("UPDATE videothumbnails SET " +
"_data='/storage/sdcard0'||SUBSTR(_data,12) WHERE _data LIKE '/mnt/sdcard/%';");
db.execSQL("UPDATE videothumbnails SET " +
"_data='/storage/sdcard1'||SUBSTR(_data,15) WHERE _data LIKE '/mnt/external1/%';");
if (!internal) {
db.execSQL("CREATE TRIGGER IF NOT EXISTS files_cleanup DELETE ON files " +
"BEGIN " +
"SELECT _OBJECT_REMOVED(old._id);" +
"END");
}
}
if (fromVersion < 507) {
// we update _data in version 506, we need to update the bucket_id as well
updateBucketNames(db);
}
if (fromVersion < 508 && !internal) {
// ensure we don't get duplicate entries in the genre map
db.execSQL("CREATE TABLE IF NOT EXISTS audio_genres_map_tmp (" +
"_id INTEGER PRIMARY KEY," +
"audio_id INTEGER NOT NULL," +
"genre_id INTEGER NOT NULL," +
"UNIQUE (audio_id,genre_id) ON CONFLICT IGNORE" +
");");
db.execSQL("INSERT INTO audio_genres_map_tmp (audio_id,genre_id)" +
" SELECT DISTINCT audio_id,genre_id FROM audio_genres_map;");
db.execSQL("DROP TABLE audio_genres_map;");
db.execSQL("ALTER TABLE audio_genres_map_tmp RENAME TO audio_genres_map;");
}
if (fromVersion < 509) {
db.execSQL("CREATE TABLE IF NOT EXISTS log (time DATETIME PRIMARY KEY, message TEXT);");
}
sanityCheck(db, fromVersion);
long elapsedSeconds = (SystemClock.currentTimeMicro() - startTime) / 1000000;
logToDb(db, "Database upgraded from version " + fromVersion + " to " + toVersion
+ " in " + elapsedSeconds + " seconds");
}
/**
* Write a persistent diagnostic message to the log table.
*/
static void logToDb(SQLiteDatabase db, String message) {
db.execSQL("INSERT INTO log (time,message) VALUES (strftime('%Y-%m-%d %H:%M:%f','now'),?);",
new String[] { message });
// delete all but the last 500 rows
db.execSQL("DELETE FROM log WHERE rowid IN" +
" (SELECT rowid FROM log ORDER BY time DESC LIMIT 500,-1);");
}
/**
* Perform a simple sanity check on the database. Currently this tests
* whether all the _data entries in audio_meta are unique
*/
private static void sanityCheck(SQLiteDatabase db, int fromVersion) {
Cursor c1 = db.query("audio_meta", new String[] {"count(*)"},
null, null, null, null, null);
Cursor c2 = db.query("audio_meta", new String[] {"count(distinct _data)"},
null, null, null, null, null);
c1.moveToFirst();
c2.moveToFirst();
int num1 = c1.getInt(0);
int num2 = c2.getInt(0);
c1.close();
c2.close();
if (num1 != num2) {
Log.e(TAG, "audio_meta._data column is not unique while upgrading" +
" from schema " +fromVersion + " : " + num1 +"/" + num2);
// Delete all audio_meta rows so they will be rebuilt by the media scanner
db.execSQL("DELETE FROM audio_meta;");
}
}
private static void recreateAudioView(SQLiteDatabase db) {
// Provides a unified audio/artist/album info view.
db.execSQL("DROP VIEW IF EXISTS audio");
db.execSQL("CREATE VIEW IF NOT EXISTS audio as SELECT * FROM audio_meta " +
"LEFT OUTER JOIN artists ON audio_meta.artist_id=artists.artist_id " +
"LEFT OUTER JOIN albums ON audio_meta.album_id=albums.album_id;");
}
/**
* Update the bucket_id and bucket_display_name columns for images and videos
* @param db
* @param tableName
*/
private static void updateBucketNames(SQLiteDatabase db) {
// Rebuild the bucket_display_name column using the natural case rather than lower case.
db.beginTransaction();
try {
String[] columns = {BaseColumns._ID, MediaColumns.DATA};
// update only images and videos
Cursor cursor = db.query("files", columns, "media_type=1 OR media_type=3",
null, null, null, null);
try {
final int idColumnIndex = cursor.getColumnIndex(BaseColumns._ID);
final int dataColumnIndex = cursor.getColumnIndex(MediaColumns.DATA);
String [] rowId = new String[1];
ContentValues values = new ContentValues();
while (cursor.moveToNext()) {
String data = cursor.getString(dataColumnIndex);
rowId[0] = cursor.getString(idColumnIndex);
if (data != null) {
values.clear();
computeBucketValues(data, values);
db.update("files", values, "_id=?", rowId);
} else {
Log.w(TAG, "null data at id " + rowId);
}
}
} finally {
cursor.close();
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* Iterate through the rows of a table in a database, ensuring that the
* display name column has a value.
* @param db
* @param tableName
*/
private static void updateDisplayName(SQLiteDatabase db, String tableName) {
// Fill in default values for null displayName values
db.beginTransaction();
try {
String[] columns = {BaseColumns._ID, MediaColumns.DATA, MediaColumns.DISPLAY_NAME};
Cursor cursor = db.query(tableName, columns, null, null, null, null, null);
try {
final int idColumnIndex = cursor.getColumnIndex(BaseColumns._ID);
final int dataColumnIndex = cursor.getColumnIndex(MediaColumns.DATA);
final int displayNameIndex = cursor.getColumnIndex(MediaColumns.DISPLAY_NAME);
ContentValues values = new ContentValues();
while (cursor.moveToNext()) {
String displayName = cursor.getString(displayNameIndex);
if (displayName == null) {
String data = cursor.getString(dataColumnIndex);
values.clear();
computeDisplayName(data, values);
int rowId = cursor.getInt(idColumnIndex);
db.update(tableName, values, "_id=" + rowId, null);
}
}
} finally {
cursor.close();
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
/**
* @param data The input path
* @param values the content values, where the bucked id name and bucket display name are updated.
*
*/
private static void computeBucketValues(String data, ContentValues values) {
File parentFile = new File(data).getParentFile();
if (parentFile == null) {
parentFile = new File("/");
}
// Lowercase the path for hashing. This avoids duplicate buckets if the
// filepath case is changed externally.
// Keep the original case for display.
String path = parentFile.toString().toLowerCase();
String name = parentFile.getName();
// Note: the BUCKET_ID and BUCKET_DISPLAY_NAME attributes are spelled the
// same for both images and video. However, for backwards-compatibility reasons
// there is no common base class. We use the ImageColumns version here
values.put(ImageColumns.BUCKET_ID, path.hashCode());
values.put(ImageColumns.BUCKET_DISPLAY_NAME, name);
}
/**
* @param data The input path
* @param values the content values, where the display name is updated.
*
*/
private static void computeDisplayName(String data, ContentValues values) {
String s = (data == null ? "" : data.toString());
int idx = s.lastIndexOf('/');
if (idx >= 0) {
s = s.substring(idx + 1);
}
values.put("_display_name", s);
}
/**
* Copy taken time from date_modified if we lost the original value (e.g. after factory reset)
* This works for both video and image tables.
*
* @param values the content values, where taken time is updated.
*/
private static void computeTakenTime(ContentValues values) {
if (! values.containsKey(Images.Media.DATE_TAKEN)) {
// This only happens when MediaScanner finds an image file that doesn't have any useful
// reference to get this value. (e.g. GPSTimeStamp)
Long lastModified = values.getAsLong(MediaColumns.DATE_MODIFIED);
if (lastModified != null) {
values.put(Images.Media.DATE_TAKEN, lastModified * 1000);
}
}
}
/**
* This method blocks until thumbnail is ready.
*
* @param thumbUri
* @return
*/
private boolean waitForThumbnailReady(Uri origUri) {
Cursor c = this.query(origUri, new String[] { ImageColumns._ID, ImageColumns.DATA,
ImageColumns.MINI_THUMB_MAGIC}, null, null, null);
if (c == null) return false;
boolean result = false;
if (c.moveToFirst()) {
long id = c.getLong(0);
String path = c.getString(1);
long magic = c.getLong(2);
MediaThumbRequest req = requestMediaThumbnail(path, origUri,
MediaThumbRequest.PRIORITY_HIGH, magic);
if (req == null) {
return false;
}
synchronized (req) {
try {
while (req.mState == MediaThumbRequest.State.WAIT) {
req.wait();
}
} catch (InterruptedException e) {
Log.w(TAG, e);
}
if (req.mState == MediaThumbRequest.State.DONE) {
result = true;
}
}
}
c.close();
return result;
}
private boolean matchThumbRequest(MediaThumbRequest req, int pid, long id, long gid,
boolean isVideo) {
boolean cancelAllOrigId = (id == -1);
boolean cancelAllGroupId = (gid == -1);
return (req.mCallingPid == pid) &&
(cancelAllGroupId || req.mGroupId == gid) &&
(cancelAllOrigId || req.mOrigId == id) &&
(req.mIsVideo == isVideo);
}
private boolean queryThumbnail(SQLiteQueryBuilder qb, Uri uri, String table,
String column, boolean hasThumbnailId) {
qb.setTables(table);
if (hasThumbnailId) {
// For uri dispatched to this method, the 4th path segment is always
// the thumbnail id.
qb.appendWhere("_id = " + uri.getPathSegments().get(3));
// client already knows which thumbnail it wants, bypass it.
return true;
}
String origId = uri.getQueryParameter("orig_id");
// We can't query ready_flag unless we know original id
if (origId == null) {
// this could be thumbnail query for other purpose, bypass it.
return true;
}
boolean needBlocking = "1".equals(uri.getQueryParameter("blocking"));
boolean cancelRequest = "1".equals(uri.getQueryParameter("cancel"));
Uri origUri = uri.buildUpon().encodedPath(
uri.getPath().replaceFirst("thumbnails", "media"))
.appendPath(origId).build();
if (needBlocking && !waitForThumbnailReady(origUri)) {
Log.w(TAG, "original media doesn't exist or it's canceled.");
return false;
} else if (cancelRequest) {
String groupId = uri.getQueryParameter("group_id");
boolean isVideo = "video".equals(uri.getPathSegments().get(1));
int pid = Binder.getCallingPid();
long id = -1;
long gid = -1;
try {
id = Long.parseLong(origId);
gid = Long.parseLong(groupId);
} catch (NumberFormatException ex) {
// invalid cancel request
return false;
}
synchronized (mMediaThumbQueue) {
if (mCurrentThumbRequest != null &&
matchThumbRequest(mCurrentThumbRequest, pid, id, gid, isVideo)) {
synchronized (mCurrentThumbRequest) {
mCurrentThumbRequest.mState = MediaThumbRequest.State.CANCEL;
mCurrentThumbRequest.notifyAll();
}
}
for (MediaThumbRequest mtq : mMediaThumbQueue) {
if (matchThumbRequest(mtq, pid, id, gid, isVideo)) {
synchronized (mtq) {
mtq.mState = MediaThumbRequest.State.CANCEL;
mtq.notifyAll();
}
mMediaThumbQueue.remove(mtq);
}
}
}
}
if (origId != null) {
qb.appendWhere(column + " = " + origId);
}
return true;
}
@SuppressWarnings("fallthrough")
@Override
public Cursor query(Uri uri, String[] projectionIn, String selection,
String[] selectionArgs, String sort) {
int table = URI_MATCHER.match(uri);
List<String> prependArgs = new ArrayList<String>();
// Log.v(TAG, "query: uri="+uri+", selection="+selection);
// handle MEDIA_SCANNER before calling getDatabaseForUri()
if (table == MEDIA_SCANNER) {
if (mMediaScannerVolume == null) {
return null;
} else {
// create a cursor to return volume currently being scanned by the media scanner
MatrixCursor c = new MatrixCursor(new String[] {MediaStore.MEDIA_SCANNER_VOLUME});
c.addRow(new String[] {mMediaScannerVolume});
return c;
}
}
// Used temporarily (until we have unique media IDs) to get an identifier
// for the current sd card, so that the music app doesn't have to use the
// non-public getFatVolumeId method
if (table == FS_ID) {
MatrixCursor c = new MatrixCursor(new String[] {"fsid"});
c.addRow(new Integer[] {mVolumeId});
return c;
}
if (table == VERSION) {
MatrixCursor c = new MatrixCursor(new String[] {"version"});
c.addRow(new Integer[] {getDatabaseVersion(getContext())});
return c;
}
String groupBy = null;
DatabaseHelper helper = getDatabaseForUri(uri);
if (helper == null) {
return null;
}
helper.mNumQueries++;
SQLiteDatabase db = helper.getReadableDatabase();
if (db == null) return null;
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
String limit = uri.getQueryParameter("limit");
String filter = uri.getQueryParameter("filter");
String [] keywords = null;
if (filter != null) {
filter = Uri.decode(filter).trim();
if (!TextUtils.isEmpty(filter)) {
String [] searchWords = filter.split(" ");
keywords = new String[searchWords.length];
Collator col = Collator.getInstance();
col.setStrength(Collator.PRIMARY);
for (int i = 0; i < searchWords.length; i++) {
String key = MediaStore.Audio.keyFor(searchWords[i]);
key = key.replace("\\", "\\\\");
key = key.replace("%", "\\%");
key = key.replace("_", "\\_");
keywords[i] = key;
}
}
}
if (uri.getQueryParameter("distinct") != null) {
qb.setDistinct(true);
}
boolean hasThumbnailId = false;
switch (table) {
case IMAGES_MEDIA:
qb.setTables("images");
if (uri.getQueryParameter("distinct") != null)
qb.setDistinct(true);
// set the project map so that data dir is prepended to _data.
//qb.setProjectionMap(mImagesProjectionMap, true);
break;
case IMAGES_MEDIA_ID:
qb.setTables("images");
if (uri.getQueryParameter("distinct") != null)
qb.setDistinct(true);
// set the project map so that data dir is prepended to _data.
//qb.setProjectionMap(mImagesProjectionMap, true);
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(3));
break;
case IMAGES_THUMBNAILS_ID:
hasThumbnailId = true;
case IMAGES_THUMBNAILS:
if (!queryThumbnail(qb, uri, "thumbnails", "image_id", hasThumbnailId)) {
return null;
}
break;
case AUDIO_MEDIA:
if (projectionIn != null && projectionIn.length == 1 && selectionArgs == null
&& (selection == null || selection.equalsIgnoreCase("is_music=1")
|| selection.equalsIgnoreCase("is_podcast=1") )
&& projectionIn[0].equalsIgnoreCase("count(*)")
&& keywords != null) {
//Log.i("@@@@", "taking fast path for counting songs");
qb.setTables("audio_meta");
} else {
qb.setTables("audio");
for (int i = 0; keywords != null && i < keywords.length; i++) {
if (i > 0) {
qb.appendWhere(" AND ");
}
qb.appendWhere(MediaStore.Audio.Media.ARTIST_KEY +
"||" + MediaStore.Audio.Media.ALBUM_KEY +
"||" + MediaStore.Audio.Media.TITLE_KEY + " LIKE ? ESCAPE '\\'");
prependArgs.add("%" + keywords[i] + "%");
}
}
break;
case AUDIO_MEDIA_ID:
qb.setTables("audio");
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(3));
break;
case AUDIO_MEDIA_ID_GENRES:
qb.setTables("audio_genres");
qb.appendWhere("_id IN (SELECT genre_id FROM " +
"audio_genres_map WHERE audio_id=?)");
prependArgs.add(uri.getPathSegments().get(3));
break;
case AUDIO_MEDIA_ID_GENRES_ID:
qb.setTables("audio_genres");
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(5));
break;
case AUDIO_MEDIA_ID_PLAYLISTS:
qb.setTables("audio_playlists");
qb.appendWhere("_id IN (SELECT playlist_id FROM " +
"audio_playlists_map WHERE audio_id=?)");
prependArgs.add(uri.getPathSegments().get(3));
break;
case AUDIO_MEDIA_ID_PLAYLISTS_ID:
qb.setTables("audio_playlists");
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(5));
break;
case AUDIO_GENRES:
qb.setTables("audio_genres");
break;
case AUDIO_GENRES_ID:
qb.setTables("audio_genres");
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(3));
break;
case AUDIO_GENRES_ALL_MEMBERS:
case AUDIO_GENRES_ID_MEMBERS:
{
// if simpleQuery is true, we can do a simpler query on just audio_genres_map
// we can do this if we have no keywords and our projection includes just columns
// from audio_genres_map
boolean simpleQuery = (keywords == null && projectionIn != null
&& (selection == null || selection.equalsIgnoreCase("genre_id=?")));
if (projectionIn != null) {
for (int i = 0; i < projectionIn.length; i++) {
String p = projectionIn[i];
if (p.equals("_id")) {
// note, this is different from playlist below, because
// "_id" used to (wrongly) be the audio id in this query, not
// the row id of the entry in the map, and we preserve this
// behavior for backwards compatibility
simpleQuery = false;
}
if (simpleQuery && !(p.equals("audio_id") ||
p.equals("genre_id"))) {
simpleQuery = false;
}
}
}
if (simpleQuery) {
qb.setTables("audio_genres_map_noid");
if (table == AUDIO_GENRES_ID_MEMBERS) {
qb.appendWhere("genre_id=?");
prependArgs.add(uri.getPathSegments().get(3));
}
} else {
qb.setTables("audio_genres_map_noid, audio");
qb.appendWhere("audio._id = audio_id");
if (table == AUDIO_GENRES_ID_MEMBERS) {
qb.appendWhere(" AND genre_id=?");
prependArgs.add(uri.getPathSegments().get(3));
}
for (int i = 0; keywords != null && i < keywords.length; i++) {
qb.appendWhere(" AND ");
qb.appendWhere(MediaStore.Audio.Media.ARTIST_KEY +
"||" + MediaStore.Audio.Media.ALBUM_KEY +
"||" + MediaStore.Audio.Media.TITLE_KEY +
" LIKE ? ESCAPE '\\'");
prependArgs.add("%" + keywords[i] + "%");
}
}
}
break;
case AUDIO_PLAYLISTS:
qb.setTables("audio_playlists");
break;
case AUDIO_PLAYLISTS_ID:
qb.setTables("audio_playlists");
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(3));
break;
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
case AUDIO_PLAYLISTS_ID_MEMBERS:
// if simpleQuery is true, we can do a simpler query on just audio_playlists_map
// we can do this if we have no keywords and our projection includes just columns
// from audio_playlists_map
boolean simpleQuery = (keywords == null && projectionIn != null
&& (selection == null || selection.equalsIgnoreCase("playlist_id=?")));
if (projectionIn != null) {
for (int i = 0; i < projectionIn.length; i++) {
String p = projectionIn[i];
if (simpleQuery && !(p.equals("audio_id") ||
p.equals("playlist_id") || p.equals("play_order"))) {
simpleQuery = false;
}
if (p.equals("_id")) {
projectionIn[i] = "audio_playlists_map._id AS _id";
}
}
}
if (simpleQuery) {
qb.setTables("audio_playlists_map");
qb.appendWhere("playlist_id=?");
prependArgs.add(uri.getPathSegments().get(3));
} else {
qb.setTables("audio_playlists_map, audio");
qb.appendWhere("audio._id = audio_id AND playlist_id=?");
prependArgs.add(uri.getPathSegments().get(3));
for (int i = 0; keywords != null && i < keywords.length; i++) {
qb.appendWhere(" AND ");
qb.appendWhere(MediaStore.Audio.Media.ARTIST_KEY +
"||" + MediaStore.Audio.Media.ALBUM_KEY +
"||" + MediaStore.Audio.Media.TITLE_KEY +
" LIKE ? ESCAPE '\\'");
prependArgs.add("%" + keywords[i] + "%");
}
}
if (table == AUDIO_PLAYLISTS_ID_MEMBERS_ID) {
qb.appendWhere(" AND audio_playlists_map._id=?");
prependArgs.add(uri.getPathSegments().get(5));
}
break;
case VIDEO_MEDIA:
qb.setTables("video");
break;
case VIDEO_MEDIA_ID:
qb.setTables("video");
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(3));
break;
case VIDEO_THUMBNAILS_ID:
hasThumbnailId = true;
case VIDEO_THUMBNAILS:
if (!queryThumbnail(qb, uri, "videothumbnails", "video_id", hasThumbnailId)) {
return null;
}
break;
case AUDIO_ARTISTS:
if (projectionIn != null && projectionIn.length == 1 && selectionArgs == null
&& (selection == null || selection.length() == 0)
&& projectionIn[0].equalsIgnoreCase("count(*)")
&& keywords != null) {
//Log.i("@@@@", "taking fast path for counting artists");
qb.setTables("audio_meta");
projectionIn[0] = "count(distinct artist_id)";
qb.appendWhere("is_music=1");
} else {
qb.setTables("artist_info");
for (int i = 0; keywords != null && i < keywords.length; i++) {
if (i > 0) {
qb.appendWhere(" AND ");
}
qb.appendWhere(MediaStore.Audio.Media.ARTIST_KEY +
" LIKE ? ESCAPE '\\'");
prependArgs.add("%" + keywords[i] + "%");
}
}
break;
case AUDIO_ARTISTS_ID:
qb.setTables("artist_info");
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(3));
break;
case AUDIO_ARTISTS_ID_ALBUMS:
String aid = uri.getPathSegments().get(3);
qb.setTables("audio LEFT OUTER JOIN album_art ON" +
" audio.album_id=album_art.album_id");
qb.appendWhere("is_music=1 AND audio.album_id IN (SELECT album_id FROM " +
"artists_albums_map WHERE artist_id=?)");
prependArgs.add(aid);
for (int i = 0; keywords != null && i < keywords.length; i++) {
qb.appendWhere(" AND ");
qb.appendWhere(MediaStore.Audio.Media.ARTIST_KEY +
"||" + MediaStore.Audio.Media.ALBUM_KEY +
" LIKE ? ESCAPE '\\'");
prependArgs.add("%" + keywords[i] + "%");
}
groupBy = "audio.album_id";
sArtistAlbumsMap.put(MediaStore.Audio.Albums.NUMBER_OF_SONGS_FOR_ARTIST,
"count(CASE WHEN artist_id==" + aid + " THEN 'foo' ELSE NULL END) AS " +
MediaStore.Audio.Albums.NUMBER_OF_SONGS_FOR_ARTIST);
qb.setProjectionMap(sArtistAlbumsMap);
break;
case AUDIO_ALBUMS:
if (projectionIn != null && projectionIn.length == 1 && selectionArgs == null
&& (selection == null || selection.length() == 0)
&& projectionIn[0].equalsIgnoreCase("count(*)")
&& keywords != null) {
//Log.i("@@@@", "taking fast path for counting albums");
qb.setTables("audio_meta");
projectionIn[0] = "count(distinct album_id)";
qb.appendWhere("is_music=1");
} else {
qb.setTables("album_info");
for (int i = 0; keywords != null && i < keywords.length; i++) {
if (i > 0) {
qb.appendWhere(" AND ");
}
qb.appendWhere(MediaStore.Audio.Media.ARTIST_KEY +
"||" + MediaStore.Audio.Media.ALBUM_KEY +
" LIKE ? ESCAPE '\\'");
prependArgs.add("%" + keywords[i] + "%");
}
}
break;
case AUDIO_ALBUMS_ID:
qb.setTables("album_info");
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(3));
break;
case AUDIO_ALBUMART_ID:
qb.setTables("album_art");
qb.appendWhere("album_id=?");
prependArgs.add(uri.getPathSegments().get(3));
break;
case AUDIO_SEARCH_LEGACY:
Log.w(TAG, "Legacy media search Uri used. Please update your code.");
// fall through
case AUDIO_SEARCH_FANCY:
case AUDIO_SEARCH_BASIC:
return doAudioSearch(db, qb, uri, projectionIn, selection,
combine(prependArgs, selectionArgs), sort, table, limit);
case FILES_ID:
case MTP_OBJECTS_ID:
qb.appendWhere("_id=?");
prependArgs.add(uri.getPathSegments().get(2));
// fall through
case FILES:
case MTP_OBJECTS:
qb.setTables("files");
break;
case MTP_OBJECT_REFERENCES:
int handle = Integer.parseInt(uri.getPathSegments().get(2));
return getObjectReferences(helper, db, handle);
default:
throw new IllegalStateException("Unknown URL: " + uri.toString());
}
// Log.v(TAG, "query = "+ qb.buildQuery(projectionIn, selection,
// combine(prependArgs, selectionArgs), groupBy, null, sort, limit));
Cursor c = qb.query(db, projectionIn, selection,
combine(prependArgs, selectionArgs), groupBy, null, sort, limit);
if (c != null) {
c.setNotificationUri(getContext().getContentResolver(), uri);
}
return c;
}
private String[] combine(List<String> prepend, String[] userArgs) {
int presize = prepend.size();
if (presize == 0) {
return userArgs;
}
int usersize = (userArgs != null) ? userArgs.length : 0;
String [] combined = new String[presize + usersize];
for (int i = 0; i < presize; i++) {
combined[i] = prepend.get(i);
}
for (int i = 0; i < usersize; i++) {
combined[presize + i] = userArgs[i];
}
return combined;
}
private Cursor doAudioSearch(SQLiteDatabase db, SQLiteQueryBuilder qb,
Uri uri, String[] projectionIn, String selection,
String[] selectionArgs, String sort, int mode,
String limit) {
String mSearchString = uri.getPath().endsWith("/") ? "" : uri.getLastPathSegment();
mSearchString = mSearchString.replaceAll(" ", " ").trim().toLowerCase();
String [] searchWords = mSearchString.length() > 0 ?
mSearchString.split(" ") : new String[0];
String [] wildcardWords = new String[searchWords.length];
Collator col = Collator.getInstance();
col.setStrength(Collator.PRIMARY);
int len = searchWords.length;
for (int i = 0; i < len; i++) {
// Because we match on individual words here, we need to remove words
// like 'a' and 'the' that aren't part of the keys.
String key = MediaStore.Audio.keyFor(searchWords[i]);
key = key.replace("\\", "\\\\");
key = key.replace("%", "\\%");
key = key.replace("_", "\\_");
wildcardWords[i] =
(searchWords[i].equals("a") || searchWords[i].equals("an") ||
searchWords[i].equals("the")) ? "%" : "%" + key + "%";
}
String where = "";
for (int i = 0; i < searchWords.length; i++) {
if (i == 0) {
where = "match LIKE ? ESCAPE '\\'";
} else {
where += " AND match LIKE ? ESCAPE '\\'";
}
}
qb.setTables("search");
String [] cols;
if (mode == AUDIO_SEARCH_FANCY) {
cols = mSearchColsFancy;
} else if (mode == AUDIO_SEARCH_BASIC) {
cols = mSearchColsBasic;
} else {
cols = mSearchColsLegacy;
}
return qb.query(db, cols, where, wildcardWords, null, null, null, limit);
}
@Override
public String getType(Uri url)
{
switch (URI_MATCHER.match(url)) {
case IMAGES_MEDIA_ID:
case AUDIO_MEDIA_ID:
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
case VIDEO_MEDIA_ID:
case FILES_ID:
Cursor c = null;
try {
c = query(url, MIME_TYPE_PROJECTION, null, null, null);
if (c != null && c.getCount() == 1) {
c.moveToFirst();
String mimeType = c.getString(1);
c.deactivate();
return mimeType;
}
} finally {
if (c != null) {
c.close();
}
}
break;
case IMAGES_MEDIA:
case IMAGES_THUMBNAILS:
return Images.Media.CONTENT_TYPE;
case AUDIO_ALBUMART_ID:
case IMAGES_THUMBNAILS_ID:
return "image/jpeg";
case AUDIO_MEDIA:
case AUDIO_GENRES_ID_MEMBERS:
case AUDIO_PLAYLISTS_ID_MEMBERS:
return Audio.Media.CONTENT_TYPE;
case AUDIO_GENRES:
case AUDIO_MEDIA_ID_GENRES:
return Audio.Genres.CONTENT_TYPE;
case AUDIO_GENRES_ID:
case AUDIO_MEDIA_ID_GENRES_ID:
return Audio.Genres.ENTRY_CONTENT_TYPE;
case AUDIO_PLAYLISTS:
case AUDIO_MEDIA_ID_PLAYLISTS:
return Audio.Playlists.CONTENT_TYPE;
case AUDIO_PLAYLISTS_ID:
case AUDIO_MEDIA_ID_PLAYLISTS_ID:
return Audio.Playlists.ENTRY_CONTENT_TYPE;
case VIDEO_MEDIA:
return Video.Media.CONTENT_TYPE;
}
throw new IllegalStateException("Unknown URL : " + url);
}
/**
* Ensures there is a file in the _data column of values, if one isn't
* present a new file is created.
*
* @param initialValues the values passed to insert by the caller
* @return the new values
*/
private ContentValues ensureFile(boolean internal, ContentValues initialValues,
String preferredExtension, String directoryName) {
ContentValues values;
String file = initialValues.getAsString(MediaStore.MediaColumns.DATA);
if (TextUtils.isEmpty(file)) {
file = generateFileName(internal, preferredExtension, directoryName);
values = new ContentValues(initialValues);
values.put(MediaStore.MediaColumns.DATA, file);
} else {
values = initialValues;
}
if (!ensureFileExists(file)) {
throw new IllegalStateException("Unable to create new file: " + file);
}
return values;
}
private void sendObjectAdded(long objectHandle) {
synchronized (mMtpServiceConnection) {
if (mMtpService != null) {
try {
mMtpService.sendObjectAdded((int)objectHandle);
} catch (RemoteException e) {
Log.e(TAG, "RemoteException in sendObjectAdded", e);
mMtpService = null;
}
}
}
}
private void sendObjectRemoved(long objectHandle) {
synchronized (mMtpServiceConnection) {
if (mMtpService != null) {
try {
mMtpService.sendObjectRemoved((int)objectHandle);
} catch (RemoteException e) {
Log.e(TAG, "RemoteException in sendObjectRemoved", e);
mMtpService = null;
}
}
}
}
@Override
public int bulkInsert(Uri uri, ContentValues values[]) {
int match = URI_MATCHER.match(uri);
if (match == VOLUMES) {
return super.bulkInsert(uri, values);
}
DatabaseHelper helper = getDatabaseForUri(uri);
if (helper == null) {
throw new UnsupportedOperationException(
"Unknown URI: " + uri);
}
SQLiteDatabase db = helper.getWritableDatabase();
if (db == null) {
throw new IllegalStateException("Couldn't open database for " + uri);
}
if (match == AUDIO_PLAYLISTS_ID || match == AUDIO_PLAYLISTS_ID_MEMBERS) {
return playlistBulkInsert(db, uri, values);
} else if (match == MTP_OBJECT_REFERENCES) {
int handle = Integer.parseInt(uri.getPathSegments().get(2));
return setObjectReferences(helper, db, handle, values);
}
db.beginTransaction();
ArrayList<Long> notifyRowIds = new ArrayList<Long>();
int numInserted = 0;
try {
int len = values.length;
for (int i = 0; i < len; i++) {
if (values[i] != null) {
insertInternal(uri, match, values[i], notifyRowIds);
}
}
numInserted = len;
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
// Notify MTP (outside of successful transaction)
notifyMtp(notifyRowIds);
getContext().getContentResolver().notifyChange(uri, null);
return numInserted;
}
@Override
public Uri insert(Uri uri, ContentValues initialValues) {
int match = URI_MATCHER.match(uri);
ArrayList<Long> notifyRowIds = new ArrayList<Long>();
Uri newUri = insertInternal(uri, match, initialValues, notifyRowIds);
notifyMtp(notifyRowIds);
// do not signal notification for MTP objects.
// we will signal instead after file transfer is successful.
if (newUri != null && match != MTP_OBJECTS) {
getContext().getContentResolver().notifyChange(uri, null);
}
return newUri;
}
private void notifyMtp(ArrayList<Long> rowIds) {
int size = rowIds.size();
for (int i = 0; i < size; i++) {
sendObjectAdded(rowIds.get(i).longValue());
}
}
private int playlistBulkInsert(SQLiteDatabase db, Uri uri, ContentValues values[]) {
DatabaseUtils.InsertHelper helper =
new DatabaseUtils.InsertHelper(db, "audio_playlists_map");
int audioidcolidx = helper.getColumnIndex(MediaStore.Audio.Playlists.Members.AUDIO_ID);
int playlistididx = helper.getColumnIndex(Audio.Playlists.Members.PLAYLIST_ID);
int playorderidx = helper.getColumnIndex(MediaStore.Audio.Playlists.Members.PLAY_ORDER);
long playlistId = Long.parseLong(uri.getPathSegments().get(3));
db.beginTransaction();
int numInserted = 0;
try {
int len = values.length;
for (int i = 0; i < len; i++) {
helper.prepareForInsert();
// getting the raw Object and converting it long ourselves saves
// an allocation (the alternative is ContentValues.getAsLong, which
// returns a Long object)
long audioid = ((Number) values[i].get(
MediaStore.Audio.Playlists.Members.AUDIO_ID)).longValue();
helper.bind(audioidcolidx, audioid);
helper.bind(playlistididx, playlistId);
// convert to int ourselves to save an allocation.
int playorder = ((Number) values[i].get(
MediaStore.Audio.Playlists.Members.PLAY_ORDER)).intValue();
helper.bind(playorderidx, playorder);
helper.execute();
}
numInserted = len;
db.setTransactionSuccessful();
} finally {
db.endTransaction();
helper.close();
}
getContext().getContentResolver().notifyChange(uri, null);
return numInserted;
}
private long insertDirectory(DatabaseHelper helper, SQLiteDatabase db, String path) {
if (LOCAL_LOGV) Log.v(TAG, "inserting directory " + path);
ContentValues values = new ContentValues();
values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
values.put(FileColumns.DATA, path);
values.put(FileColumns.PARENT, getParent(helper, db, path));
values.put(FileColumns.STORAGE_ID, getStorageId(path));
File file = new File(path);
if (file.exists()) {
values.put(FileColumns.DATE_MODIFIED, file.lastModified() / 1000);
}
helper.mNumInserts++;
long rowId = db.insert("files", FileColumns.DATE_MODIFIED, values);
sendObjectAdded(rowId);
return rowId;
}
private long getParent(DatabaseHelper helper, SQLiteDatabase db, String path) {
int lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) {
String parentPath = path.substring(0, lastSlash);
for (int i = 0; i < mExternalStoragePaths.length; i++) {
if (parentPath.equals(mExternalStoragePaths[i])) {
return 0;
}
}
Long cid = mDirectoryCache.get(parentPath);
if (cid != null) {
if (LOCAL_LOGV) Log.v(TAG, "Returning cached entry for " + parentPath);
return cid;
}
// Use "LIKE" instead of "=" on case insensitive file systems so we do a
// case insensitive match when looking for parent directory.
// TODO: investigate whether a "nocase" constraint on the column and
// using "=" would give the same result faster.
String selection = (mCaseInsensitivePaths ? MediaStore.MediaColumns.DATA + " LIKE ?1"
// The like above makes it use the index.
// The comparison below makes it correct when the path has wildcard chars
+ " AND lower(_data)=lower(?1)"
// search only directories.
+ " AND format=" + MtpConstants.FORMAT_ASSOCIATION
: MediaStore.MediaColumns.DATA + "=?");
String [] selargs = { parentPath };
helper.mNumQueries++;
Cursor c = db.query("files", sIdOnlyColumn, selection, selargs, null, null, null);
try {
long id;
if (c == null || c.getCount() == 0) {
// parent isn't in the database - so add it
id = insertDirectory(helper, db, parentPath);
if (LOCAL_LOGV) Log.v(TAG, "Inserted " + parentPath);
} else {
if (c.getCount() > 1) {
Log.e(TAG, "more than one match for " + parentPath);
}
c.moveToFirst();
id = c.getLong(0);
if (LOCAL_LOGV) Log.v(TAG, "Queried " + parentPath);
}
mDirectoryCache.put(parentPath, id);
return id;
} finally {
if (c != null) c.close();
}
} else {
return 0;
}
}
private int getStorageId(String path) {
for (int i = 0; i < mExternalStoragePaths.length; i++) {
String test = mExternalStoragePaths[i];
if (path.startsWith(test)) {
int length = test.length();
if (path.length() == length || path.charAt(length) == '/') {
return MtpStorage.getStorageId(i);
}
}
}
// default to primary storage
return MtpStorage.getStorageId(0);
}
private long insertFile(DatabaseHelper helper, Uri uri, ContentValues initialValues, int mediaType,
boolean notify, ArrayList<Long> notifyRowIds) {
SQLiteDatabase db = helper.getWritableDatabase();
ContentValues values = null;
switch (mediaType) {
case FileColumns.MEDIA_TYPE_IMAGE: {
values = ensureFile(helper.mInternal, initialValues, ".jpg", "DCIM/Camera");
values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000);
String data = values.getAsString(MediaColumns.DATA);
if (! values.containsKey(MediaColumns.DISPLAY_NAME)) {
computeDisplayName(data, values);
}
computeTakenTime(values);
break;
}
case FileColumns.MEDIA_TYPE_AUDIO: {
// SQLite Views are read-only, so we need to deconstruct this
// insert and do inserts into the underlying tables.
// If doing this here turns out to be a performance bottleneck,
// consider moving this to native code and using triggers on
// the view.
values = new ContentValues(initialValues);
String albumartist = values.getAsString(MediaStore.Audio.Media.ALBUM_ARTIST);
String compilation = values.getAsString(MediaStore.Audio.Media.COMPILATION);
values.remove(MediaStore.Audio.Media.COMPILATION);
// Insert the artist into the artist table and remove it from
// the input values
Object so = values.get("artist");
String s = (so == null ? "" : so.toString());
values.remove("artist");
long artistRowId;
HashMap<String, Long> artistCache = helper.mArtistCache;
String path = values.getAsString(MediaStore.MediaColumns.DATA);
synchronized(artistCache) {
Long temp = artistCache.get(s);
if (temp == null) {
artistRowId = getKeyIdForName(helper, db,
"artists", "artist_key", "artist",
s, s, path, 0, null, artistCache, uri);
} else {
artistRowId = temp.longValue();
}
}
String artist = s;
// Do the same for the album field
so = values.get("album");
s = (so == null ? "" : so.toString());
values.remove("album");
long albumRowId;
HashMap<String, Long> albumCache = helper.mAlbumCache;
synchronized(albumCache) {
int albumhash = 0;
if (albumartist != null) {
albumhash = albumartist.hashCode();
} else if (compilation != null && compilation.equals("1")) {
// nothing to do, hash already set
} else {
albumhash = path.substring(0, path.lastIndexOf('/')).hashCode();
}
String cacheName = s + albumhash;
Long temp = albumCache.get(cacheName);
if (temp == null) {
albumRowId = getKeyIdForName(helper, db,
"albums", "album_key", "album",
s, cacheName, path, albumhash, artist, albumCache, uri);
} else {
albumRowId = temp;
}
}
values.put("artist_id", Integer.toString((int)artistRowId));
values.put("album_id", Integer.toString((int)albumRowId));
so = values.getAsString("title");
s = (so == null ? "" : so.toString());
values.put("title_key", MediaStore.Audio.keyFor(s));
// do a final trim of the title, in case it started with the special
// "sort first" character (ascii \001)
values.remove("title");
values.put("title", s.trim());
computeDisplayName(values.getAsString(MediaStore.MediaColumns.DATA), values);
break;
}
case FileColumns.MEDIA_TYPE_VIDEO: {
values = ensureFile(helper.mInternal, initialValues, ".3gp", "video");
String data = values.getAsString(MediaStore.MediaColumns.DATA);
computeDisplayName(data, values);
computeTakenTime(values);
break;
}
}
if (values == null) {
values = new ContentValues(initialValues);
}
// compute bucket_id and bucket_display_name for all files
String path = values.getAsString(MediaStore.MediaColumns.DATA);
if (path != null) {
computeBucketValues(path, values);
}
values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000);
long rowId = 0;
Integer i = values.getAsInteger(
MediaStore.MediaColumns.MEDIA_SCANNER_NEW_OBJECT_ID);
if (i != null) {
rowId = i.intValue();
values = new ContentValues(values);
values.remove(MediaStore.MediaColumns.MEDIA_SCANNER_NEW_OBJECT_ID);
}
String title = values.getAsString(MediaStore.MediaColumns.TITLE);
if (title == null && path != null) {
title = MediaFile.getFileTitle(path);
}
values.put(FileColumns.TITLE, title);
String mimeType = values.getAsString(MediaStore.MediaColumns.MIME_TYPE);
Integer formatObject = values.getAsInteger(FileColumns.FORMAT);
int format = (formatObject == null ? 0 : formatObject.intValue());
if (format == 0) {
if (TextUtils.isEmpty(path)) {
// special case device created playlists
if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) {
values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ABSTRACT_AV_PLAYLIST);
// create a file path for the benefit of MTP
path = mExternalStoragePaths[0]
+ "/Playlists/" + values.getAsString(Audio.Playlists.NAME);
values.put(MediaStore.MediaColumns.DATA, path);
values.put(FileColumns.PARENT, getParent(helper, db, path));
} else {
Log.e(TAG, "path is empty in insertFile()");
}
} else {
format = MediaFile.getFormatCode(path, mimeType);
}
}
if (format != 0) {
values.put(FileColumns.FORMAT, format);
if (mimeType == null) {
mimeType = MediaFile.getMimeTypeForFormatCode(format);
}
}
if (mimeType == null && path != null) {
mimeType = MediaFile.getMimeTypeForFile(path);
}
if (mimeType != null) {
values.put(FileColumns.MIME_TYPE, mimeType);
if (mediaType == FileColumns.MEDIA_TYPE_NONE && !MediaScanner.isNoMediaPath(path)) {
int fileType = MediaFile.getFileTypeForMimeType(mimeType);
if (MediaFile.isAudioFileType(fileType)) {
mediaType = FileColumns.MEDIA_TYPE_AUDIO;
} else if (MediaFile.isVideoFileType(fileType)) {
mediaType = FileColumns.MEDIA_TYPE_VIDEO;
} else if (MediaFile.isImageFileType(fileType)) {
mediaType = FileColumns.MEDIA_TYPE_IMAGE;
} else if (MediaFile.isPlayListFileType(fileType)) {
mediaType = FileColumns.MEDIA_TYPE_PLAYLIST;
}
}
}
values.put(FileColumns.MEDIA_TYPE, mediaType);
if (rowId == 0) {
if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) {
String name = values.getAsString(Audio.Playlists.NAME);
if (name == null && path == null) {
// MediaScanner will compute the name from the path if we have one
throw new IllegalArgumentException(
"no name was provided when inserting abstract playlist");
}
} else {
if (path == null) {
// path might be null for playlists created on the device
// or transfered via MTP
throw new IllegalArgumentException(
"no path was provided when inserting new file");
}
}
// make sure modification date and size are set
if (path != null) {
File file = new File(path);
if (file.exists()) {
values.put(FileColumns.DATE_MODIFIED, file.lastModified() / 1000);
values.put(FileColumns.SIZE, file.length());
}
}
Long parent = values.getAsLong(FileColumns.PARENT);
if (parent == null) {
if (path != null) {
long parentId = getParent(helper, db, path);
values.put(FileColumns.PARENT, parentId);
}
}
Integer storage = values.getAsInteger(FileColumns.STORAGE_ID);
if (storage == null) {
int storageId = getStorageId(path);
values.put(FileColumns.STORAGE_ID, storageId);
}
helper.mNumInserts++;
rowId = db.insert("files", FileColumns.DATE_MODIFIED, values);
if (LOCAL_LOGV) Log.v(TAG, "insertFile: values=" + values + " returned: " + rowId);
if (rowId != -1 && notify) {
notifyRowIds.add(rowId);
}
} else {
helper.mNumUpdates++;
db.update("files", values, FileColumns._ID + "=?",
new String[] { Long.toString(rowId) });
}
if (format == MtpConstants.FORMAT_ASSOCIATION) {
mDirectoryCache.put(path, rowId);
}
return rowId;
}
private Cursor getObjectReferences(DatabaseHelper helper, SQLiteDatabase db, int handle) {
helper.mNumQueries++;
Cursor c = db.query("files", sMediaTableColumns, "_id=?",
new String[] { Integer.toString(handle) },
null, null, null);
try {
if (c != null && c.moveToNext()) {
long playlistId = c.getLong(0);
int mediaType = c.getInt(1);
if (mediaType != FileColumns.MEDIA_TYPE_PLAYLIST) {
// we only support object references for playlist objects
return null;
}
helper.mNumQueries++;
return db.rawQuery(OBJECT_REFERENCES_QUERY,
new String[] { Long.toString(playlistId) } );
}
} finally {
if (c != null) {
c.close();
}
}
return null;
}
private int setObjectReferences(DatabaseHelper helper, SQLiteDatabase db,
int handle, ContentValues values[]) {
// first look up the media table and media ID for the object
long playlistId = 0;
helper.mNumQueries++;
Cursor c = db.query("files", sMediaTableColumns, "_id=?",
new String[] { Integer.toString(handle) },
null, null, null);
try {
if (c != null && c.moveToNext()) {
int mediaType = c.getInt(1);
if (mediaType != FileColumns.MEDIA_TYPE_PLAYLIST) {
// we only support object references for playlist objects
return 0;
}
playlistId = c.getLong(0);
}
} finally {
if (c != null) {
c.close();
}
}
if (playlistId == 0) {
return 0;
}
// next delete any existing entries
helper.mNumDeletes++;
db.delete("audio_playlists_map", "playlist_id=?",
new String[] { Long.toString(playlistId) });
// finally add the new entries
int count = values.length;
int added = 0;
ContentValues[] valuesList = new ContentValues[count];
for (int i = 0; i < count; i++) {
// convert object ID to audio ID
long audioId = 0;
long objectId = values[i].getAsLong(MediaStore.MediaColumns._ID);
helper.mNumQueries++;
c = db.query("files", sMediaTableColumns, "_id=?",
new String[] { Long.toString(objectId) },
null, null, null);
try {
if (c != null && c.moveToNext()) {
int mediaType = c.getInt(1);
if (mediaType != FileColumns.MEDIA_TYPE_AUDIO) {
// we only allow audio files in playlists, so skip
continue;
}
audioId = c.getLong(0);
}
} finally {
if (c != null) {
c.close();
}
}
if (audioId != 0) {
ContentValues v = new ContentValues();
v.put(MediaStore.Audio.Playlists.Members.PLAYLIST_ID, playlistId);
v.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, audioId);
v.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, added);
valuesList[added++] = v;
}
}
if (added < count) {
// we weren't able to find everything on the list, so lets resize the array
// and pass what we have.
ContentValues[] newValues = new ContentValues[added];
System.arraycopy(valuesList, 0, newValues, 0, added);
valuesList = newValues;
}
return playlistBulkInsert(db,
Audio.Playlists.Members.getContentUri(EXTERNAL_VOLUME, playlistId),
valuesList);
}
private static final String[] GENRE_LOOKUP_PROJECTION = new String[] {
Audio.Genres._ID, // 0
Audio.Genres.NAME, // 1
};
private void updateGenre(long rowId, String genre) {
Uri uri = null;
Cursor cursor = null;
Uri genresUri = MediaStore.Audio.Genres.getContentUri("external");
try {
// see if the genre already exists
cursor = query(genresUri, GENRE_LOOKUP_PROJECTION, MediaStore.Audio.Genres.NAME + "=?",
new String[] { genre }, null);
if (cursor == null || cursor.getCount() == 0) {
// genre does not exist, so create the genre in the genre table
ContentValues values = new ContentValues();
values.put(MediaStore.Audio.Genres.NAME, genre);
uri = insert(genresUri, values);
} else {
// genre already exists, so compute its Uri
cursor.moveToNext();
uri = ContentUris.withAppendedId(genresUri, cursor.getLong(0));
}
if (uri != null) {
uri = Uri.withAppendedPath(uri, MediaStore.Audio.Genres.Members.CONTENT_DIRECTORY);
}
} finally {
// release the cursor if it exists
if (cursor != null) {
cursor.close();
}
}
if (uri != null) {
// add entry to audio_genre_map
ContentValues values = new ContentValues();
values.put(MediaStore.Audio.Genres.Members.AUDIO_ID, Long.valueOf(rowId));
insert(uri, values);
}
}
private Uri insertInternal(Uri uri, int match, ContentValues initialValues,
ArrayList<Long> notifyRowIds) {
long rowId;
if (LOCAL_LOGV) Log.v(TAG, "insertInternal: "+uri+", initValues="+initialValues);
// handle MEDIA_SCANNER before calling getDatabaseForUri()
if (match == MEDIA_SCANNER) {
mMediaScannerVolume = initialValues.getAsString(MediaStore.MEDIA_SCANNER_VOLUME);
DatabaseHelper database = getDatabaseForUri(
Uri.parse("content://media/" + mMediaScannerVolume + "/audio"));
if (database == null) {
Log.w(TAG, "no database for scanned volume " + mMediaScannerVolume);
} else {
database.mScanStartTime = SystemClock.currentTimeMicro();
}
return MediaStore.getMediaScannerUri();
}
String genre = null;
String path = null;
if (initialValues != null) {
genre = initialValues.getAsString(Audio.AudioColumns.GENRE);
initialValues.remove(Audio.AudioColumns.GENRE);
path = initialValues.getAsString(MediaStore.MediaColumns.DATA);
}
Uri newUri = null;
DatabaseHelper helper = getDatabaseForUri(uri);
if (helper == null && match != VOLUMES && match != MTP_CONNECTED) {
throw new UnsupportedOperationException(
"Unknown URI: " + uri);
}
SQLiteDatabase db = ((match == VOLUMES || match == MTP_CONNECTED) ? null
: helper.getWritableDatabase());
switch (match) {
case IMAGES_MEDIA: {
rowId = insertFile(helper, uri, initialValues,
FileColumns.MEDIA_TYPE_IMAGE, true, notifyRowIds);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(
Images.Media.getContentUri(uri.getPathSegments().get(0)), rowId);
}
break;
}
// This will be triggered by requestMediaThumbnail (see getThumbnailUri)
case IMAGES_THUMBNAILS: {
ContentValues values = ensureFile(helper.mInternal, initialValues, ".jpg",
"DCIM/.thumbnails");
helper.mNumInserts++;
rowId = db.insert("thumbnails", "name", values);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(Images.Thumbnails.
getContentUri(uri.getPathSegments().get(0)), rowId);
}
break;
}
// This is currently only used by MICRO_KIND video thumbnail (see getThumbnailUri)
case VIDEO_THUMBNAILS: {
ContentValues values = ensureFile(helper.mInternal, initialValues, ".jpg",
"DCIM/.thumbnails");
helper.mNumInserts++;
rowId = db.insert("videothumbnails", "name", values);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(Video.Thumbnails.
getContentUri(uri.getPathSegments().get(0)), rowId);
}
break;
}
case AUDIO_MEDIA: {
rowId = insertFile(helper, uri, initialValues,
FileColumns.MEDIA_TYPE_AUDIO, true, notifyRowIds);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(Audio.Media.getContentUri(uri.getPathSegments().get(0)), rowId);
if (genre != null) {
updateGenre(rowId, genre);
}
}
break;
}
case AUDIO_MEDIA_ID_GENRES: {
Long audioId = Long.parseLong(uri.getPathSegments().get(2));
ContentValues values = new ContentValues(initialValues);
values.put(Audio.Genres.Members.AUDIO_ID, audioId);
helper.mNumInserts++;
rowId = db.insert("audio_genres_map", "genre_id", values);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(uri, rowId);
}
break;
}
case AUDIO_MEDIA_ID_PLAYLISTS: {
Long audioId = Long.parseLong(uri.getPathSegments().get(2));
ContentValues values = new ContentValues(initialValues);
values.put(Audio.Playlists.Members.AUDIO_ID, audioId);
helper.mNumInserts++;
rowId = db.insert("audio_playlists_map", "playlist_id",
values);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(uri, rowId);
}
break;
}
case AUDIO_GENRES: {
helper.mNumInserts++;
rowId = db.insert("audio_genres", "audio_id", initialValues);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(Audio.Genres.getContentUri(uri.getPathSegments().get(0)), rowId);
}
break;
}
case AUDIO_GENRES_ID_MEMBERS: {
Long genreId = Long.parseLong(uri.getPathSegments().get(3));
ContentValues values = new ContentValues(initialValues);
values.put(Audio.Genres.Members.GENRE_ID, genreId);
helper.mNumInserts++;
rowId = db.insert("audio_genres_map", "genre_id", values);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(uri, rowId);
}
break;
}
case AUDIO_PLAYLISTS: {
ContentValues values = new ContentValues(initialValues);
values.put(MediaStore.Audio.Playlists.DATE_ADDED, System.currentTimeMillis() / 1000);
rowId = insertFile(helper, uri, values,
FileColumns.MEDIA_TYPE_PLAYLIST, true, notifyRowIds);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(Audio.Playlists.getContentUri(uri.getPathSegments().get(0)), rowId);
}
break;
}
case AUDIO_PLAYLISTS_ID:
case AUDIO_PLAYLISTS_ID_MEMBERS: {
Long playlistId = Long.parseLong(uri.getPathSegments().get(3));
ContentValues values = new ContentValues(initialValues);
values.put(Audio.Playlists.Members.PLAYLIST_ID, playlistId);
helper.mNumInserts++;
rowId = db.insert("audio_playlists_map", "playlist_id", values);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(uri, rowId);
}
break;
}
case VIDEO_MEDIA: {
rowId = insertFile(helper, uri, initialValues,
FileColumns.MEDIA_TYPE_VIDEO, true, notifyRowIds);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(Video.Media.getContentUri(
uri.getPathSegments().get(0)), rowId);
}
break;
}
case AUDIO_ALBUMART: {
if (helper.mInternal) {
throw new UnsupportedOperationException("no internal album art allowed");
}
ContentValues values = null;
try {
values = ensureFile(false, initialValues, "", ALBUM_THUMB_FOLDER);
} catch (IllegalStateException ex) {
// probably no more room to store albumthumbs
values = initialValues;
}
helper.mNumInserts++;
rowId = db.insert("album_art", MediaStore.MediaColumns.DATA, values);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(uri, rowId);
}
break;
}
case VOLUMES:
{
String name = initialValues.getAsString("name");
Uri attachedVolume = attachVolume(name);
if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) {
DatabaseHelper dbhelper = getDatabaseForUri(attachedVolume);
if (dbhelper == null) {
Log.e(TAG, "no database for attached volume " + attachedVolume);
} else {
dbhelper.mScanStartTime = SystemClock.currentTimeMicro();
}
}
return attachedVolume;
}
case MTP_CONNECTED:
synchronized (mMtpServiceConnection) {
if (mMtpService == null) {
Context context = getContext();
// MTP is connected, so grab a connection to MtpService
context.bindService(new Intent(context, MtpService.class),
mMtpServiceConnection, Context.BIND_AUTO_CREATE);
}
}
break;
case FILES:
rowId = insertFile(helper, uri, initialValues,
FileColumns.MEDIA_TYPE_NONE, true, notifyRowIds);
if (rowId > 0) {
newUri = Files.getContentUri(uri.getPathSegments().get(0), rowId);
}
break;
case MTP_OBJECTS:
// We don't send a notification if the insert originated from MTP
rowId = insertFile(helper, uri, initialValues,
FileColumns.MEDIA_TYPE_NONE, false, notifyRowIds);
if (rowId > 0) {
newUri = Files.getMtpObjectsUri(uri.getPathSegments().get(0), rowId);
}
break;
default:
throw new UnsupportedOperationException("Invalid URI " + uri);
}
if (path != null && path.toLowerCase(Locale.US).endsWith("/.nomedia")) {
// need to set the media_type of all the files below this folder to 0
processNewNoMediaPath(helper, db, path);
}
return newUri;
}
/*
* Sets the media type of all files below the newly added .nomedia file or
* hidden folder to 0, so the entries no longer appear in e.g. the audio and
* images views.
*
* @param path The path to the new .nomedia file or hidden directory
*/
private void processNewNoMediaPath(final DatabaseHelper helper, final SQLiteDatabase db,
final String path) {
final File nomedia = new File(path);
if (nomedia.exists()) {
hidePath(helper, db, path);
} else {
// File doesn't exist. Try again in a little while.
// XXX there's probably a better way of doing this
new Thread(new Runnable() {
@Override
public void run() {
SystemClock.sleep(2000);
if (nomedia.exists()) {
hidePath(helper, db, path);
} else {
Log.w(TAG, "does not exist: " + path, new Exception());
}
}}).start();
}
}
private void hidePath(DatabaseHelper helper, SQLiteDatabase db, String path) {
File nomedia = new File(path);
String hiddenroot = nomedia.isDirectory() ? path : nomedia.getParent();
ContentValues mediatype = new ContentValues();
mediatype.put("media_type", 0);
int numrows = db.update("files", mediatype,
// the "like" test makes use of the index, while the lower() test ensures it
// doesn't match entries it shouldn't when the path contains sqlite wildcards
"_data LIKE ? AND lower(substr(_data,1,?))=lower(?)",
new String[] { hiddenroot + "/%",
"" + (hiddenroot.length() + 1), hiddenroot + "/"});
helper.mNumUpdates += numrows;
ContentResolver res = getContext().getContentResolver();
res.notifyChange(Uri.parse("content://media/"), null);
}
/*
* Rescan files for missing metadata and set their type accordingly.
* There is code for detecting the removal of a nomedia file or renaming of
* a directory from hidden to non-hidden in the MediaScanner and MtpDatabase,
* both of which call here.
*/
private void processRemovedNoMediaPath(final String path) {
final DatabaseHelper helper;
if (path.startsWith(mExternalStoragePaths[0])) {
helper = getDatabaseForUri(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI);
} else {
helper = getDatabaseForUri(MediaStore.Audio.Media.INTERNAL_CONTENT_URI);
}
SQLiteDatabase db = helper.getWritableDatabase();
new ScannerClient(getContext(), db, path);
}
private static final class ScannerClient implements MediaScannerConnectionClient {
String mPath = null;
MediaScannerConnection mScannerConnection;
SQLiteDatabase mDb;
public ScannerClient(Context context, SQLiteDatabase db, String path) {
mDb = db;
mPath = path;
mScannerConnection = new MediaScannerConnection(context, this);
mScannerConnection.connect();
}
@Override
public void onMediaScannerConnected() {
Cursor c = mDb.query("files", openFileColumns,
// the "like" test makes use of the index, while the lower() ensures it
// doesn't match entries it shouldn't when the path contains sqlite wildcards
"_data like ? AND lower(substr(_data,1,?))=lower(?)",
new String[] { mPath + "/%", "" + (mPath.length() + 1), mPath + "/"},
null, null, null);
while (c.moveToNext()) {
String d = c.getString(0);
File f = new File(d);
if (f.isFile()) {
mScannerConnection.scanFile(d, null);
}
}
mScannerConnection.disconnect();
c.close();
}
@Override
public void onScanCompleted(String path, Uri uri) {
}
}
@Override
public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
throws OperationApplicationException {
// The operations array provides no overall information about the URI(s) being operated
// on, so begin a transaction for ALL of the databases.
DatabaseHelper ihelper = getDatabaseForUri(MediaStore.Audio.Media.INTERNAL_CONTENT_URI);
DatabaseHelper ehelper = getDatabaseForUri(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI);
SQLiteDatabase idb = ihelper.getWritableDatabase();
idb.beginTransaction();
SQLiteDatabase edb = null;
if (ehelper != null) {
edb = ehelper.getWritableDatabase();
edb.beginTransaction();
}
try {
ContentProviderResult[] result = super.applyBatch(operations);
idb.setTransactionSuccessful();
if (edb != null) {
edb.setTransactionSuccessful();
}
// Rather than sending targeted change notifications for every Uri
// affected by the batch operation, just invalidate the entire internal
// and external name space.
ContentResolver res = getContext().getContentResolver();
res.notifyChange(Uri.parse("content://media/"), null);
return result;
} finally {
idb.endTransaction();
if (edb != null) {
edb.endTransaction();
}
}
}
private MediaThumbRequest requestMediaThumbnail(String path, Uri uri, int priority, long magic) {
synchronized (mMediaThumbQueue) {
MediaThumbRequest req = null;
try {
req = new MediaThumbRequest(
getContext().getContentResolver(), path, uri, priority, magic);
mMediaThumbQueue.add(req);
// Trigger the handler.
Message msg = mThumbHandler.obtainMessage(IMAGE_THUMB);
msg.sendToTarget();
} catch (Throwable t) {
Log.w(TAG, t);
}
return req;
}
}
private String generateFileName(boolean internal, String preferredExtension, String directoryName)
{
// create a random file
String name = String.valueOf(System.currentTimeMillis());
if (internal) {
throw new UnsupportedOperationException("Writing to internal storage is not supported.");
// return Environment.getDataDirectory()
// + "/" + directoryName + "/" + name + preferredExtension;
} else {
return mExternalStoragePaths[0] + "/" + directoryName + "/" + name + preferredExtension;
}
}
private boolean ensureFileExists(String path) {
File file = new File(path);
if (file.exists()) {
return true;
} else {
// we will not attempt to create the first directory in the path
// (for example, do not create /sdcard if the SD card is not mounted)
int secondSlash = path.indexOf('/', 1);
if (secondSlash < 1) return false;
String directoryPath = path.substring(0, secondSlash);
File directory = new File(directoryPath);
if (!directory.exists())
return false;
file.getParentFile().mkdirs();
try {
return file.createNewFile();
} catch(IOException ioe) {
Log.e(TAG, "File creation failed", ioe);
}
return false;
}
}
private static final class GetTableAndWhereOutParameter {
public String table;
public String where;
}
static final GetTableAndWhereOutParameter sGetTableAndWhereParam =
new GetTableAndWhereOutParameter();
private void getTableAndWhere(Uri uri, int match, String userWhere,
GetTableAndWhereOutParameter out) {
String where = null;
switch (match) {
case IMAGES_MEDIA:
out.table = "files";
where = FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_IMAGE;
break;
case IMAGES_MEDIA_ID:
out.table = "files";
where = "_id = " + uri.getPathSegments().get(3);
break;
case IMAGES_THUMBNAILS_ID:
where = "_id=" + uri.getPathSegments().get(3);
case IMAGES_THUMBNAILS:
out.table = "thumbnails";
break;
case AUDIO_MEDIA:
out.table = "files";
where = FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_AUDIO;
break;
case AUDIO_MEDIA_ID:
out.table = "files";
where = "_id=" + uri.getPathSegments().get(3);
break;
case AUDIO_MEDIA_ID_GENRES:
out.table = "audio_genres";
where = "audio_id=" + uri.getPathSegments().get(3);
break;
case AUDIO_MEDIA_ID_GENRES_ID:
out.table = "audio_genres";
where = "audio_id=" + uri.getPathSegments().get(3) +
" AND genre_id=" + uri.getPathSegments().get(5);
break;
case AUDIO_MEDIA_ID_PLAYLISTS:
out.table = "audio_playlists";
where = "audio_id=" + uri.getPathSegments().get(3);
break;
case AUDIO_MEDIA_ID_PLAYLISTS_ID:
out.table = "audio_playlists";
where = "audio_id=" + uri.getPathSegments().get(3) +
" AND playlists_id=" + uri.getPathSegments().get(5);
break;
case AUDIO_GENRES:
out.table = "audio_genres";
break;
case AUDIO_GENRES_ID:
out.table = "audio_genres";
where = "_id=" + uri.getPathSegments().get(3);
break;
case AUDIO_GENRES_ID_MEMBERS:
out.table = "audio_genres";
where = "genre_id=" + uri.getPathSegments().get(3);
break;
case AUDIO_PLAYLISTS:
out.table = "files";
where = FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_PLAYLIST;
break;
case AUDIO_PLAYLISTS_ID:
out.table = "files";
where = "_id=" + uri.getPathSegments().get(3);
break;
case AUDIO_PLAYLISTS_ID_MEMBERS:
out.table = "audio_playlists_map";
where = "playlist_id=" + uri.getPathSegments().get(3);
break;
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
out.table = "audio_playlists_map";
where = "playlist_id=" + uri.getPathSegments().get(3) +
" AND _id=" + uri.getPathSegments().get(5);
break;
case AUDIO_ALBUMART_ID:
out.table = "album_art";
where = "album_id=" + uri.getPathSegments().get(3);
break;
case VIDEO_MEDIA:
out.table = "files";
where = FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_VIDEO;
break;
case VIDEO_MEDIA_ID:
out.table = "files";
where = "_id=" + uri.getPathSegments().get(3);
break;
case VIDEO_THUMBNAILS_ID:
where = "_id=" + uri.getPathSegments().get(3);
case VIDEO_THUMBNAILS:
out.table = "videothumbnails";
break;
case FILES_ID:
case MTP_OBJECTS_ID:
where = "_id=" + uri.getPathSegments().get(2);
case FILES:
case MTP_OBJECTS:
out.table = "files";
break;
default:
throw new UnsupportedOperationException(
"Unknown or unsupported URL: " + uri.toString());
}
// Add in the user requested WHERE clause, if needed
if (!TextUtils.isEmpty(userWhere)) {
if (!TextUtils.isEmpty(where)) {
out.where = where + " AND (" + userWhere + ")";
} else {
out.where = userWhere;
}
} else {
out.where = where;
}
}
@Override
public int delete(Uri uri, String userWhere, String[] whereArgs) {
int count;
int match = URI_MATCHER.match(uri);
// handle MEDIA_SCANNER before calling getDatabaseForUri()
if (match == MEDIA_SCANNER) {
if (mMediaScannerVolume == null) {
return 0;
}
DatabaseHelper database = getDatabaseForUri(
Uri.parse("content://media/" + mMediaScannerVolume + "/audio"));
if (database == null) {
Log.w(TAG, "no database for scanned volume " + mMediaScannerVolume);
} else {
database.mScanStopTime = SystemClock.currentTimeMicro();
String msg = dump(database, false);
logToDb(database.getWritableDatabase(), msg);
}
mMediaScannerVolume = null;
return 1;
}
if (match == VOLUMES_ID) {
detachVolume(uri);
count = 1;
} else if (match == MTP_CONNECTED) {
synchronized (mMtpServiceConnection) {
if (mMtpService != null) {
// MTP has disconnected, so release our connection to MtpService
getContext().unbindService(mMtpServiceConnection);
count = 1;
// mMtpServiceConnection.onServiceDisconnected might not get called,
// so set mMtpService = null here
mMtpService = null;
} else {
count = 0;
}
}
} else {
DatabaseHelper database = getDatabaseForUri(uri);
if (database == null) {
throw new UnsupportedOperationException(
"Unknown URI: " + uri + " match: " + match);
}
database.mNumDeletes++;
SQLiteDatabase db = database.getWritableDatabase();
synchronized (sGetTableAndWhereParam) {
getTableAndWhere(uri, match, userWhere, sGetTableAndWhereParam);
if (sGetTableAndWhereParam.table.equals("files")) {
String deleteparam = uri.getQueryParameter(MediaStore.PARAM_DELETE_DATA);
if (deleteparam == null || ! deleteparam.equals("false")) {
database.mNumQueries++;
Cursor c = db.query(sGetTableAndWhereParam.table,
sMediaTypeDataId,
sGetTableAndWhereParam.where, whereArgs, null, null, null);
String [] idvalue = new String[] { "" };
String [] playlistvalues = new String[] { "", "" };
while (c.moveToNext()) {
int mediatype = c.getInt(0);
if (mediatype == FileColumns.MEDIA_TYPE_IMAGE) {
try {
Libcore.os.remove(c.getString(1));
idvalue[0] = "" + c.getLong(2);
database.mNumQueries++;
Cursor cc = db.query("thumbnails", sDataOnlyColumn,
"image_id=?", idvalue, null, null, null);
while (cc.moveToNext()) {
Libcore.os.remove(cc.getString(0));
}
cc.close();
database.mNumDeletes++;
db.delete("thumbnails", "image_id=?", idvalue);
} catch (ErrnoException e) {
}
} else if (mediatype == FileColumns.MEDIA_TYPE_VIDEO) {
try {
Libcore.os.remove(c.getString(1));
} catch (ErrnoException e) {
}
} else if (mediatype == FileColumns.MEDIA_TYPE_AUDIO) {
if (!database.mInternal) {
idvalue[0] = "" + c.getLong(2);
db.delete("audio_genres_map", "audio_id=?", idvalue);
// for each playlist that the item appears in, move
// all the items behind it forward by one
Cursor cc = db.query("audio_playlists_map",
sPlaylistIdPlayOrder,
"audio_id=?", idvalue, null, null, null);
while (cc.moveToNext()) {
playlistvalues[0] = "" + cc.getLong(0);
playlistvalues[1] = "" + cc.getInt(1);
db.execSQL("UPDATE audio_playlists_map" +
" SET play_order=play_order-1" +
" WHERE playlist_id=? AND play_order>?",
playlistvalues);
}
cc.close();
db.delete("audio_playlists_map", "audio_id=?", idvalue);
}
} else if (mediatype == FileColumns.MEDIA_TYPE_PLAYLIST) {
// TODO, maybe: remove the audio_playlists_cleanup trigger and implement
// it functionality here (clean up the playlist map)
}
}
c.close();
}
}
switch (match) {
case MTP_OBJECTS:
case MTP_OBJECTS_ID:
try {
// don't send objectRemoved event since this originated from MTP
mDisableMtpObjectCallbacks = true;
database.mNumDeletes++;
count = db.delete("files", sGetTableAndWhereParam.where, whereArgs);
} finally {
mDisableMtpObjectCallbacks = false;
}
break;
case AUDIO_GENRES_ID_MEMBERS:
database.mNumDeletes++;
count = db.delete("audio_genres_map",
sGetTableAndWhereParam.where, whereArgs);
break;
case IMAGES_THUMBNAILS_ID:
case IMAGES_THUMBNAILS:
case VIDEO_THUMBNAILS_ID:
case VIDEO_THUMBNAILS:
// Delete the referenced files first.
Cursor c = db.query(sGetTableAndWhereParam.table,
sDataOnlyColumn,
sGetTableAndWhereParam.where, whereArgs, null, null, null);
if (c != null) {
while (c.moveToNext()) {
try {
Libcore.os.remove(c.getString(0));
} catch (ErrnoException e) {
}
}
c.close();
}
database.mNumDeletes++;
count = db.delete(sGetTableAndWhereParam.table,
sGetTableAndWhereParam.where, whereArgs);
break;
default:
database.mNumDeletes++;
count = db.delete(sGetTableAndWhereParam.table,
sGetTableAndWhereParam.where, whereArgs);
break;
}
// Since there are multiple Uris that can refer to the same files
// and deletes can affect other objects in storage (like subdirectories
// or playlists) we will notify a change on the entire volume to make
// sure no listeners miss the notification.
String volume = uri.getPathSegments().get(0);
Uri notifyUri = Uri.parse("content://" + MediaStore.AUTHORITY + "/" + volume);
getContext().getContentResolver().notifyChange(notifyUri, null);
}
}
return count;
}
@Override
public Bundle call(String method, String arg, Bundle extras) {
if (MediaStore.UNHIDE_CALL.equals(method)) {
processRemovedNoMediaPath(arg);
return null;
}
throw new UnsupportedOperationException("Unsupported call: " + method);
}
@Override
public int update(Uri uri, ContentValues initialValues, String userWhere,
String[] whereArgs) {
int count;
// Log.v(TAG, "update for uri="+uri+", initValues="+initialValues);
int match = URI_MATCHER.match(uri);
DatabaseHelper helper = getDatabaseForUri(uri);
if (helper == null) {
throw new UnsupportedOperationException(
"Unknown URI: " + uri);
}
helper.mNumUpdates++;
SQLiteDatabase db = helper.getWritableDatabase();
String genre = null;
if (initialValues != null) {
genre = initialValues.getAsString(Audio.AudioColumns.GENRE);
initialValues.remove(Audio.AudioColumns.GENRE);
}
synchronized (sGetTableAndWhereParam) {
getTableAndWhere(uri, match, userWhere, sGetTableAndWhereParam);
// special case renaming directories via MTP.
// in this case we must update all paths in the database with
// the directory name as a prefix
if ((match == MTP_OBJECTS || match == MTP_OBJECTS_ID)
&& initialValues != null && initialValues.size() == 1) {
String oldPath = null;
String newPath = initialValues.getAsString(MediaStore.MediaColumns.DATA);
mDirectoryCache.remove(newPath);
// MtpDatabase will rename the directory first, so we test the new file name
File f = new File(newPath);
if (newPath != null && f.isDirectory()) {
helper.mNumQueries++;
Cursor cursor = db.query(sGetTableAndWhereParam.table, PATH_PROJECTION,
userWhere, whereArgs, null, null, null);
try {
if (cursor != null && cursor.moveToNext()) {
oldPath = cursor.getString(1);
}
} finally {
if (cursor != null) cursor.close();
}
if (oldPath != null) {
mDirectoryCache.remove(oldPath);
// first rename the row for the directory
helper.mNumUpdates++;
count = db.update(sGetTableAndWhereParam.table, initialValues,
sGetTableAndWhereParam.where, whereArgs);
if (count > 0) {
// then update the paths of any files and folders contained in the directory.
Object[] bindArgs = new Object[] {newPath, oldPath.length() + 1,
oldPath + "/%", (oldPath.length() + 1), oldPath + "/"};
helper.mNumUpdates++;
db.execSQL("UPDATE files SET _data=?1||SUBSTR(_data, ?2)" +
// the "like" test makes use of the index, while the lower()
// test ensures it doesn't match entries it shouldn't when the
// path contains sqlite wildcards
" WHERE _data LIKE ?3 AND lower(substr(_data,1,?4))=lower(?5);",
bindArgs);
}
if (count > 0 && !db.inTransaction()) {
getContext().getContentResolver().notifyChange(uri, null);
}
if (f.getName().startsWith(".")) {
// the new directory name is hidden
processNewNoMediaPath(helper, db, newPath);
}
return count;
}
} else if (newPath.toLowerCase(Locale.US).endsWith("/.nomedia")) {
processNewNoMediaPath(helper, db, newPath);
}
}
switch (match) {
case AUDIO_MEDIA:
case AUDIO_MEDIA_ID:
{
ContentValues values = new ContentValues(initialValues);
String albumartist = values.getAsString(MediaStore.Audio.Media.ALBUM_ARTIST);
String compilation = values.getAsString(MediaStore.Audio.Media.COMPILATION);
values.remove(MediaStore.Audio.Media.COMPILATION);
// Insert the artist into the artist table and remove it from
// the input values
String artist = values.getAsString("artist");
values.remove("artist");
if (artist != null) {
long artistRowId;
HashMap<String, Long> artistCache = helper.mArtistCache;
synchronized(artistCache) {
Long temp = artistCache.get(artist);
if (temp == null) {
artistRowId = getKeyIdForName(helper, db,
"artists", "artist_key", "artist",
artist, artist, null, 0, null, artistCache, uri);
} else {
artistRowId = temp.longValue();
}
}
values.put("artist_id", Integer.toString((int)artistRowId));
}
// Do the same for the album field.
String so = values.getAsString("album");
values.remove("album");
if (so != null) {
String path = values.getAsString(MediaStore.MediaColumns.DATA);
int albumHash = 0;
if (albumartist != null) {
albumHash = albumartist.hashCode();
} else if (compilation != null && compilation.equals("1")) {
// nothing to do, hash already set
} else {
if (path == null) {
if (match == AUDIO_MEDIA) {
Log.w(TAG, "Possible multi row album name update without"
+ " path could give wrong album key");
} else {
//Log.w(TAG, "Specify path to avoid extra query");
Cursor c = query(uri,
new String[] { MediaStore.Audio.Media.DATA},
null, null, null);
if (c != null) {
try {
int numrows = c.getCount();
if (numrows == 1) {
c.moveToFirst();
path = c.getString(0);
} else {
Log.e(TAG, "" + numrows + " rows for " + uri);
}
} finally {
c.close();
}
}
}
}
if (path != null) {
albumHash = path.substring(0, path.lastIndexOf('/')).hashCode();
}
}
String s = so.toString();
long albumRowId;
HashMap<String, Long> albumCache = helper.mAlbumCache;
synchronized(albumCache) {
String cacheName = s + albumHash;
Long temp = albumCache.get(cacheName);
if (temp == null) {
albumRowId = getKeyIdForName(helper, db,
"albums", "album_key", "album",
s, cacheName, path, albumHash, artist, albumCache, uri);
} else {
albumRowId = temp.longValue();
}
}
values.put("album_id", Integer.toString((int)albumRowId));
}
// don't allow the title_key field to be updated directly
values.remove("title_key");
// If the title field is modified, update the title_key
so = values.getAsString("title");
if (so != null) {
String s = so.toString();
values.put("title_key", MediaStore.Audio.keyFor(s));
// do a final trim of the title, in case it started with the special
// "sort first" character (ascii \001)
values.remove("title");
values.put("title", s.trim());
}
helper.mNumUpdates++;
count = db.update(sGetTableAndWhereParam.table, values,
sGetTableAndWhereParam.where, whereArgs);
if (genre != null) {
if (count == 1 && match == AUDIO_MEDIA_ID) {
long rowId = Long.parseLong(uri.getPathSegments().get(3));
updateGenre(rowId, genre);
} else {
// can't handle genres for bulk update or for non-audio files
Log.w(TAG, "ignoring genre in update: count = "
+ count + " match = " + match);
}
}
}
break;
case IMAGES_MEDIA:
case IMAGES_MEDIA_ID:
case VIDEO_MEDIA:
case VIDEO_MEDIA_ID:
{
ContentValues values = new ContentValues(initialValues);
// Don't allow bucket id or display name to be updated directly.
// The same names are used for both images and table columns, so
// we use the ImageColumns constants here.
values.remove(ImageColumns.BUCKET_ID);
values.remove(ImageColumns.BUCKET_DISPLAY_NAME);
// If the data is being modified update the bucket values
String data = values.getAsString(MediaColumns.DATA);
if (data != null) {
computeBucketValues(data, values);
}
computeTakenTime(values);
helper.mNumUpdates++;
count = db.update(sGetTableAndWhereParam.table, values,
sGetTableAndWhereParam.where, whereArgs);
// if this is a request from MediaScanner, DATA should contains file path
// we only process update request from media scanner, otherwise the requests
// could be duplicate.
if (count > 0 && values.getAsString(MediaStore.MediaColumns.DATA) != null) {
helper.mNumQueries++;
Cursor c = db.query(sGetTableAndWhereParam.table,
READY_FLAG_PROJECTION, sGetTableAndWhereParam.where,
whereArgs, null, null, null);
if (c != null) {
try {
while (c.moveToNext()) {
long magic = c.getLong(2);
if (magic == 0) {
requestMediaThumbnail(c.getString(1), uri,
MediaThumbRequest.PRIORITY_NORMAL, 0);
}
}
} finally {
c.close();
}
}
}
}
break;
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
String moveit = uri.getQueryParameter("move");
if (moveit != null) {
String key = MediaStore.Audio.Playlists.Members.PLAY_ORDER;
if (initialValues.containsKey(key)) {
int newpos = initialValues.getAsInteger(key);
List <String> segments = uri.getPathSegments();
long playlist = Long.valueOf(segments.get(3));
int oldpos = Integer.valueOf(segments.get(5));
return movePlaylistEntry(helper, db, playlist, oldpos, newpos);
}
throw new IllegalArgumentException("Need to specify " + key +
" when using 'move' parameter");
}
// fall through
default:
helper.mNumUpdates++;
count = db.update(sGetTableAndWhereParam.table, initialValues,
sGetTableAndWhereParam.where, whereArgs);
break;
}
}
// in a transaction, the code that began the transaction should be taking
// care of notifications once it ends the transaction successfully
if (count > 0 && !db.inTransaction()) {
getContext().getContentResolver().notifyChange(uri, null);
}
return count;
}
private int movePlaylistEntry(DatabaseHelper helper, SQLiteDatabase db,
long playlist, int from, int to) {
if (from == to) {
return 0;
}
db.beginTransaction();
try {
int numlines = 0;
helper.mNumUpdates += 3;
Cursor c = db.query("audio_playlists_map",
new String [] {"play_order" },
"playlist_id=?", new String[] {"" + playlist}, null, null, "play_order",
from + ",1");
c.moveToFirst();
int from_play_order = c.getInt(0);
c.close();
c = db.query("audio_playlists_map",
new String [] {"play_order" },
"playlist_id=?", new String[] {"" + playlist}, null, null, "play_order",
to + ",1");
c.moveToFirst();
int to_play_order = c.getInt(0);
c.close();
db.execSQL("UPDATE audio_playlists_map SET play_order=-1" +
" WHERE play_order=" + from_play_order +
" AND playlist_id=" + playlist);
// We could just run both of the next two statements, but only one of
// of them will actually do anything, so might as well skip the compile
// and execute steps.
if (from < to) {
db.execSQL("UPDATE audio_playlists_map SET play_order=play_order-1" +
" WHERE play_order<=" + to_play_order +
" AND play_order>" + from_play_order +
" AND playlist_id=" + playlist);
numlines = to - from + 1;
} else {
db.execSQL("UPDATE audio_playlists_map SET play_order=play_order+1" +
" WHERE play_order>=" + to_play_order +
" AND play_order<" + from_play_order +
" AND playlist_id=" + playlist);
numlines = from - to + 1;
}
db.execSQL("UPDATE audio_playlists_map SET play_order=" + to_play_order +
" WHERE play_order=-1 AND playlist_id=" + playlist);
db.setTransactionSuccessful();
Uri uri = MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI
.buildUpon().appendEncodedPath(String.valueOf(playlist)).build();
getContext().getContentResolver().notifyChange(uri, null);
return numlines;
} finally {
db.endTransaction();
}
}
private static final String[] openFileColumns = new String[] {
MediaStore.MediaColumns.DATA,
};
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode)
throws FileNotFoundException {
ParcelFileDescriptor pfd = null;
if (URI_MATCHER.match(uri) == AUDIO_ALBUMART_FILE_ID) {
// get album art for the specified media file
DatabaseHelper database = getDatabaseForUri(uri);
if (database == null) {
throw new IllegalStateException("Couldn't open database for " + uri);
}
SQLiteDatabase db = database.getReadableDatabase();
if (db == null) {
throw new IllegalStateException("Couldn't open database for " + uri);
}
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
int songid = Integer.parseInt(uri.getPathSegments().get(3));
qb.setTables("audio_meta");
qb.appendWhere("_id=" + songid);
Cursor c = qb.query(db,
new String [] {
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.ALBUM_ID },
null, null, null, null, null);
if (c.moveToFirst()) {
String audiopath = c.getString(0);
int albumid = c.getInt(1);
// Try to get existing album art for this album first, which
// could possibly have been obtained from a different file.
// If that fails, try to get it from this specific file.
Uri newUri = ContentUris.withAppendedId(ALBUMART_URI, albumid);
try {
pfd = openFileAndEnforcePathPermissionsHelper(newUri, mode);
} catch (FileNotFoundException ex) {
// That didn't work, now try to get it from the specific file
pfd = getThumb(database, db, audiopath, albumid, null);
}
}
c.close();
return pfd;
}
try {
pfd = openFileAndEnforcePathPermissionsHelper(uri, mode);
} catch (FileNotFoundException ex) {
if (mode.contains("w")) {
// if the file couldn't be created, we shouldn't extract album art
throw ex;
}
if (URI_MATCHER.match(uri) == AUDIO_ALBUMART_ID) {
// Tried to open an album art file which does not exist. Regenerate.
DatabaseHelper database = getDatabaseForUri(uri);
if (database == null) {
throw ex;
}
SQLiteDatabase db = database.getReadableDatabase();
if (db == null) {
throw new IllegalStateException("Couldn't open database for " + uri);
}
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
int albumid = Integer.parseInt(uri.getPathSegments().get(3));
qb.setTables("audio_meta");
qb.appendWhere("album_id=" + albumid);
Cursor c = qb.query(db,
new String [] {
MediaStore.Audio.Media.DATA },
null, null, null, null, MediaStore.Audio.Media.TRACK);
if (c.moveToFirst()) {
String audiopath = c.getString(0);
pfd = getThumb(database, db, audiopath, albumid, uri);
}
c.close();
}
if (pfd == null) {
throw ex;
}
}
return pfd;
}
/**
* Return the {@link MediaColumns#DATA} field for the given {@code Uri}.
*/
private File queryForDataFile(Uri uri) throws FileNotFoundException {
final Cursor cursor = query(
uri, new String[] { MediaColumns.DATA }, null, null, null);
if (cursor == null) {
throw new FileNotFoundException("Missing cursor for " + uri);
}
try {
switch (cursor.getCount()) {
case 0:
throw new FileNotFoundException("No entry for " + uri);
case 1:
if (cursor.moveToFirst()) {
return new File(cursor.getString(0));
} else {
throw new FileNotFoundException("Unable to read entry for " + uri);
}
default:
throw new FileNotFoundException("Multiple items at " + uri);
}
} finally {
cursor.close();
}
}
/**
* Replacement for {@link #openFileHelper(Uri, String)} which enforces any
* permissions applicable to the path before returning.
*/
private ParcelFileDescriptor openFileAndEnforcePathPermissionsHelper(Uri uri, String mode)
throws FileNotFoundException {
final int modeBits = ContentResolver.modeToMode(uri, mode);
final boolean isWrite = (modeBits & MODE_WRITE_ONLY) != 0;
File file = queryForDataFile(uri);
final String path;
try {
path = file.getCanonicalPath();
} catch (IOException e) {
throw new IllegalArgumentException("Unable to resolve canonical path for " + file, e);
}
if (path.startsWith(sExternalPath)) {
getContext().enforceCallingOrSelfPermission(
READ_EXTERNAL_STORAGE, "External path: " + path);
if (isWrite) {
getContext().enforceCallingOrSelfPermission(
WRITE_EXTERNAL_STORAGE, "External path: " + path);
}
// bypass emulation layer when file is opened for reading, but only
// when opening read-only and we have an exact match.
if (modeBits == MODE_READ_ONLY && Environment.isExternalStorageEmulated()) {
final File directFile = new File(Environment.getMediaStorageDirectory(), path
.substring(sExternalPath.length()));
if (directFile.exists()) {
file = directFile;
}
}
} else if (path.startsWith(sCachePath)) {
getContext().enforceCallingOrSelfPermission(
ACCESS_CACHE_FILESYSTEM, "Cache path: " + path);
}
return ParcelFileDescriptor.open(file, modeBits);
}
private class ThumbData {
DatabaseHelper helper;
SQLiteDatabase db;
String path;
long album_id;
Uri albumart_uri;
}
private void makeThumbAsync(DatabaseHelper helper, SQLiteDatabase db,
String path, long album_id) {
synchronized (mPendingThumbs) {
if (mPendingThumbs.contains(path)) {
// There's already a request to make an album art thumbnail
// for this audio file in the queue.
return;
}
mPendingThumbs.add(path);
}
ThumbData d = new ThumbData();
d.helper = helper;
d.db = db;
d.path = path;
d.album_id = album_id;
d.albumart_uri = ContentUris.withAppendedId(mAlbumArtBaseUri, album_id);
// Instead of processing thumbnail requests in the order they were
// received we instead process them stack-based, i.e. LIFO.
// The idea behind this is that the most recently requested thumbnails
// are most likely the ones still in the user's view, whereas those
// requested earlier may have already scrolled off.
synchronized (mThumbRequestStack) {
mThumbRequestStack.push(d);
}
// Trigger the handler.
Message msg = mThumbHandler.obtainMessage(ALBUM_THUMB);
msg.sendToTarget();
}
// Extract compressed image data from the audio file itself or, if that fails,
// look for a file "AlbumArt.jpg" in the containing directory.
private static byte[] getCompressedAlbumArt(Context context, String path) {
byte[] compressed = null;
try {
File f = new File(path);
ParcelFileDescriptor pfd = ParcelFileDescriptor.open(f,
ParcelFileDescriptor.MODE_READ_ONLY);
MediaScanner scanner = new MediaScanner(context);
compressed = scanner.extractAlbumArt(pfd.getFileDescriptor());
pfd.close();
// If no embedded art exists, look for a suitable image file in the
// same directory as the media file, except if that directory is
// is the root directory of the sd card or the download directory.
// We look for, in order of preference:
// 0 AlbumArt.jpg
// 1 AlbumArt*Large.jpg
// 2 Any other jpg image with 'albumart' anywhere in the name
// 3 Any other jpg image
// 4 any other png image
if (compressed == null && path != null) {
int lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) {
String artPath = path.substring(0, lastSlash);
String sdroot = mExternalStoragePaths[0];
String dwndir = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS).getAbsolutePath();
String bestmatch = null;
synchronized (sFolderArtMap) {
if (sFolderArtMap.containsKey(artPath)) {
bestmatch = sFolderArtMap.get(artPath);
} else if (!artPath.equalsIgnoreCase(sdroot) &&
!artPath.equalsIgnoreCase(dwndir)) {
File dir = new File(artPath);
String [] entrynames = dir.list();
if (entrynames == null) {
return null;
}
bestmatch = null;
int matchlevel = 1000;
for (int i = entrynames.length - 1; i >=0; i--) {
String entry = entrynames[i].toLowerCase();
if (entry.equals("albumart.jpg")) {
bestmatch = entrynames[i];
break;
} else if (entry.startsWith("albumart")
&& entry.endsWith("large.jpg")
&& matchlevel > 1) {
bestmatch = entrynames[i];
matchlevel = 1;
} else if (entry.contains("albumart")
&& entry.endsWith(".jpg")
&& matchlevel > 2) {
bestmatch = entrynames[i];
matchlevel = 2;
} else if (entry.endsWith(".jpg") && matchlevel > 3) {
bestmatch = entrynames[i];
matchlevel = 3;
} else if (entry.endsWith(".png") && matchlevel > 4) {
bestmatch = entrynames[i];
matchlevel = 4;
}
}
// note that this may insert null if no album art was found
sFolderArtMap.put(artPath, bestmatch);
}
}
if (bestmatch != null) {
File file = new File(artPath, bestmatch);
if (file.exists()) {
compressed = new byte[(int)file.length()];
FileInputStream stream = null;
try {
stream = new FileInputStream(file);
stream.read(compressed);
} catch (IOException ex) {
compressed = null;
} finally {
if (stream != null) {
stream.close();
}
}
}
}
}
}
} catch (IOException e) {
}
return compressed;
}
// Return a URI to write the album art to and update the database as necessary.
Uri getAlbumArtOutputUri(DatabaseHelper helper, SQLiteDatabase db, long album_id, Uri albumart_uri) {
Uri out = null;
// TODO: this could be done more efficiently with a call to db.replace(), which
// replaces or inserts as needed, making it unnecessary to query() first.
if (albumart_uri != null) {
Cursor c = query(albumart_uri, new String [] { MediaStore.MediaColumns.DATA },
null, null, null);
try {
if (c != null && c.moveToFirst()) {
String albumart_path = c.getString(0);
if (ensureFileExists(albumart_path)) {
out = albumart_uri;
}
} else {
albumart_uri = null;
}
} finally {
if (c != null) {
c.close();
}
}
}
if (albumart_uri == null){
ContentValues initialValues = new ContentValues();
initialValues.put("album_id", album_id);
try {
ContentValues values = ensureFile(false, initialValues, "", ALBUM_THUMB_FOLDER);
helper.mNumInserts++;
long rowId = db.insert("album_art", MediaStore.MediaColumns.DATA, values);
if (rowId > 0) {
out = ContentUris.withAppendedId(ALBUMART_URI, rowId);
}
} catch (IllegalStateException ex) {
Log.e(TAG, "error creating album thumb file");
}
}
return out;
}
// Write out the album art to the output URI, recompresses the given Bitmap
// if necessary, otherwise writes the compressed data.
private void writeAlbumArt(
boolean need_to_recompress, Uri out, byte[] compressed, Bitmap bm) {
boolean success = false;
try {
OutputStream outstream = getContext().getContentResolver().openOutputStream(out);
if (!need_to_recompress) {
// No need to recompress here, just write out the original
// compressed data here.
outstream.write(compressed);
success = true;
} else {
success = bm.compress(Bitmap.CompressFormat.JPEG, 85, outstream);
}
outstream.close();
} catch (FileNotFoundException ex) {
Log.e(TAG, "error creating file", ex);
} catch (IOException ex) {
Log.e(TAG, "error creating file", ex);
}
if (!success) {
// the thumbnail was not written successfully, delete the entry that refers to it
getContext().getContentResolver().delete(out, null, null);
}
}
private ParcelFileDescriptor getThumb(DatabaseHelper helper, SQLiteDatabase db, String path,
long album_id, Uri albumart_uri) {
ThumbData d = new ThumbData();
d.helper = helper;
d.db = db;
d.path = path;
d.album_id = album_id;
d.albumart_uri = albumart_uri;
return makeThumbInternal(d);
}
private ParcelFileDescriptor makeThumbInternal(ThumbData d) {
byte[] compressed = getCompressedAlbumArt(getContext(), d.path);
if (compressed == null) {
return null;
}
Bitmap bm = null;
boolean need_to_recompress = true;
try {
// get the size of the bitmap
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
opts.inSampleSize = 1;
BitmapFactory.decodeByteArray(compressed, 0, compressed.length, opts);
// request a reasonably sized output image
final Resources r = getContext().getResources();
final int maximumThumbSize = r.getDimensionPixelSize(R.dimen.maximum_thumb_size);
while (opts.outHeight > maximumThumbSize || opts.outWidth > maximumThumbSize) {
opts.outHeight /= 2;
opts.outWidth /= 2;
opts.inSampleSize *= 2;
}
if (opts.inSampleSize == 1) {
// The original album art was of proper size, we won't have to
// recompress the bitmap later.
need_to_recompress = false;
} else {
// get the image for real now
opts.inJustDecodeBounds = false;
opts.inPreferredConfig = Bitmap.Config.RGB_565;
bm = BitmapFactory.decodeByteArray(compressed, 0, compressed.length, opts);
if (bm != null && bm.getConfig() == null) {
Bitmap nbm = bm.copy(Bitmap.Config.RGB_565, false);
if (nbm != null && nbm != bm) {
bm.recycle();
bm = nbm;
}
}
}
} catch (Exception e) {
}
if (need_to_recompress && bm == null) {
return null;
}
if (d.albumart_uri == null) {
// this one doesn't need to be saved (probably a song with an unknown album),
// so stick it in a memory file and return that
try {
return ParcelFileDescriptor.fromData(compressed, "albumthumb");
} catch (IOException e) {
}
} else {
// This one needs to actually be saved on the sd card.
// This is wrapped in a transaction because there are various things
// that could go wrong while generating the thumbnail, and we only want
// to update the database when all steps succeeded.
d.db.beginTransaction();
try {
Uri out = getAlbumArtOutputUri(d.helper, d.db, d.album_id, d.albumart_uri);
if (out != null) {
writeAlbumArt(need_to_recompress, out, compressed, bm);
getContext().getContentResolver().notifyChange(MEDIA_URI, null);
ParcelFileDescriptor pfd = openFileHelper(out, "r");
d.db.setTransactionSuccessful();
return pfd;
}
} catch (FileNotFoundException ex) {
// do nothing, just return null below
} catch (UnsupportedOperationException ex) {
// do nothing, just return null below
} finally {
d.db.endTransaction();
if (bm != null) {
bm.recycle();
}
}
}
return null;
}
/**
* Look up the artist or album entry for the given name, creating that entry
* if it does not already exists.
* @param db The database
* @param table The table to store the key/name pair in.
* @param keyField The name of the key-column
* @param nameField The name of the name-column
* @param rawName The name that the calling app was trying to insert into the database
* @param cacheName The string that will be inserted in to the cache
* @param path The full path to the file being inserted in to the audio table
* @param albumHash A hash to distinguish between different albums of the same name
* @param artist The name of the artist, if known
* @param cache The cache to add this entry to
* @param srcuri The Uri that prompted the call to this method, used for determining whether this is
* the internal or external database
* @return The row ID for this artist/album, or -1 if the provided name was invalid
*/
private long getKeyIdForName(DatabaseHelper helper, SQLiteDatabase db,
String table, String keyField, String nameField,
String rawName, String cacheName, String path, int albumHash,
String artist, HashMap<String, Long> cache, Uri srcuri) {
long rowId;
if (rawName == null || rawName.length() == 0) {
rawName = MediaStore.UNKNOWN_STRING;
}
String k = MediaStore.Audio.keyFor(rawName);
if (k == null) {
// shouldn't happen, since we only get null keys for null inputs
Log.e(TAG, "null key", new Exception());
return -1;
}
boolean isAlbum = table.equals("albums");
boolean isUnknown = MediaStore.UNKNOWN_STRING.equals(rawName);
// To distinguish same-named albums, we append a hash. The hash is based
// on the "album artist" tag if present, otherwise on the "compilation" tag
// if present, otherwise on the path.
// Ideally we would also take things like CDDB ID in to account, so
// we can group files from the same album that aren't in the same
// folder, but this is a quick and easy start that works immediately
// without requiring support from the mp3, mp4 and Ogg meta data
// readers, as long as the albums are in different folders.
if (isAlbum) {
k = k + albumHash;
if (isUnknown) {
k = k + artist;
}
}
String [] selargs = { k };
helper.mNumQueries++;
Cursor c = db.query(table, null, keyField + "=?", selargs, null, null, null);
try {
switch (c.getCount()) {
case 0: {
// insert new entry into table
ContentValues otherValues = new ContentValues();
otherValues.put(keyField, k);
otherValues.put(nameField, rawName);
helper.mNumInserts++;
rowId = db.insert(table, "duration", otherValues);
if (path != null && isAlbum && ! isUnknown) {
// We just inserted a new album. Now create an album art thumbnail for it.
makeThumbAsync(helper, db, path, rowId);
}
if (rowId > 0) {
String volume = srcuri.toString().substring(16, 24); // extract internal/external
Uri uri = Uri.parse("content://media/" + volume + "/audio/" + table + "/" + rowId);
getContext().getContentResolver().notifyChange(uri, null);
}
}
break;
case 1: {
// Use the existing entry
c.moveToFirst();
rowId = c.getLong(0);
// Determine whether the current rawName is better than what's
// currently stored in the table, and update the table if it is.
String currentFancyName = c.getString(2);
String bestName = makeBestName(rawName, currentFancyName);
if (!bestName.equals(currentFancyName)) {
// update the table with the new name
ContentValues newValues = new ContentValues();
newValues.put(nameField, bestName);
helper.mNumUpdates++;
db.update(table, newValues, "rowid="+Integer.toString((int)rowId), null);
String volume = srcuri.toString().substring(16, 24); // extract internal/external
Uri uri = Uri.parse("content://media/" + volume + "/audio/" + table + "/" + rowId);
getContext().getContentResolver().notifyChange(uri, null);
}
}
break;
default:
// corrupt database
Log.e(TAG, "Multiple entries in table " + table + " for key " + k);
rowId = -1;
break;
}
} finally {
if (c != null) c.close();
}
if (cache != null && ! isUnknown) {
cache.put(cacheName, rowId);
}
return rowId;
}
/**
* Returns the best string to use for display, given two names.
* Note that this function does not necessarily return either one
* of the provided names; it may decide to return a better alternative
* (for example, specifying the inputs "Police" and "Police, The" will
* return "The Police")
*
* The basic assumptions are:
* - longer is better ("The police" is better than "Police")
* - prefix is better ("The Police" is better than "Police, The")
* - accents are better ("Mot&ouml;rhead" is better than "Motorhead")
*
* @param one The first of the two names to consider
* @param two The last of the two names to consider
* @return The actual name to use
*/
String makeBestName(String one, String two) {
String name;
// Longer names are usually better.
if (one.length() > two.length()) {
name = one;
} else {
// Names with accents are usually better, and conveniently sort later
if (one.toLowerCase().compareTo(two.toLowerCase()) > 0) {
name = one;
} else {
name = two;
}
}
// Prefixes are better than postfixes.
if (name.endsWith(", the") || name.endsWith(",the") ||
name.endsWith(", an") || name.endsWith(",an") ||
name.endsWith(", a") || name.endsWith(",a")) {
String fix = name.substring(1 + name.lastIndexOf(','));
name = fix.trim() + " " + name.substring(0, name.lastIndexOf(','));
}
// TODO: word-capitalize the resulting name
return name;
}
/**
* Looks up the database based on the given URI.
*
* @param uri The requested URI
* @returns the database for the given URI
*/
private DatabaseHelper getDatabaseForUri(Uri uri) {
synchronized (mDatabases) {
if (uri.getPathSegments().size() >= 1) {
return mDatabases.get(uri.getPathSegments().get(0));
}
}
return null;
}
static boolean isMediaDatabaseName(String name) {
if (INTERNAL_DATABASE_NAME.equals(name)) {
return true;
}
if (EXTERNAL_DATABASE_NAME.equals(name)) {
return true;
}
if (name.startsWith("external-")) {
return true;
}
return false;
}
static boolean isInternalMediaDatabaseName(String name) {
if (INTERNAL_DATABASE_NAME.equals(name)) {
return true;
}
return false;
}
/**
* Attach the database for a volume (internal or external).
* Does nothing if the volume is already attached, otherwise
* checks the volume ID and sets up the corresponding database.
*
* @param volume to attach, either {@link #INTERNAL_VOLUME} or {@link #EXTERNAL_VOLUME}.
* @return the content URI of the attached volume.
*/
private Uri attachVolume(String volume) {
if (Binder.getCallingPid() != Process.myPid()) {
throw new SecurityException(
"Opening and closing databases not allowed.");
}
synchronized (mDatabases) {
if (mDatabases.get(volume) != null) { // Already attached
return Uri.parse("content://media/" + volume);
}
Context context = getContext();
DatabaseHelper helper;
if (INTERNAL_VOLUME.equals(volume)) {
helper = new DatabaseHelper(context, INTERNAL_DATABASE_NAME, true,
false, mObjectRemovedCallback);
} else if (EXTERNAL_VOLUME.equals(volume)) {
if (Environment.isExternalStorageRemovable()) {
String path = mExternalStoragePaths[0];
int volumeID = FileUtils.getFatVolumeId(path);
if (LOCAL_LOGV) Log.v(TAG, path + " volume ID: " + volumeID);
// Must check for failure!
// If the volume is not (yet) mounted, this will create a new
// external-ffffffff.db database instead of the one we expect. Then, if
// android.process.media is later killed and respawned, the real external
// database will be attached, containing stale records, or worse, be empty.
if (volumeID == -1) {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state) ||
Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
// This may happen if external storage was _just_ mounted. It may also
// happen if the volume ID is _actually_ 0xffffffff, in which case it
// must be changed since FileUtils::getFatVolumeId doesn't allow for
// that. It may also indicate that FileUtils::getFatVolumeId is broken
// (missing ioctl), which is also impossible to disambiguate.
Log.e(TAG, "Can't obtain external volume ID even though it's mounted.");
} else {
Log.i(TAG, "External volume is not (yet) mounted, cannot attach.");
}
throw new IllegalArgumentException("Can't obtain external volume ID for " +
volume + " volume.");
}
// generate database name based on volume ID
String dbName = "external-" + Integer.toHexString(volumeID) + ".db";
helper = new DatabaseHelper(context, dbName, false,
false, mObjectRemovedCallback);
mVolumeId = volumeID;
} else {
// external database name should be EXTERNAL_DATABASE_NAME
// however earlier releases used the external-XXXXXXXX.db naming
// for devices without removable storage, and in that case we need to convert
// to this new convention
File dbFile = context.getDatabasePath(EXTERNAL_DATABASE_NAME);
if (!dbFile.exists()) {
// find the most recent external database and rename it to
// EXTERNAL_DATABASE_NAME, and delete any other older
// external database files
File recentDbFile = null;
for (String database : context.databaseList()) {
if (database.startsWith("external-")) {
File file = context.getDatabasePath(database);
if (recentDbFile == null) {
recentDbFile = file;
} else if (file.lastModified() > recentDbFile.lastModified()) {
recentDbFile.delete();
recentDbFile = file;
} else {
file.delete();
}
}
}
if (recentDbFile != null) {
if (recentDbFile.renameTo(dbFile)) {
Log.d(TAG, "renamed database " + recentDbFile.getName() +
" to " + EXTERNAL_DATABASE_NAME);
} else {
Log.e(TAG, "Failed to rename database " + recentDbFile.getName() +
" to " + EXTERNAL_DATABASE_NAME);
// This shouldn't happen, but if it does, continue using
// the file under its old name
dbFile = recentDbFile;
}
}
// else DatabaseHelper will create one named EXTERNAL_DATABASE_NAME
}
helper = new DatabaseHelper(context, dbFile.getName(), false,
false, mObjectRemovedCallback);
}
} else {
throw new IllegalArgumentException("There is no volume named " + volume);
}
mDatabases.put(volume, helper);
if (!helper.mInternal) {
// create default directories (only happens on first boot)
createDefaultFolders(helper, helper.getWritableDatabase());
// clean up stray album art files: delete every file not in the database
File[] files = new File(mExternalStoragePaths[0], ALBUM_THUMB_FOLDER).listFiles();
HashSet<String> fileSet = new HashSet();
for (int i = 0; files != null && i < files.length; i++) {
fileSet.add(files[i].getPath());
}
Cursor cursor = query(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
new String[] { MediaStore.Audio.Albums.ALBUM_ART }, null, null, null);
try {
while (cursor != null && cursor.moveToNext()) {
fileSet.remove(cursor.getString(0));
}
} finally {
if (cursor != null) cursor.close();
}
Iterator<String> iterator = fileSet.iterator();
while (iterator.hasNext()) {
String filename = iterator.next();
if (LOCAL_LOGV) Log.v(TAG, "deleting obsolete album art " + filename);
new File(filename).delete();
}
}
}
if (LOCAL_LOGV) Log.v(TAG, "Attached volume: " + volume);
return Uri.parse("content://media/" + volume);
}
/**
* Detach the database for a volume (must be external).
* Does nothing if the volume is already detached, otherwise
* closes the database and sends a notification to listeners.
*
* @param uri The content URI of the volume, as returned by {@link #attachVolume}
*/
private void detachVolume(Uri uri) {
if (Binder.getCallingPid() != Process.myPid()) {
throw new SecurityException(
"Opening and closing databases not allowed.");
}
String volume = uri.getPathSegments().get(0);
if (INTERNAL_VOLUME.equals(volume)) {
throw new UnsupportedOperationException(
"Deleting the internal volume is not allowed");
} else if (!EXTERNAL_VOLUME.equals(volume)) {
throw new IllegalArgumentException(
"There is no volume named " + volume);
}
synchronized (mDatabases) {
DatabaseHelper database = mDatabases.get(volume);
if (database == null) return;
try {
// touch the database file to show it is most recently used
File file = new File(database.getReadableDatabase().getPath());
file.setLastModified(System.currentTimeMillis());
} catch (Exception e) {
Log.e(TAG, "Can't touch database file", e);
}
mDatabases.remove(volume);
database.close();
}
getContext().getContentResolver().notifyChange(uri, null);
if (LOCAL_LOGV) Log.v(TAG, "Detached volume: " + volume);
}
private static String TAG = "MediaProvider";
private static final boolean LOCAL_LOGV = false;
private static final String INTERNAL_DATABASE_NAME = "internal.db";
private static final String EXTERNAL_DATABASE_NAME = "external.db";
// maximum number of cached external databases to keep
private static final int MAX_EXTERNAL_DATABASES = 3;
// Delete databases that have not been used in two months
// 60 days in milliseconds (1000 * 60 * 60 * 24 * 60)
private static final long OBSOLETE_DATABASE_DB = 5184000000L;
private HashMap<String, DatabaseHelper> mDatabases;
private Handler mThumbHandler;
// name of the volume currently being scanned by the media scanner (or null)
private String mMediaScannerVolume;
// current FAT volume ID
private int mVolumeId = -1;
static final String INTERNAL_VOLUME = "internal";
static final String EXTERNAL_VOLUME = "external";
static final String ALBUM_THUMB_FOLDER = "Android/data/com.android.providers.media/albumthumbs";
// path for writing contents of in memory temp database
private String mTempDatabasePath;
// WARNING: the values of IMAGES_MEDIA, AUDIO_MEDIA, and VIDEO_MEDIA and AUDIO_PLAYLISTS
// are stored in the "files" table, so do not renumber them unless you also add
// a corresponding database upgrade step for it.
private static final int IMAGES_MEDIA = 1;
private static final int IMAGES_MEDIA_ID = 2;
private static final int IMAGES_THUMBNAILS = 3;
private static final int IMAGES_THUMBNAILS_ID = 4;
private static final int AUDIO_MEDIA = 100;
private static final int AUDIO_MEDIA_ID = 101;
private static final int AUDIO_MEDIA_ID_GENRES = 102;
private static final int AUDIO_MEDIA_ID_GENRES_ID = 103;
private static final int AUDIO_MEDIA_ID_PLAYLISTS = 104;
private static final int AUDIO_MEDIA_ID_PLAYLISTS_ID = 105;
private static final int AUDIO_GENRES = 106;
private static final int AUDIO_GENRES_ID = 107;
private static final int AUDIO_GENRES_ID_MEMBERS = 108;
private static final int AUDIO_GENRES_ALL_MEMBERS = 109;
private static final int AUDIO_PLAYLISTS = 110;
private static final int AUDIO_PLAYLISTS_ID = 111;
private static final int AUDIO_PLAYLISTS_ID_MEMBERS = 112;
private static final int AUDIO_PLAYLISTS_ID_MEMBERS_ID = 113;
private static final int AUDIO_ARTISTS = 114;
private static final int AUDIO_ARTISTS_ID = 115;
private static final int AUDIO_ALBUMS = 116;
private static final int AUDIO_ALBUMS_ID = 117;
private static final int AUDIO_ARTISTS_ID_ALBUMS = 118;
private static final int AUDIO_ALBUMART = 119;
private static final int AUDIO_ALBUMART_ID = 120;
private static final int AUDIO_ALBUMART_FILE_ID = 121;
private static final int VIDEO_MEDIA = 200;
private static final int VIDEO_MEDIA_ID = 201;
private static final int VIDEO_THUMBNAILS = 202;
private static final int VIDEO_THUMBNAILS_ID = 203;
private static final int VOLUMES = 300;
private static final int VOLUMES_ID = 301;
private static final int AUDIO_SEARCH_LEGACY = 400;
private static final int AUDIO_SEARCH_BASIC = 401;
private static final int AUDIO_SEARCH_FANCY = 402;
private static final int MEDIA_SCANNER = 500;
private static final int FS_ID = 600;
private static final int VERSION = 601;
private static final int FILES = 700;
private static final int FILES_ID = 701;
// Used only by the MTP implementation
private static final int MTP_OBJECTS = 702;
private static final int MTP_OBJECTS_ID = 703;
private static final int MTP_OBJECT_REFERENCES = 704;
// UsbReceiver calls insert() and delete() with this URI to tell us
// when MTP is connected and disconnected
private static final int MTP_CONNECTED = 705;
private static final UriMatcher URI_MATCHER =
new UriMatcher(UriMatcher.NO_MATCH);
private static final String[] ID_PROJECTION = new String[] {
MediaStore.MediaColumns._ID
};
private static final String[] PATH_PROJECTION = new String[] {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA,
};
private static final String[] MIME_TYPE_PROJECTION = new String[] {
MediaStore.MediaColumns._ID, // 0
MediaStore.MediaColumns.MIME_TYPE, // 1
};
private static final String[] READY_FLAG_PROJECTION = new String[] {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA,
Images.Media.MINI_THUMB_MAGIC
};
private static final String OBJECT_REFERENCES_QUERY =
"SELECT " + Audio.Playlists.Members.AUDIO_ID + " FROM audio_playlists_map"
+ " WHERE " + Audio.Playlists.Members.PLAYLIST_ID + "=?"
+ " ORDER BY " + Audio.Playlists.Members.PLAY_ORDER;
static
{
URI_MATCHER.addURI("media", "*/images/media", IMAGES_MEDIA);
URI_MATCHER.addURI("media", "*/images/media/#", IMAGES_MEDIA_ID);
URI_MATCHER.addURI("media", "*/images/thumbnails", IMAGES_THUMBNAILS);
URI_MATCHER.addURI("media", "*/images/thumbnails/#", IMAGES_THUMBNAILS_ID);
URI_MATCHER.addURI("media", "*/audio/media", AUDIO_MEDIA);
URI_MATCHER.addURI("media", "*/audio/media/#", AUDIO_MEDIA_ID);
URI_MATCHER.addURI("media", "*/audio/media/#/genres", AUDIO_MEDIA_ID_GENRES);
URI_MATCHER.addURI("media", "*/audio/media/#/genres/#", AUDIO_MEDIA_ID_GENRES_ID);
URI_MATCHER.addURI("media", "*/audio/media/#/playlists", AUDIO_MEDIA_ID_PLAYLISTS);
URI_MATCHER.addURI("media", "*/audio/media/#/playlists/#", AUDIO_MEDIA_ID_PLAYLISTS_ID);
URI_MATCHER.addURI("media", "*/audio/genres", AUDIO_GENRES);
URI_MATCHER.addURI("media", "*/audio/genres/#", AUDIO_GENRES_ID);
URI_MATCHER.addURI("media", "*/audio/genres/#/members", AUDIO_GENRES_ID_MEMBERS);
URI_MATCHER.addURI("media", "*/audio/genres/all/members", AUDIO_GENRES_ALL_MEMBERS);
URI_MATCHER.addURI("media", "*/audio/playlists", AUDIO_PLAYLISTS);
URI_MATCHER.addURI("media", "*/audio/playlists/#", AUDIO_PLAYLISTS_ID);
URI_MATCHER.addURI("media", "*/audio/playlists/#/members", AUDIO_PLAYLISTS_ID_MEMBERS);
URI_MATCHER.addURI("media", "*/audio/playlists/#/members/#", AUDIO_PLAYLISTS_ID_MEMBERS_ID);
URI_MATCHER.addURI("media", "*/audio/artists", AUDIO_ARTISTS);
URI_MATCHER.addURI("media", "*/audio/artists/#", AUDIO_ARTISTS_ID);
URI_MATCHER.addURI("media", "*/audio/artists/#/albums", AUDIO_ARTISTS_ID_ALBUMS);
URI_MATCHER.addURI("media", "*/audio/albums", AUDIO_ALBUMS);
URI_MATCHER.addURI("media", "*/audio/albums/#", AUDIO_ALBUMS_ID);
URI_MATCHER.addURI("media", "*/audio/albumart", AUDIO_ALBUMART);
URI_MATCHER.addURI("media", "*/audio/albumart/#", AUDIO_ALBUMART_ID);
URI_MATCHER.addURI("media", "*/audio/media/#/albumart", AUDIO_ALBUMART_FILE_ID);
URI_MATCHER.addURI("media", "*/video/media", VIDEO_MEDIA);
URI_MATCHER.addURI("media", "*/video/media/#", VIDEO_MEDIA_ID);
URI_MATCHER.addURI("media", "*/video/thumbnails", VIDEO_THUMBNAILS);
URI_MATCHER.addURI("media", "*/video/thumbnails/#", VIDEO_THUMBNAILS_ID);
URI_MATCHER.addURI("media", "*/media_scanner", MEDIA_SCANNER);
URI_MATCHER.addURI("media", "*/fs_id", FS_ID);
URI_MATCHER.addURI("media", "*/version", VERSION);
URI_MATCHER.addURI("media", "*/mtp_connected", MTP_CONNECTED);
URI_MATCHER.addURI("media", "*", VOLUMES_ID);
URI_MATCHER.addURI("media", null, VOLUMES);
// Used by MTP implementation
URI_MATCHER.addURI("media", "*/file", FILES);
URI_MATCHER.addURI("media", "*/file/#", FILES_ID);
URI_MATCHER.addURI("media", "*/object", MTP_OBJECTS);
URI_MATCHER.addURI("media", "*/object/#", MTP_OBJECTS_ID);
URI_MATCHER.addURI("media", "*/object/#/references", MTP_OBJECT_REFERENCES);
/**
* @deprecated use the 'basic' or 'fancy' search Uris instead
*/
URI_MATCHER.addURI("media", "*/audio/" + SearchManager.SUGGEST_URI_PATH_QUERY,
AUDIO_SEARCH_LEGACY);
URI_MATCHER.addURI("media", "*/audio/" + SearchManager.SUGGEST_URI_PATH_QUERY + "/*",
AUDIO_SEARCH_LEGACY);
// used for search suggestions
URI_MATCHER.addURI("media", "*/audio/search/" + SearchManager.SUGGEST_URI_PATH_QUERY,
AUDIO_SEARCH_BASIC);
URI_MATCHER.addURI("media", "*/audio/search/" + SearchManager.SUGGEST_URI_PATH_QUERY +
"/*", AUDIO_SEARCH_BASIC);
// used by the music app's search activity
URI_MATCHER.addURI("media", "*/audio/search/fancy", AUDIO_SEARCH_FANCY);
URI_MATCHER.addURI("media", "*/audio/search/fancy/*", AUDIO_SEARCH_FANCY);
}
@Override
public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
Collection<DatabaseHelper> foo = mDatabases.values();
for (DatabaseHelper dbh: foo) {
writer.println(dump(dbh, true));
}
writer.flush();
}
private String dump(DatabaseHelper dbh, boolean dumpDbLog) {
StringBuilder s = new StringBuilder();
s.append(dbh.mName);
s.append(": ");
SQLiteDatabase db = dbh.getReadableDatabase();
if (db == null) {
s.append("null");
} else {
s.append("version " + db.getVersion() + ", ");
Cursor c = db.query("files", new String[] {"count(*)"}, null, null, null, null, null);
try {
if (c != null && c.moveToFirst()) {
int num = c.getInt(0);
s.append(num + " rows, ");
} else {
s.append("couldn't get row count, ");
}
} finally {
if (c != null) {
c.close();
}
}
s.append(dbh.mNumInserts + " inserts, ");
s.append(dbh.mNumUpdates + " updates, ");
s.append(dbh.mNumDeletes + " deletes, ");
s.append(dbh.mNumQueries + " queries, ");
if (dbh.mScanStartTime != 0) {
s.append("scan started " + DateUtils.formatDateTime(getContext(),
dbh.mScanStartTime / 1000,
DateUtils.FORMAT_SHOW_DATE
| DateUtils.FORMAT_SHOW_TIME
| DateUtils.FORMAT_ABBREV_ALL));
long now = dbh.mScanStopTime;
if (now < dbh.mScanStartTime) {
now = SystemClock.currentTimeMicro();
}
s.append(" (" + DateUtils.formatElapsedTime(
(now - dbh.mScanStartTime) / 1000000) + ")");
if (dbh.mScanStopTime < dbh.mScanStartTime) {
if (mMediaScannerVolume != null &&
dbh.mName.startsWith(mMediaScannerVolume)) {
s.append(" (ongoing)");
} else {
s.append(" (scanning " + mMediaScannerVolume + ")");
}
}
}
if (dumpDbLog) {
c = db.query("log", new String[] {"time", "message"},
null, null, null, null, "time");
try {
if (c != null) {
while (c.moveToNext()) {
String when = c.getString(0);
String msg = c.getString(1);
s.append("\n" + when + " : " + msg);
}
}
} finally {
if (c != null) {
c.close();
}
}
}
}
return s.toString();
}
}