first version of automatic media scanner

This commit is contained in:
Adrian Ulrich 2016-12-26 19:31:12 +01:00
parent 8abdf25d38
commit dcd7df4f66
4 changed files with 358 additions and 119 deletions

View File

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

View File

@ -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
*

View File

@ -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<Step> mSteps;
/**
* Statistics of the currently running step
*/
private Statistics mStats;
MediaScanPlan() {
mSteps = new ArrayList<>();
mStats = new Statistics();
}
Statistics getStatistics() {
return mStats;
}
/**
* Called by the scanner to signal that a file was handled
*
* @param path the file we scanned
* @param changed true if this triggered a database update
*/
void registerProgress(String path, boolean changed) {
mStats.lastFile = path;
mStats.seen++;
if (changed) {
mStats.changed++;
}
}
/**
* Adds the next step in our plan
*
* @param msg the message to add
* @param arg the argument to msg
*/
MediaScanPlan addNextStep(int msg, Object arg) {
mSteps.add(new Step(msg, arg, false));
return this;
}
/**
* Adds an optional step to our plan. This will NOT
* run if the previous step caused database changes
*
* @param msg the message to add
* @param arg the argument to msg
*/
MediaScanPlan addOptionalStep(int msg, Object arg) {
mSteps.add(new Step(msg, arg, true));
return this;
}
/**
* Returns the next step of our scan plan
*
* @return a new step object, null if we hit the end
*/
Step getNextStep() {
Step next = (mSteps.size() != 0 ? mSteps.remove(0) : null);
if (next != null) {
if (next.optional && mStats.changed != 0) {
next = null;
mSteps.clear();
}
}
mStats.reset();
return next;
}
}
}

View File

@ -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();
}
}