From dcd7df4f6683f3f09700dbecc256bb7d3e789a94 Mon Sep 17 00:00:00 2001 From: Adrian Ulrich Date: Mon, 26 Dec 2016 19:31:12 +0100 Subject: [PATCH] first version of automatic media scanner --- res/layout/sdscanner_fragment.xml | 14 - .../android/medialibrary/MediaLibrary.java | 33 +- .../android/medialibrary/MediaScanner.java | 388 +++++++++++++----- .../android/vanilla/SDScannerFragment.java | 42 +- 4 files changed, 358 insertions(+), 119 deletions(-) diff --git a/res/layout/sdscanner_fragment.xml b/res/layout/sdscanner_fragment.xml index e9ac4291..ffeedb32 100644 --- a/res/layout/sdscanner_fragment.xml +++ b/res/layout/sdscanner_fragment.xml @@ -38,24 +38,10 @@ Copied from SD Scanner's layout/main.xml with minor changes android:text="@string/button_start"> - - diff --git a/src/ch/blinkenlights/android/medialibrary/MediaLibrary.java b/src/ch/blinkenlights/android/medialibrary/MediaLibrary.java index 9ca5d388..754e2fb1 100644 --- a/src/ch/blinkenlights/android/medialibrary/MediaLibrary.java +++ b/src/ch/blinkenlights/android/medialibrary/MediaLibrary.java @@ -68,13 +68,21 @@ public class MediaLibrary { synchronized(sWait) { if (sBackend == null) { sBackend = new MediaLibraryBackend(context); - sScanner = new MediaScanner(sBackend); + sScanner = new MediaScanner(context, sBackend); + sScanner.startQuickScan(); } } } return sBackend; } + /** + * Triggers a rescan of the library + * + * @param context the context to use + * @param forceFull starts a full / slow scan if true + * @param drop drop the existing library if true + */ public static void scanLibrary(Context context, boolean forceFull, boolean drop) { MediaLibraryBackend backend = getBackend(context); // also initialized sScanner if (drop) { @@ -84,15 +92,28 @@ public class MediaLibrary { } if (forceFull) { - for (File dir : discoverMediaPaths()) { - sScanner.startFullScan(dir); - } - sScanner.startUpdateScan(); // also gets rid of deleted files + sScanner.startFullScan(); } else { - // fixme: implement smart scanner with startNativeLibraryScan(); + sScanner.startNormalScan(); } } + /** + * Whacky function to get the current scan progress + * + * @param context the context to use + * @return a description of the progress, null if no scan is running + */ + public static String describeScanProgress(Context context) { + MediaLibraryBackend backend = getBackend(context); // also initialized sScanner + MediaScanner.MediaScanPlan.Statistics stats = sScanner.getScanStatistics(); + String msg = null; + if (stats.lastFile != null) + msg = "seen files = "+stats.seen+", changes made = "+stats.changed+", currently scanning = "+stats.lastFile; + return msg; + } + + /** * Registers a new content observer for the media library * diff --git a/src/ch/blinkenlights/android/medialibrary/MediaScanner.java b/src/ch/blinkenlights/android/medialibrary/MediaScanner.java index 945cb08e..e3a6e632 100644 --- a/src/ch/blinkenlights/android/medialibrary/MediaScanner.java +++ b/src/ch/blinkenlights/android/medialibrary/MediaScanner.java @@ -19,14 +19,15 @@ package ch.blinkenlights.android.medialibrary; import android.content.Context; import android.content.ContentValues; +import android.content.SharedPreferences; import android.database.Cursor; +import android.database.ContentObserver; import android.util.Log; import android.provider.MediaStore; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; import android.os.Process; -import android.os.SystemClock; import java.io.File; import java.util.ArrayList; @@ -34,108 +35,123 @@ import java.util.regex.Pattern; public class MediaScanner implements Handler.Callback { /** - * How long to wait until we post an update notification + * Our scan plan */ - private final static int SCAN_NOTIFY_DELAY_MS = 1200; - /** - * At which (up-)time we shall trigger the next notification - */ - private long mNextNotification = 0; - /** - * The backend instance we are acting on - */ - private MediaLibraryBackend mBackend; + private MediaScanPlan mScanPlan; /** * Our message handler */ private Handler mHandler; /** - * Files we are ignoring based on their filename + * The context to use for native library queries */ - private static final Pattern sIgnoredNames = Pattern.compile("^([^\\.]+|.+\\.(jpe?g|gif|png|bmp|webm|txt|pdf|avi|mp4|mkv|zip|tgz|xml))$", Pattern.CASE_INSENSITIVE); + private Context mContext; /** - * Constructs a new MediaScanner instance - * - * @param backend the backend to use + * Instance of a media backend */ - MediaScanner(MediaLibraryBackend backend) { + private MediaLibraryBackend mBackend; + + + MediaScanner(Context context, MediaLibraryBackend backend) { + mContext = context; mBackend = backend; - HandlerThread handlerThread = new HandlerThread("MediaScannerThred", Process.THREAD_PRIORITY_LOWEST); + mScanPlan = new MediaScanPlan(); + HandlerThread handlerThread = new HandlerThread("MediaScannerThread", Process.THREAD_PRIORITY_LOWEST); handlerThread.start(); mHandler = new Handler(handlerThread.getLooper(), this); + + // the content observer to use + ContentObserver mObserver = new ContentObserver(null) { + @Override + public void onChange(boolean self) { + startQuickScan(); + } + }; + context.getContentResolver().registerContentObserver(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, false, mObserver); } /** - * Initiates a scan at given directory + * Performs a 'fast' scan by checking the native and our own + * library for new and changed files + */ + public void startNormalScan() { + mScanPlan.addNextStep(RPC_NATIVE_VRFY, null) + .addNextStep(RPC_LIBRARY_VRFY, null); + } + + /** + * Performs a 'slow' scan by inspecting all files on the device + */ + public void startFullScan() { + for (File dir : MediaLibrary.discoverMediaPaths()) { + mScanPlan.addNextStep(RPC_READ_DIR, dir); + } + mScanPlan.addNextStep(RPC_LIBRARY_VRFY, null); + mScanPlan.addNextStep(RPC_NATIVE_VRFY, null); + mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_NOOP, 0)); + } + + /** + * Called by the content observer if a change in the media library + * has been detected + */ + public void startQuickScan() { + if (!mHandler.hasMessages(MSG_SCAN_RPC)) { + mScanPlan.addNextStep(RPC_NATIVE_VRFY, null) + .addOptionalStep(RPC_LIBRARY_VRFY, null); // only runs if previous scan found no change + mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_NOOP, 0), 1400); + } + } + + /** + * Returns some scan statistics * - * @param dir the directory to scan + * @return a stats object */ - void startFullScan(File dir) { - mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_DIRECTORY, 0, 0, dir)); + MediaScanPlan.Statistics getScanStatistics() { + return mScanPlan.getStatistics(); } - /** - * Performs a full check of the current media library, scanning for - * removed or changed files - */ - void startUpdateScan() { - Cursor cursor = mBackend.query(false, MediaLibrary.TABLE_SONGS, new String[]{MediaLibrary.SongColumns.PATH}, null, null, null, null, null, null); - if (cursor != null) - mHandler.sendMessage(mHandler.obtainMessage(MSG_UPDATE_LIBRARY, 0, 0, cursor)); - } - - /** - * Queries all items found in androids native media database - * - * @param context the context to use - */ - void startNativeLibraryScan(Context context) { - String selection = MediaStore.Audio.Media.IS_MUSIC + "!= 0"; - String[] projection = { MediaStore.MediaColumns.DATA }; - Cursor cursor = context.getContentResolver().query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection, null, null); - if (cursor != null) - mHandler.sendMessage(mHandler.obtainMessage(MSG_UPDATE_LIBRARY, 0, 0, cursor)); - } - - private static final int MSG_SCAN_DIRECTORY = 1; - private static final int MSG_ADD_FILE = 2; - private static final int MSG_UPDATE_LIBRARY = 3; + private static final int MSG_SCAN_RPC = 0; + private static final int MSG_NOTIFY_CHANGE = 1; + private static final int RPC_NOOP = 100; + private static final int RPC_READ_DIR = 101; + private static final int RPC_INSPECT_FILE = 102; + private static final int RPC_LIBRARY_VRFY = 103; + private static final int RPC_NATIVE_VRFY = 104; @Override public boolean handleMessage(Message message) { - switch (message.what) { - case MSG_SCAN_DIRECTORY: { - File directory = (File)message.obj; - scanDirectory(directory); + int rpc = (message.what == MSG_SCAN_RPC ? message.arg1 : message.what); + + switch (rpc) { + case MSG_NOTIFY_CHANGE: { + MediaLibrary.notifyObserver(); break; } - case MSG_ADD_FILE: { - File file = (File)message.obj; - long now = SystemClock.uptimeMillis(); - boolean changed = addFile(file); - - // Notify the observer if this was the last message OR if the deadline was reached - if (!mHandler.hasMessages(MSG_ADD_FILE) || (mNextNotification != 0 && now >= mNextNotification)) { - MediaLibrary.notifyObserver(); - mNextNotification = 0; - } - - // Initiate a new notification trigger if the old one fired and we got a change - if (changed && mNextNotification == 0) - mNextNotification = now + SCAN_NOTIFY_DELAY_MS; - + case RPC_NOOP: { + // just used to trigger the initial scan break; } - case MSG_UPDATE_LIBRARY: { - Cursor cursor = (Cursor)message.obj; - while (cursor.moveToNext()) { - String path = cursor.getString(0); - if (path != null) { - File update = new File(path); - mHandler.sendMessage(mHandler.obtainMessage(MSG_ADD_FILE, 0, 0, update)); - } + case RPC_INSPECT_FILE: { + final File file = (File)message.obj; + boolean changed = rpcInspectFile(file); + mScanPlan.registerProgress(file.toString(), changed); + if (changed && !mHandler.hasMessages(MSG_NOTIFY_CHANGE)) { + mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_NOTIFY_CHANGE), 500); } - cursor.close(); + break; + } + case RPC_READ_DIR: { + rpcReadDirectory((File)message.obj); + break; + } + case RPC_LIBRARY_VRFY: { + rpcLibraryVerify((Cursor)message.obj); + break; + } + case RPC_NATIVE_VRFY: { + rpcNativeVerify((Cursor)message.obj, message.arg2); break; } default: { @@ -143,15 +159,82 @@ public class MediaScanner implements Handler.Callback { } } + if (message.what == MSG_SCAN_RPC && !mHandler.hasMessages(MSG_SCAN_RPC)) { + MediaScanPlan.Step step = mScanPlan.getNextStep(); + if (step == null) { + Log.v("VanillaMusic", "--- all scanners finished ---"); + } else { + Log.v("VanillaMusic", "--- starting scan of type "+step.msg); + mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, step.msg, 0, step.arg)); + } + } + return true; } /** - * Scans a directory for indexable files + * Scans the android library, inspecting every found file + * + * @param cursor the cursor we are using + * @param mtime the mtime to carry over, ignored if cursor is null + */ + private void rpcNativeVerify(Cursor cursor, int mtime) { + if (cursor == null) { + mtime = getSetScanMark(-1); // starting a new scan -> read stored mtime from preferences + String selection = MediaStore.Audio.Media.IS_MUSIC + "!= 0 AND "+ MediaStore.MediaColumns.DATE_MODIFIED +" > " + mtime; + String sort = MediaStore.MediaColumns.DATE_MODIFIED; + String[] projection = { MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DATE_MODIFIED }; + try { + cursor = mContext.getContentResolver().query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection, null, sort); + } catch(SecurityException e) { + Log.e("VanillaMusic", "rpcNativeVerify failed: "+e); + } + } + + if (cursor == null) + return; // still null.. fixme: handle me better + + if (cursor.moveToNext()) { + String path = cursor.getString(0); + mtime = cursor.getInt(1); + if (path != null) { // this seems to be a thing... + File entry = new File(path); + mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_INSPECT_FILE, 0, entry)); + mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_NATIVE_VRFY, mtime, cursor)); + } + } else { + cursor.close(); + getSetScanMark(mtime); + Log.v("VanillaMusic", "NativeLibraryScanner finished, mtime mark is now at "+mtime); + } + } + + + /** + * Scans every file in our own library and checks for changes + * + * @param cursor the cursor we are using + */ + private void rpcLibraryVerify(Cursor cursor) { + if (cursor == null) + cursor = mBackend.query(false, MediaLibrary.TABLE_SONGS, new String[]{MediaLibrary.SongColumns.PATH}, null, null, null, null, null, null); + + if (cursor.moveToNext()) { + File entry = new File(cursor.getString(0)); + mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_INSPECT_FILE, 0, entry)); + mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_LIBRARY_VRFY, 0, cursor)); + } else { + cursor.close(); + } + } + + /** + * Loops trough given directory and adds all found + * files to the scan queue * * @param dir the directory to scan */ - private void scanDirectory(File dir) { + private void rpcReadDirectory(File dir) { if (!dir.isDirectory()) return; @@ -163,31 +246,18 @@ public class MediaScanner implements Handler.Callback { return; for (File file : dirents) { - if (file.isFile()) { - mHandler.sendMessage(mHandler.obtainMessage(MSG_ADD_FILE, 0, 0, file)); - } else if (file.isDirectory()) { - mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_DIRECTORY, 0, 0, file)); - } + int rpc = (file.isFile() ? RPC_INSPECT_FILE : RPC_READ_DIR); + mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, rpc, 0, file)); } } /** - * Returns true if the file should not be scanned - * - * @param file the file to inspect - * @return boolean - */ - private boolean isBlacklisted(File file) { - return sIgnoredNames.matcher(file.getName()).matches(); - } - - /** - * Scans a single file and adds it to the database + * Inspects a single file and adds it to the database or removes it. maybe. * * @param file the file to add * @return true if we modified the database */ - private boolean addFile(File file) { + private boolean rpcInspectFile(File file) { String path = file.getAbsolutePath(); long songId = MediaLibrary.hash63(path); @@ -309,5 +379,133 @@ public class MediaScanner implements Handler.Callback { return (needsInsert || needsCleanup); } + private static final Pattern sIgnoredNames = Pattern.compile("^([^\\.]+|.+\\.(jpe?g|gif|png|bmp|webm|txt|pdf|avi|mp4|mkv|zip|tgz|xml))$", Pattern.CASE_INSENSITIVE); + /** + * Returns true if the file should not be scanned + * + * @param file the file to inspect + * @return boolean + */ + private boolean isBlacklisted(File file) { + return sIgnoredNames.matcher(file.getName()).matches(); + } + + + /** + * Clunky shortcut to preferences editor + * + * @param newVal the new value to store, ignored if < 0 + * @return the value previously set, or 0 as a default + */ + private int getSetScanMark(int newVal) { + final String prefKey = "native_last_mtime"; + SharedPreferences sharedPref = mContext.getSharedPreferences("scanner_preferences", Context.MODE_PRIVATE); + int oldVal = sharedPref.getInt(prefKey, 0); + + if (newVal >= 0) { + SharedPreferences.Editor editor = sharedPref.edit(); + editor.putInt(prefKey, newVal); + editor.apply(); + } + + return oldVal; + } + + // MediaScanPlan describes how we are going to perform the media scan + class MediaScanPlan { + class Step { + int msg; + Object arg; + boolean optional; + Step (int msg, Object arg, boolean optional) { + this.msg = msg; + this.arg = arg; + this.optional = optional; + } + } + + class Statistics { + String lastFile; + int seen = 0; + int changed = 0; + void reset() { + this.seen = 0; + this.changed = 0; + this.lastFile = null; + } + } + + /** + * All steps in this plan + */ + private ArrayList mSteps; + /** + * Statistics of the currently running step + */ + private Statistics mStats; + + MediaScanPlan() { + mSteps = new ArrayList<>(); + mStats = new Statistics(); + } + + Statistics getStatistics() { + return mStats; + } + + /** + * Called by the scanner to signal that a file was handled + * + * @param path the file we scanned + * @param changed true if this triggered a database update + */ + void registerProgress(String path, boolean changed) { + mStats.lastFile = path; + mStats.seen++; + if (changed) { + mStats.changed++; + } + } + + /** + * Adds the next step in our plan + * + * @param msg the message to add + * @param arg the argument to msg + */ + MediaScanPlan addNextStep(int msg, Object arg) { + mSteps.add(new Step(msg, arg, false)); + return this; + } + + /** + * Adds an optional step to our plan. This will NOT + * run if the previous step caused database changes + * + * @param msg the message to add + * @param arg the argument to msg + */ + MediaScanPlan addOptionalStep(int msg, Object arg) { + mSteps.add(new Step(msg, arg, true)); + return this; + } + + /** + * Returns the next step of our scan plan + * + * @return a new step object, null if we hit the end + */ + Step getNextStep() { + Step next = (mSteps.size() != 0 ? mSteps.remove(0) : null); + if (next != null) { + if (next.optional && mStats.changed != 0) { + next = null; + mSteps.clear(); + } + } + mStats.reset(); + return next; + } + } } diff --git a/src/ch/blinkenlights/android/vanilla/SDScannerFragment.java b/src/ch/blinkenlights/android/vanilla/SDScannerFragment.java index b83b3558..55b6cde4 100644 --- a/src/ch/blinkenlights/android/vanilla/SDScannerFragment.java +++ b/src/ch/blinkenlights/android/vanilla/SDScannerFragment.java @@ -22,11 +22,17 @@ import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.TextView; +import java.util.Timer; +import java.util.TimerTask; +import android.util.Log; public class SDScannerFragment extends Fragment { + private Timer mTimer; + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.sdscanner_fragment, container, false); @@ -44,16 +50,44 @@ public class SDScannerFragment extends Fragment }); } - /** Called when the activity is first created. */ @Override - public void onCreate(Bundle savedInstanceState) - { - super.onCreate(savedInstanceState); + public void onResume() { + super.onResume(); + Log.v("VanillaMusic", "onResume! "+mTimer); + + mTimer = new Timer(); + mTimer.scheduleAtFixedRate((new TimerTask() { + @Override + public void run() { + getActivity().runOnUiThread(new Runnable(){ + public void run() { + updateProgress(); + } + }); + }}), 0, 120); } + @Override + public void onPause() { + super.onPause(); + Log.v("VanillaMusic", "onPause "+mTimer); + if (mTimer != null) { + mTimer.cancel(); + mTimer = null; + } + } + + private void updateProgress() { + View button = getActivity().findViewById(R.id.start_button); + TextView progress = (TextView)getActivity().findViewById(R.id.progress_label); + String scanText = MediaLibrary.describeScanProgress(getActivity()); + progress.setText(scanText); + button.setEnabled(scanText == null); + } public void startButtonPressed(View view) { MediaLibrary.scanLibrary(getActivity(), true, false); + updateProgress(); } }