/* * Copyright (C) 2007 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 android.media; import android.content.ContentValues; import android.content.Context; import android.content.IContentProvider; import android.content.ContentUris; import android.database.Cursor; import android.database.SQLException; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Process; import android.os.RemoteException; import android.os.SystemProperties; import android.provider.MediaStore; import android.provider.Settings; import android.provider.MediaStore.Audio; import android.provider.MediaStore.Images; import android.provider.MediaStore.Video; import android.provider.MediaStore.Audio.Genres; import android.provider.MediaStore.Audio.Playlists; import android.sax.Element; import android.sax.ElementListener; import android.sax.RootElement; import android.text.TextUtils; import android.util.Config; import android.util.Log; import android.util.Xml; import org.xml.sax.Attributes; import org.xml.sax.ContentHandler; import org.xml.sax.SAXException; import java.io.*; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; /** * Internal service that no-one should use directly. * * {@hide} */ public class MediaScanner { static { System.loadLibrary("media_jni"); } private final static String TAG = "MediaScanner"; private static final String[] AUDIO_PROJECTION = new String[] { Audio.Media._ID, // 0 Audio.Media.DATA, // 1 Audio.Media.DATE_MODIFIED, // 2 }; private static final int ID_AUDIO_COLUMN_INDEX = 0; private static final int PATH_AUDIO_COLUMN_INDEX = 1; private static final int DATE_MODIFIED_AUDIO_COLUMN_INDEX = 2; private static final String[] VIDEO_PROJECTION = new String[] { Video.Media._ID, // 0 Video.Media.DATA, // 1 Video.Media.DATE_MODIFIED, // 2 }; private static final int ID_VIDEO_COLUMN_INDEX = 0; private static final int PATH_VIDEO_COLUMN_INDEX = 1; private static final int DATE_MODIFIED_VIDEO_COLUMN_INDEX = 2; private static final String[] IMAGES_PROJECTION = new String[] { Images.Media._ID, // 0 Images.Media.DATA, // 1 Images.Media.DATE_MODIFIED, // 2 }; private static final int ID_IMAGES_COLUMN_INDEX = 0; private static final int PATH_IMAGES_COLUMN_INDEX = 1; private static final int DATE_MODIFIED_IMAGES_COLUMN_INDEX = 2; private static final String[] PLAYLISTS_PROJECTION = new String[] { Audio.Playlists._ID, // 0 Audio.Playlists.DATA, // 1 Audio.Playlists.DATE_MODIFIED, // 2 }; private static final String[] PLAYLIST_MEMBERS_PROJECTION = new String[] { Audio.Playlists.Members.PLAYLIST_ID, // 0 }; private static final int ID_PLAYLISTS_COLUMN_INDEX = 0; private static final int PATH_PLAYLISTS_COLUMN_INDEX = 1; private static final int DATE_MODIFIED_PLAYLISTS_COLUMN_INDEX = 2; private static final String[] GENRE_LOOKUP_PROJECTION = new String[] { Audio.Genres._ID, // 0 Audio.Genres.NAME, // 1 }; private static final String RINGTONES_DIR = "/ringtones/"; private static final String NOTIFICATIONS_DIR = "/notifications/"; private static final String ALARMS_DIR = "/alarms/"; private static final String MUSIC_DIR = "/music/"; private static final String PODCAST_DIR = "/podcasts/"; private static final String[] ID3_GENRES = { // ID3v1 Genres "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise", "AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock", // The following genres are Winamp extensions "Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin", "Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock", "Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour", "Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony", "Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam", "Club", "Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul", "Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House", "Dance Hall" }; private int mNativeContext; private Context mContext; private IContentProvider mMediaProvider; private Uri mAudioUri; private Uri mVideoUri; private Uri mImagesUri; private Uri mThumbsUri; private Uri mGenresUri; private Uri mPlaylistsUri; private boolean mProcessPlaylists, mProcessGenres; // used when scanning the image database so we know whether we have to prune // old thumbnail files private int mOriginalCount; /** Whether the scanner has set a default sound for the ringer ringtone. */ private boolean mDefaultRingtoneSet; /** Whether the scanner has set a default sound for the notification ringtone. */ private boolean mDefaultNotificationSet; /** The filename for the default sound for the ringer ringtone. */ private String mDefaultRingtoneFilename; /** The filename for the default sound for the notification ringtone. */ private String mDefaultNotificationFilename; /** * The prefix for system properties that define the default sound for * ringtones. Concatenate the name of the setting from Settings * to get the full system property. */ private static final String DEFAULT_RINGTONE_PROPERTY_PREFIX = "ro.config."; // set to true if file path comparisons should be case insensitive. // this should be set when scanning files on a case insensitive file system. private boolean mCaseInsensitivePaths; private BitmapFactory.Options mBitmapOptions = new BitmapFactory.Options(); private static class FileCacheEntry { Uri mTableUri; long mRowId; String mPath; long mLastModified; boolean mSeenInFileSystem; boolean mLastModifiedChanged; FileCacheEntry(Uri tableUri, long rowId, String path, long lastModified) { mTableUri = tableUri; mRowId = rowId; mPath = path; mLastModified = lastModified; mSeenInFileSystem = false; mLastModifiedChanged = false; } @Override public String toString() { return mPath; } } // hashes file path to FileCacheEntry. // path should be lower case if mCaseInsensitivePaths is true private HashMap<String, FileCacheEntry> mFileCache; private ArrayList<FileCacheEntry> mPlayLists; private HashMap<String, Uri> mGenreCache; public MediaScanner(Context c) { native_setup(); mContext = c; mBitmapOptions.inSampleSize = 1; mBitmapOptions.inJustDecodeBounds = true; setDefaultRingtoneFileNames(); } private void setDefaultRingtoneFileNames() { mDefaultRingtoneFilename = SystemProperties.get(DEFAULT_RINGTONE_PROPERTY_PREFIX + Settings.System.RINGTONE); mDefaultNotificationFilename = SystemProperties.get(DEFAULT_RINGTONE_PROPERTY_PREFIX + Settings.System.NOTIFICATION_SOUND); } private MyMediaScannerClient mClient = new MyMediaScannerClient(); private class MyMediaScannerClient implements MediaScannerClient { private String mArtist; private String mAlbumArtist; // use this if mArtist is missing private String mAlbum; private String mTitle; private String mComposer; private String mGenre; private String mMimeType; private int mFileType; private int mTrack; private int mYear; private int mDuration; private String mPath; private long mLastModified; private long mFileSize; public FileCacheEntry beginFile(String path, String mimeType, long lastModified, long fileSize) { // special case certain file names // I use regionMatches() instead of substring() below // to avoid memory allocation int lastSlash = path.lastIndexOf('/'); if (lastSlash >= 0 && lastSlash + 2 < path.length()) { // ignore those ._* files created by MacOS if (path.regionMatches(lastSlash + 1, "._", 0, 2)) { return null; } // ignore album art files created by Windows Media Player: // Folder.jpg, AlbumArtSmall.jpg, AlbumArt_{...}_Large.jpg and AlbumArt_{...}_Small.jpg if (path.regionMatches(true, path.length() - 4, ".jpg", 0, 4)) { if (path.regionMatches(true, lastSlash + 1, "AlbumArt_{", 0, 10) || path.regionMatches(true, lastSlash + 1, "AlbumArt.", 0, 9)) { return null; } int length = path.length() - lastSlash - 1; if ((length == 17 && path.regionMatches(true, lastSlash + 1, "AlbumArtSmall", 0, 13)) || (length == 10 && path.regionMatches(true, lastSlash + 1, "Folder", 0, 6))) { return null; } } } mMimeType = null; // try mimeType first, if it is specified if (mimeType != null) { mFileType = MediaFile.getFileTypeForMimeType(mimeType); if (mFileType != 0) { mMimeType = mimeType; } } mFileSize = fileSize; // if mimeType was not specified, compute file type based on file extension. if (mMimeType == null) { MediaFile.MediaFileType mediaFileType = MediaFile.getFileType(path); if (mediaFileType != null) { mFileType = mediaFileType.fileType; mMimeType = mediaFileType.mimeType; } } String key = path; if (mCaseInsensitivePaths) { key = path.toLowerCase(); } FileCacheEntry entry = mFileCache.get(key); if (entry == null) { entry = new FileCacheEntry(null, 0, path, 0); mFileCache.put(key, entry); } entry.mSeenInFileSystem = true; // add some slack to avoid a rounding error long delta = lastModified - entry.mLastModified; if (delta > 1 || delta < -1) { entry.mLastModified = lastModified; entry.mLastModifiedChanged = true; } if (mProcessPlaylists && MediaFile.isPlayListFileType(mFileType)) { mPlayLists.add(entry); // we don't process playlists in the main scan, so return null return null; } // clear all the metadata mArtist = null; mAlbumArtist = null; mAlbum = null; mTitle = null; mComposer = null; mGenre = null; mTrack = 0; mYear = 0; mDuration = 0; mPath = path; mLastModified = lastModified; return entry; } public void scanFile(String path, long lastModified, long fileSize) { doScanFile(path, null, lastModified, fileSize, false); } public void scanFile(String path, String mimeType, long lastModified, long fileSize) { doScanFile(path, mimeType, lastModified, fileSize, false); } public Uri doScanFile(String path, String mimeType, long lastModified, long fileSize, boolean scanAlways) { Uri result = null; // long t1 = System.currentTimeMillis(); try { FileCacheEntry entry = beginFile(path, mimeType, lastModified, fileSize); // rescan for metadata if file was modified since last scan if (entry != null && (entry.mLastModifiedChanged || scanAlways)) { boolean ringtones = (path.indexOf(RINGTONES_DIR) > 0); boolean notifications = (path.indexOf(NOTIFICATIONS_DIR) > 0); boolean alarms = (path.indexOf(ALARMS_DIR) > 0); boolean podcasts = (path.indexOf(PODCAST_DIR) > 0); boolean music = (path.indexOf(MUSIC_DIR) > 0) || (!ringtones && !notifications && !alarms && !podcasts); if (mFileType == MediaFile.FILE_TYPE_MP3 || mFileType == MediaFile.FILE_TYPE_MP4 || mFileType == MediaFile.FILE_TYPE_M4A || mFileType == MediaFile.FILE_TYPE_3GPP || mFileType == MediaFile.FILE_TYPE_3GPP2 || mFileType == MediaFile.FILE_TYPE_OGG || mFileType == MediaFile.FILE_TYPE_MID || mFileType == MediaFile.FILE_TYPE_WMA) { // we only extract metadata from MP3, M4A, OGG, MID and WMA files. // check MP4 files, to determine if they contain only audio. processFile(path, mimeType, this); } else if (MediaFile.isImageFileType(mFileType)) { // we used to compute the width and height but it's not worth it } result = endFile(entry, ringtones, notifications, alarms, music, podcasts); } } catch (RemoteException e) { Log.e(TAG, "RemoteException in MediaScanner.scanFile()", e); } // long t2 = System.currentTimeMillis(); // Log.v(TAG, "scanFile: " + path + " took " + (t2-t1)); return result; } private int parseSubstring(String s, int start, int defaultValue) { int length = s.length(); if (start == length) return defaultValue; char ch = s.charAt(start++); // return defaultValue if we have no integer at all if (ch < '0' || ch > '9') return defaultValue; int result = ch - '0'; while (start < length) { ch = s.charAt(start++); if (ch < '0' || ch > '9') return result; result = result * 10 + (ch - '0'); } return result; } public void handleStringTag(String name, String value) { if (name.equalsIgnoreCase("title") || name.startsWith("title;")) { mTitle = value.trim(); } else if (name.equalsIgnoreCase("artist") || name.startsWith("artist;")) { mArtist = value.trim(); } else if (name.equalsIgnoreCase("albumartist") || name.startsWith("albumartist;")) { mAlbumArtist = value.trim(); } else if (name.equalsIgnoreCase("album") || name.startsWith("album;")) { mAlbum = value.trim(); } else if (name.equalsIgnoreCase("composer") || name.startsWith("composer;")) { mComposer = value.trim(); } else if (name.equalsIgnoreCase("genre") || name.startsWith("genre;")) { // handle numeric genres, which PV sometimes encodes like "(20)" if (value.length() > 0) { int genreCode = -1; char ch = value.charAt(0); if (ch == '(') { genreCode = parseSubstring(value, 1, -1); } else if (ch >= '0' && ch <= '9') { genreCode = parseSubstring(value, 0, -1); } if (genreCode >= 0 && genreCode < ID3_GENRES.length) { value = ID3_GENRES[genreCode]; } } mGenre = value; } else if (name.equalsIgnoreCase("year") || name.startsWith("year;")) { mYear = parseSubstring(value, 0, 0); } else if (name.equalsIgnoreCase("tracknumber") || name.startsWith("tracknumber;")) { // track number might be of the form "2/12" // we just read the number before the slash int num = parseSubstring(value, 0, 0); mTrack = (mTrack / 1000) * 1000 + num; } else if (name.equalsIgnoreCase("discnumber") || name.equals("set") || name.startsWith("set;")) { // set number might be of the form "1/3" // we just read the number before the slash int num = parseSubstring(value, 0, 0); mTrack = (num * 1000) + (mTrack % 1000); } else if (name.equalsIgnoreCase("duration")) { mDuration = parseSubstring(value, 0, 0); } } public void setMimeType(String mimeType) { mMimeType = mimeType; mFileType = MediaFile.getFileTypeForMimeType(mimeType); } /** * Formats the data into a values array suitable for use with the Media * Content Provider. * * @return a map of values */ private ContentValues toValues() { ContentValues map = new ContentValues(); map.put(MediaStore.MediaColumns.DATA, mPath); map.put(MediaStore.MediaColumns.TITLE, mTitle); map.put(MediaStore.MediaColumns.DATE_MODIFIED, mLastModified); map.put(MediaStore.MediaColumns.SIZE, mFileSize); map.put(MediaStore.MediaColumns.MIME_TYPE, mMimeType); if (MediaFile.isVideoFileType(mFileType)) { map.put(Video.Media.ARTIST, (mArtist != null && mArtist.length() > 0 ? mArtist : MediaFile.UNKNOWN_STRING)); map.put(Video.Media.ALBUM, (mAlbum != null && mAlbum.length() > 0 ? mAlbum : MediaFile.UNKNOWN_STRING)); map.put(Video.Media.DURATION, mDuration); // FIXME - add RESOLUTION } else if (MediaFile.isImageFileType(mFileType)) { // FIXME - add DESCRIPTION // map.put(field, value); } else if (MediaFile.isAudioFileType(mFileType)) { map.put(Audio.Media.ARTIST, (mArtist != null && mArtist.length() > 0 ? mArtist : MediaFile.UNKNOWN_STRING)); map.put(Audio.Media.ALBUM, (mAlbum != null && mAlbum.length() > 0 ? mAlbum : MediaFile.UNKNOWN_STRING)); map.put(Audio.Media.COMPOSER, mComposer); if (mYear != 0) { map.put(Audio.Media.YEAR, mYear); } map.put(Audio.Media.TRACK, mTrack); map.put(Audio.Media.DURATION, mDuration); } return map; } private Uri endFile(FileCacheEntry entry, boolean ringtones, boolean notifications, boolean alarms, boolean music, boolean podcasts) throws RemoteException { // update database Uri tableUri; boolean isAudio = MediaFile.isAudioFileType(mFileType); boolean isVideo = MediaFile.isVideoFileType(mFileType); boolean isImage = MediaFile.isImageFileType(mFileType); if (isVideo) { tableUri = mVideoUri; } else if (isImage) { tableUri = mImagesUri; } else if (isAudio) { tableUri = mAudioUri; } else { // don't add file to database if not audio, video or image return null; } entry.mTableUri = tableUri; // use album artist if artist is missing if (mArtist == null || mArtist.length() == 0) { mArtist = mAlbumArtist; } ContentValues values = toValues(); String title = values.getAsString(MediaStore.MediaColumns.TITLE); if (TextUtils.isEmpty(title)) { title = values.getAsString(MediaStore.MediaColumns.DATA); // extract file name after last slash int lastSlash = title.lastIndexOf('/'); if (lastSlash >= 0) { lastSlash++; if (lastSlash < title.length()) { title = title.substring(lastSlash); } } // truncate the file extension (if any) int lastDot = title.lastIndexOf('.'); if (lastDot > 0) { title = title.substring(0, lastDot); } values.put(MediaStore.MediaColumns.TITLE, title); } if (isAudio) { values.put(Audio.Media.IS_RINGTONE, ringtones); values.put(Audio.Media.IS_NOTIFICATION, notifications); values.put(Audio.Media.IS_ALARM, alarms); values.put(Audio.Media.IS_MUSIC, music); values.put(Audio.Media.IS_PODCAST, podcasts); } else if (isImage) { // nothing right now } Uri result = null; long rowId = entry.mRowId; if (rowId == 0) { // new file, insert it result = mMediaProvider.insert(tableUri, values); if (result != null) { rowId = ContentUris.parseId(result); entry.mRowId = rowId; } } else { // updated file result = ContentUris.withAppendedId(tableUri, rowId); mMediaProvider.update(result, values, null, null); } if (mProcessGenres && mGenre != null) { String genre = mGenre; Uri uri = mGenreCache.get(genre); if (uri == null) { Cursor cursor = null; try { // see if the genre already exists cursor = mMediaProvider.query( mGenresUri, 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 values.clear(); values.put(MediaStore.Audio.Genres.NAME, genre); uri = mMediaProvider.insert(mGenresUri, values); } else { // genre already exists, so compute its Uri cursor.moveToNext(); uri = ContentUris.withAppendedId(mGenresUri, cursor.getLong(0)); } if (uri != null) { uri = Uri.withAppendedPath(uri, Genres.Members.CONTENT_DIRECTORY); mGenreCache.put(genre, uri); } } finally { // release the cursor if it exists if (cursor != null) { cursor.close(); } } } if (uri != null) { // add entry to audio_genre_map values.clear(); values.put(MediaStore.Audio.Genres.Members.AUDIO_ID, Long.valueOf(rowId)); mMediaProvider.insert(uri, values); } } if (notifications && !mDefaultNotificationSet) { if (TextUtils.isEmpty(mDefaultNotificationFilename) || doesPathHaveFilename(entry.mPath, mDefaultNotificationFilename)) { setSettingIfNotSet(Settings.System.NOTIFICATION_SOUND, tableUri, rowId); mDefaultNotificationSet = true; } } else if (ringtones && !mDefaultRingtoneSet) { if (TextUtils.isEmpty(mDefaultRingtoneFilename) || doesPathHaveFilename(entry.mPath, mDefaultRingtoneFilename)) { setSettingIfNotSet(Settings.System.RINGTONE, tableUri, rowId); mDefaultRingtoneSet = true; } } return result; } private boolean doesPathHaveFilename(String path, String filename) { int pathFilenameStart = path.lastIndexOf(File.separatorChar) + 1; int filenameLength = filename.length(); return path.regionMatches(pathFilenameStart, filename, 0, filenameLength) && pathFilenameStart + filenameLength == path.length(); } private void setSettingIfNotSet(String settingName, Uri uri, long rowId) { String existingSettingValue = Settings.System.getString(mContext.getContentResolver(), settingName); if (TextUtils.isEmpty(existingSettingValue)) { // Set the setting to the given URI Settings.System.putString(mContext.getContentResolver(), settingName, ContentUris.withAppendedId(uri, rowId).toString()); } } }; // end of anonymous MediaScannerClient instance private void prescan(String filePath) throws RemoteException { Cursor c = null; String where = null; String[] selectionArgs = null; if (mFileCache == null) { mFileCache = new HashMap<String, FileCacheEntry>(); } else { mFileCache.clear(); } if (mPlayLists == null) { mPlayLists = new ArrayList<FileCacheEntry>(); } else { mPlayLists.clear(); } // Build the list of files from the content provider try { // Read existing files from the audio table if (filePath != null) { where = MediaStore.Audio.Media.DATA + "=?"; selectionArgs = new String[] { filePath }; } c = mMediaProvider.query(mAudioUri, AUDIO_PROJECTION, where, selectionArgs, null); if (c != null) { try { while (c.moveToNext()) { long rowId = c.getLong(ID_AUDIO_COLUMN_INDEX); String path = c.getString(PATH_AUDIO_COLUMN_INDEX); long lastModified = c.getLong(DATE_MODIFIED_AUDIO_COLUMN_INDEX); String key = path; if (mCaseInsensitivePaths) { key = path.toLowerCase(); } mFileCache.put(key, new FileCacheEntry(mAudioUri, rowId, path, lastModified)); } } finally { c.close(); c = null; } } // Read existing files from the video table if (filePath != null) { where = MediaStore.Video.Media.DATA + "=?"; } else { where = null; } c = mMediaProvider.query(mVideoUri, VIDEO_PROJECTION, where, selectionArgs, null); if (c != null) { try { while (c.moveToNext()) { long rowId = c.getLong(ID_VIDEO_COLUMN_INDEX); String path = c.getString(PATH_VIDEO_COLUMN_INDEX); long lastModified = c.getLong(DATE_MODIFIED_VIDEO_COLUMN_INDEX); String key = path; if (mCaseInsensitivePaths) { key = path.toLowerCase(); } mFileCache.put(key, new FileCacheEntry(mVideoUri, rowId, path, lastModified)); } } finally { c.close(); c = null; } } // Read existing files from the images table if (filePath != null) { where = MediaStore.Images.Media.DATA + "=?"; } else { where = null; } mOriginalCount = 0; c = mMediaProvider.query(mImagesUri, IMAGES_PROJECTION, where, selectionArgs, null); if (c != null) { try { mOriginalCount = c.getCount(); while (c.moveToNext()) { long rowId = c.getLong(ID_IMAGES_COLUMN_INDEX); String path = c.getString(PATH_IMAGES_COLUMN_INDEX); long lastModified = c.getLong(DATE_MODIFIED_IMAGES_COLUMN_INDEX); String key = path; if (mCaseInsensitivePaths) { key = path.toLowerCase(); } mFileCache.put(key, new FileCacheEntry(mImagesUri, rowId, path, lastModified)); } } finally { c.close(); c = null; } } if (mProcessPlaylists) { // Read existing files from the playlists table if (filePath != null) { where = MediaStore.Audio.Playlists.DATA + "=?"; } else { where = null; } c = mMediaProvider.query(mPlaylistsUri, PLAYLISTS_PROJECTION, where, selectionArgs, null); if (c != null) { try { while (c.moveToNext()) { String path = c.getString(PATH_IMAGES_COLUMN_INDEX); if (path != null && path.length() > 0) { long rowId = c.getLong(ID_PLAYLISTS_COLUMN_INDEX); long lastModified = c.getLong(DATE_MODIFIED_PLAYLISTS_COLUMN_INDEX); String key = path; if (mCaseInsensitivePaths) { key = path.toLowerCase(); } mFileCache.put(key, new FileCacheEntry(mPlaylistsUri, rowId, path, lastModified)); } } } finally { c.close(); c = null; } } } } finally { if (c != null) { c.close(); } } } private boolean inScanDirectory(String path, String[] directories) { for (int i = 0; i < directories.length; i++) { if (path.startsWith(directories[i])) { return true; } } return false; } private void pruneDeadThumbnailFiles() { HashSet<String> existingFiles = new HashSet<String>(); String directory = "/sdcard/DCIM/.thumbnails"; String [] files = (new File(directory)).list(); if (files == null) files = new String[0]; for (int i = 0; i < files.length; i++) { String fullPathString = directory + "/" + files[i]; existingFiles.add(fullPathString); } try { Cursor c = mMediaProvider.query( mThumbsUri, new String [] { "_data" }, null, null, null); Log.v(TAG, "pruneDeadThumbnailFiles... " + c); if (c != null && c.moveToFirst()) { do { String fullPathString = c.getString(0); existingFiles.remove(fullPathString); } while (c.moveToNext()); } for (String fileToDelete : existingFiles) { if (Config.LOGV) Log.v(TAG, "fileToDelete is " + fileToDelete); try { (new File(fileToDelete)).delete(); } catch (SecurityException ex) { } } Log.v(TAG, "/pruneDeadThumbnailFiles... " + c); if (c != null) { c.close(); } } catch (RemoteException e) { // We will soon be killed... } } private void postscan(String[] directories) throws RemoteException { Iterator<FileCacheEntry> iterator = mFileCache.values().iterator(); while (iterator.hasNext()) { FileCacheEntry entry = iterator.next(); String path = entry.mPath; // remove database entries for files that no longer exist. boolean fileMissing = false; if (!entry.mSeenInFileSystem) { if (inScanDirectory(path, directories)) { // we didn't see this file in the scan directory. fileMissing = true; } else { // the file is outside of our scan directory, // so we need to check for file existence here. File testFile = new File(path); if (!testFile.exists()) { fileMissing = true; } } } if (fileMissing) { // do not delete missing playlists, since they may have been modified by the user. // the user can delete them in the media player instead. // instead, clear the path and lastModified fields in the row MediaFile.MediaFileType mediaFileType = MediaFile.getFileType(path); int fileType = (mediaFileType == null ? 0 : mediaFileType.fileType); if (MediaFile.isPlayListFileType(fileType)) { ContentValues values = new ContentValues(); values.put(MediaStore.Audio.Playlists.DATA, ""); values.put(MediaStore.Audio.Playlists.DATE_MODIFIED, 0); mMediaProvider.update(ContentUris.withAppendedId(mPlaylistsUri, entry.mRowId), values, null, null); } else { mMediaProvider.delete(ContentUris.withAppendedId(entry.mTableUri, entry.mRowId), null, null); iterator.remove(); } } } // handle playlists last, after we know what media files are on the storage. if (mProcessPlaylists) { processPlayLists(); } if (mOriginalCount == 0 && mImagesUri.equals(Images.Media.getContentUri("external"))) pruneDeadThumbnailFiles(); // allow GC to clean up mGenreCache = null; mPlayLists = null; mFileCache = null; mMediaProvider = null; } private void initialize(String volumeName) { mMediaProvider = mContext.getContentResolver().acquireProvider("media"); mAudioUri = Audio.Media.getContentUri(volumeName); mVideoUri = Video.Media.getContentUri(volumeName); mImagesUri = Images.Media.getContentUri(volumeName); mThumbsUri = Images.Thumbnails.getContentUri(volumeName); if (!volumeName.equals("internal")) { // we only support playlists on external media mProcessPlaylists = true; mProcessGenres = true; mGenreCache = new HashMap<String, Uri>(); mGenresUri = Genres.getContentUri(volumeName); mPlaylistsUri = Playlists.getContentUri(volumeName); // assuming external storage is FAT (case insensitive), except on the simulator. if ( Process.supportsProcesses()) { mCaseInsensitivePaths = true; } } } public void scanDirectories(String[] directories, String volumeName) { try { long start = System.currentTimeMillis(); initialize(volumeName); prescan(null); long prescan = System.currentTimeMillis(); for (int i = 0; i < directories.length; i++) { processDirectory(directories[i], MediaFile.sFileExtensions, mClient); } long scan = System.currentTimeMillis(); postscan(directories); long end = System.currentTimeMillis(); if (Config.LOGD) { Log.d(TAG, " prescan time: " + (prescan - start) + "ms\n"); Log.d(TAG, " scan time: " + (scan - prescan) + "ms\n"); Log.d(TAG, "postscan time: " + (end - scan) + "ms\n"); Log.d(TAG, " total time: " + (end - start) + "ms\n"); } } catch (SQLException e) { // this might happen if the SD card is removed while the media scanner is running Log.e(TAG, "SQLException in MediaScanner.scan()", e); } catch (UnsupportedOperationException e) { // this might happen if the SD card is removed while the media scanner is running Log.e(TAG, "UnsupportedOperationException in MediaScanner.scan()", e); } catch (RemoteException e) { Log.e(TAG, "RemoteException in MediaScanner.scan()", e); } } // this function is used to scan a single file public Uri scanSingleFile(String path, String volumeName, String mimeType) { try { initialize(volumeName); prescan(path); File file = new File(path); // always scan the file, so we can return the content://media Uri for existing files return mClient.doScanFile(path, mimeType, file.lastModified(), file.length(), true); } catch (RemoteException e) { Log.e(TAG, "RemoteException in MediaScanner.scanFile()", e); return null; } } // returns the number of matching file/directory names, starting from the right private int matchPaths(String path1, String path2) { int result = 0; int end1 = path1.length(); int end2 = path2.length(); while (end1 > 0 && end2 > 0) { int slash1 = path1.lastIndexOf('/', end1 - 1); int slash2 = path2.lastIndexOf('/', end2 - 1); int backSlash1 = path1.lastIndexOf('\\', end1 - 1); int backSlash2 = path2.lastIndexOf('\\', end2 - 1); int start1 = (slash1 > backSlash1 ? slash1 : backSlash1); int start2 = (slash2 > backSlash2 ? slash2 : backSlash2); if (start1 < 0) start1 = 0; else start1++; if (start2 < 0) start2 = 0; else start2++; int length = end1 - start1; if (end2 - start2 != length) break; if (path1.regionMatches(true, start1, path2, start2, length)) { result++; end1 = start1 - 1; end2 = start2 - 1; } else break; } return result; } private boolean addPlayListEntry(String entry, String playListDirectory, Uri uri, ContentValues values, int index) { // watch for trailing whitespace int entryLength = entry.length(); while (entryLength > 0 && Character.isWhitespace(entry.charAt(entryLength - 1))) entryLength--; // path should be longer than 3 characters. // avoid index out of bounds errors below by returning here. if (entryLength < 3) return false; if (entryLength < entry.length()) entry = entry.substring(0, entryLength); // does entry appear to be an absolute path? // look for Unix or DOS absolute paths char ch1 = entry.charAt(0); boolean fullPath = (ch1 == '/' || (Character.isLetter(ch1) && entry.charAt(1) == ':' && entry.charAt(2) == '\\')); // if we have a relative path, combine entry with playListDirectory if (!fullPath) entry = playListDirectory + entry; //FIXME - should we look for "../" within the path? // best matching MediaFile for the play list entry FileCacheEntry bestMatch = null; // number of rightmost file/directory names for bestMatch int bestMatchLength = 0; Iterator<FileCacheEntry> iterator = mFileCache.values().iterator(); while (iterator.hasNext()) { FileCacheEntry cacheEntry = iterator.next(); String path = cacheEntry.mPath; if (path.equalsIgnoreCase(entry)) { bestMatch = cacheEntry; break; // don't bother continuing search } int matchLength = matchPaths(path, entry); if (matchLength > bestMatchLength) { bestMatch = cacheEntry; bestMatchLength = matchLength; } } if (bestMatch == null) { return false; } try { // OK, now we need to add this to the database values.clear(); values.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, Integer.valueOf(index)); values.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, Long.valueOf(bestMatch.mRowId)); mMediaProvider.insert(uri, values); } catch (RemoteException e) { Log.e(TAG, "RemoteException in MediaScanner.addPlayListEntry()", e); return false; } return true; } private void processM3uPlayList(String path, String playListDirectory, Uri uri, ContentValues values) { BufferedReader reader = null; try { File f = new File(path); if (f.exists()) { reader = new BufferedReader( new InputStreamReader(new FileInputStream(f)), 8192); String line = reader.readLine(); int index = 0; while (line != null) { // ignore comment lines, which begin with '#' if (line.length() > 0 && line.charAt(0) != '#') { values.clear(); if (addPlayListEntry(line, playListDirectory, uri, values, index)) index++; } line = reader.readLine(); } } } catch (IOException e) { Log.e(TAG, "IOException in MediaScanner.processM3uPlayList()", e); } finally { try { if (reader != null) reader.close(); } catch (IOException e) { Log.e(TAG, "IOException in MediaScanner.processM3uPlayList()", e); } } } private void processPlsPlayList(String path, String playListDirectory, Uri uri, ContentValues values) { BufferedReader reader = null; try { File f = new File(path); if (f.exists()) { reader = new BufferedReader( new InputStreamReader(new FileInputStream(f)), 8192); String line = reader.readLine(); int index = 0; while (line != null) { // ignore comment lines, which begin with '#' if (line.startsWith("File")) { int equals = line.indexOf('='); if (equals > 0) { values.clear(); if (addPlayListEntry(line.substring(equals + 1), playListDirectory, uri, values, index)) index++; } } line = reader.readLine(); } } } catch (IOException e) { Log.e(TAG, "IOException in MediaScanner.processPlsPlayList()", e); } finally { try { if (reader != null) reader.close(); } catch (IOException e) { Log.e(TAG, "IOException in MediaScanner.processPlsPlayList()", e); } } } class WplHandler implements ElementListener { final ContentHandler handler; String playListDirectory; Uri uri; ContentValues values = new ContentValues(); int index = 0; public WplHandler(String playListDirectory, Uri uri) { this.playListDirectory = playListDirectory; this.uri = uri; RootElement root = new RootElement("smil"); Element body = root.getChild("body"); Element seq = body.getChild("seq"); Element media = seq.getChild("media"); media.setElementListener(this); this.handler = root.getContentHandler(); } public void start(Attributes attributes) { String path = attributes.getValue("", "src"); if (path != null) { values.clear(); if (addPlayListEntry(path, playListDirectory, uri, values, index)) { index++; } } } public void end() { } ContentHandler getContentHandler() { return handler; } } private void processWplPlayList(String path, String playListDirectory, Uri uri) { FileInputStream fis = null; try { File f = new File(path); if (f.exists()) { fis = new FileInputStream(f); Xml.parse(fis, Xml.findEncodingByName("UTF-8"), new WplHandler(playListDirectory, uri).getContentHandler()); } } catch (SAXException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { try { if (fis != null) fis.close(); } catch (IOException e) { Log.e(TAG, "IOException in MediaScanner.processWplPlayList()", e); } } } private void processPlayLists() throws RemoteException { Iterator<FileCacheEntry> iterator = mPlayLists.iterator(); while (iterator.hasNext()) { FileCacheEntry entry = iterator.next(); String path = entry.mPath; // only process playlist files if they are new or have been modified since the last scan if (entry.mLastModifiedChanged) { ContentValues values = new ContentValues(); int lastSlash = path.lastIndexOf('/'); if (lastSlash < 0) throw new IllegalArgumentException("bad path " + path); Uri uri, membersUri; long rowId = entry.mRowId; if (rowId == 0) { // Create a new playlist int lastDot = path.lastIndexOf('.'); String name = (lastDot < 0 ? path.substring(lastSlash + 1) : path.substring(lastSlash + 1, lastDot)); values.put(MediaStore.Audio.Playlists.NAME, name); values.put(MediaStore.Audio.Playlists.DATA, path); values.put(MediaStore.Audio.Playlists.DATE_MODIFIED, entry.mLastModified); uri = mMediaProvider.insert(mPlaylistsUri, values); rowId = ContentUris.parseId(uri); membersUri = Uri.withAppendedPath(uri, Playlists.Members.CONTENT_DIRECTORY); } else { uri = ContentUris.withAppendedId(mPlaylistsUri, rowId); // update lastModified value of existing playlist values.put(MediaStore.Audio.Playlists.DATE_MODIFIED, entry.mLastModified); mMediaProvider.update(uri, values, null, null); // delete members of existing playlist membersUri = Uri.withAppendedPath(uri, Playlists.Members.CONTENT_DIRECTORY); mMediaProvider.delete(membersUri, null, null); } String playListDirectory = path.substring(0, lastSlash + 1); MediaFile.MediaFileType mediaFileType = MediaFile.getFileType(path); int fileType = (mediaFileType == null ? 0 : mediaFileType.fileType); if (fileType == MediaFile.FILE_TYPE_M3U) processM3uPlayList(path, playListDirectory, membersUri, values); else if (fileType == MediaFile.FILE_TYPE_PLS) processPlsPlayList(path, playListDirectory, membersUri, values); else if (fileType == MediaFile.FILE_TYPE_WPL) processWplPlayList(path, playListDirectory, membersUri); Cursor cursor = mMediaProvider.query(membersUri, PLAYLIST_MEMBERS_PROJECTION, null, null, null); try { if (cursor == null || cursor.getCount() == 0) { Log.d(TAG, "playlist is empty - deleting"); mMediaProvider.delete(uri, null, null); } } finally { if (cursor != null) cursor.close(); } } } } private native void processDirectory(String path, String extensions, MediaScannerClient client); private native void processFile(String path, String mimeType, MediaScannerClient client); public native void setLocale(String locale); public native byte[] extractAlbumArt(FileDescriptor fd); private native final void native_setup(); private native final void native_finalize(); @Override protected void finalize() { mContext.getContentResolver().releaseProvider(mMediaProvider); native_finalize(); } }