ac.robinson.mediaphone.MediaPhoneActivity.java Source code

Java tutorial

Introduction

Here is the source code for ac.robinson.mediaphone.MediaPhoneActivity.java

Source

/*
 *  Copyright (C) 2012 Simon Robinson
 * 
 *  This file is part of Com-Me.
 * 
 *  Com-Me is free software; you can redistribute it and/or modify it 
 *  under the terms of the GNU Lesser General Public License as 
 *  published by the Free Software Foundation; either version 3 of the 
 *  License, or (at your option) any later version.
 *
 *  Com-Me is distributed in the hope that it will be useful, but WITHOUT 
 *  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 
 *  or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General 
 *  Public License for more details.
 *
 *  You should have received a copy of the GNU Lesser General Public
 *  License along with Com-Me.
 *  If not, see <http://www.gnu.org/licenses/>.
 */

package ac.robinson.mediaphone;

import java.io.File;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Hashtable;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import ac.robinson.mediaphone.activity.NarrativeBrowserActivity;
import ac.robinson.mediaphone.activity.PreferencesActivity;
import ac.robinson.mediaphone.activity.SaveNarrativeActivity;
import ac.robinson.mediaphone.activity.TemplateBrowserActivity;
import ac.robinson.mediaphone.importing.ImportedFileParser;
import ac.robinson.mediaphone.provider.FrameItem;
import ac.robinson.mediaphone.provider.FrameItem.NavigationMode;
import ac.robinson.mediaphone.provider.FramesManager;
import ac.robinson.mediaphone.provider.MediaItem;
import ac.robinson.mediaphone.provider.MediaManager;
import ac.robinson.mediaphone.provider.MediaPhoneProvider;
import ac.robinson.mediaphone.provider.NarrativeItem;
import ac.robinson.mediaphone.provider.NarrativesManager;
import ac.robinson.mediautilities.FrameMediaContainer;
import ac.robinson.mediautilities.HTMLUtilities;
import ac.robinson.mediautilities.MOVUtilities;
import ac.robinson.mediautilities.MediaUtilities;
import ac.robinson.mediautilities.SMILUtilities;
import ac.robinson.util.AndroidUtilities;
import ac.robinson.util.BitmapUtilities;
import ac.robinson.util.DebugUtilities;
import ac.robinson.util.IOUtilities;
import ac.robinson.util.ImageCacheUtilities;
import ac.robinson.util.UIUtilities;
import ac.robinson.util.ViewServer;
import ac.robinson.view.CenteredImageTextButton;
import ac.robinson.view.CrossFadeDrawable;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Point;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Message;
import android.os.Parcelable;
import android.os.SystemClock;
import android.preference.PreferenceManager;
import android.provider.MediaStore;
import android.provider.MediaStore.Video;
import android.support.v4.app.FragmentActivity;
import android.util.Log;
import android.util.TypedValue;
import android.view.GestureDetector;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.ImageView;

public abstract class MediaPhoneActivity extends FragmentActivity {

    private ImportFramesTask mImportFramesTask;
    private ProgressDialog mImportFramesProgressDialog;
    private boolean mImportFramesDialogShown = false;

    private ExportNarrativesTask mExportNarrativesTask;
    private boolean mExportNarrativeDialogShown = false;
    private boolean mExportVideoDialogShown = false;

    private QueuedBackgroundRunnerTask mBackgroundRunnerTask;
    private boolean mBackgroundRunnerDialogShown = false;

    private GestureDetector mGestureDetector = null;
    private boolean mCanSwipe;
    private boolean mHasSwiped;

    // stores the time of the last call to onResume() so we can filter out touch events that happened before this
    // activity was visible (happens on HTC Desire S for example) - see: http://stackoverflow.com/a/13988083/1993220
    private long mResumeTime = 0;

    // bit of a hack in a similar vein to above to prevent some devices from multiple-clicking
    private int mRecentlyClickedButton = -1;

    // load preferences that don't affect the interface
    abstract protected void loadPreferences(SharedPreferences mediaPhoneSettings);

    // load preferences that need to be configured after onCreate
    abstract protected void configureInterfacePreferences(SharedPreferences mediaPhoneSettings);

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (MediaPhone.DEBUG) {
            ViewServer.get(this).addWindow(this);
        }
        UIUtilities.setPixelDithering(getWindow());
        checkDirectoriesExist();

        Object retained = getLastCustomNonConfigurationInstance();
        if (retained instanceof Object[]) {
            Object[] retainedTasks = (Object[]) retained;
            if (retainedTasks.length == 3) {
                if (retainedTasks[0] instanceof ImportFramesTask) {
                    // reconnect to the task; dialog is shown automatically
                    mImportFramesTask = (ImportFramesTask) retainedTasks[0];
                    mImportFramesTask.setActivity(this);
                }
                if (retainedTasks[1] instanceof ExportNarrativesTask) {
                    // reconnect to the task; dialog is shown automatically
                    mExportNarrativesTask = (ExportNarrativesTask) retainedTasks[1];
                    mExportNarrativesTask.setActivity(this);
                }
                if (retainedTasks[2] instanceof QueuedBackgroundRunnerTask) {
                    // reconnect to the task; dialog is shown automatically
                    mBackgroundRunnerTask = (QueuedBackgroundRunnerTask) retainedTasks[2];
                    mBackgroundRunnerTask.setActivity(this);
                }
            }
        }

        loadAllPreferences(); // must do this before loading so that, e.g., audio knows high/low setting before setup
    }

    @Override
    protected void onStart() {
        configureInterfacePreferences(PreferenceManager.getDefaultSharedPreferences(MediaPhoneActivity.this));
        super.onStart();
    }

    @Override
    protected void onResume() {
        super.onResume();
        mResumeTime = SystemClock.uptimeMillis();
        if (MediaPhone.DEBUG) {
            ViewServer.get(this).setFocusedWindow(this);
        }
        ((MediaPhoneApplication) getApplication()).registerActivityHandle(this);
    }

    @Override
    protected void onPause() {
        super.onPause();
        mImportFramesProgressDialog = null;
        ((MediaPhoneApplication) getApplication()).removeActivityHandle(this);
    }

    @Override
    protected void onDestroy() {
        if (MediaPhone.DEBUG) {
            ViewServer.get(this).removeWindow(this);
        }
        super.onDestroy();
    }

    @Override
    public Object onRetainCustomNonConfigurationInstance() {
        // called before screen change - have to remove the parent activity
        if (mImportFramesTask != null) {
            mImportFramesTask.setActivity(null);
        }
        if (mExportNarrativesTask != null) {
            mExportNarrativesTask.setActivity(null);
        }
        if (mBackgroundRunnerTask != null) {
            mBackgroundRunnerTask.setActivity(null);
        }
        return new Object[] { mImportFramesTask, mExportNarrativesTask, mBackgroundRunnerTask };
    }

    protected void registerForSwipeEvents() {
        mHasSwiped = false;
        mCanSwipe = true;
        if (mGestureDetector == null) { // so we can re-call any time
            mGestureDetector = new GestureDetector(MediaPhoneActivity.this, new SwipeDetector());
        }
    }

    protected void setSwipeEventsEnabled(boolean enabled) {
        mCanSwipe = enabled;
    }

    // see: http://stackoverflow.com/a/7767610
    private class SwipeDetector extends SimpleOnGestureListener {
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (!mCanSwipe) {
                return false;
            }
            try {
                // must swipe along a fairly horizontal path
                if (Math.abs(e1.getY() - e2.getY()) > MediaPhone.SWIPE_MAX_OFF_PATH) {
                    return false;
                }

                // right to left
                if (e1.getX() - e2.getX() > MediaPhone.SWIPE_MIN_DISTANCE
                        && Math.abs(velocityX) > MediaPhone.SWIPE_THRESHOLD_VELOCITY) {
                    if (!mHasSwiped) {
                        mHasSwiped = swipeNext(); // so that we don't double-swipe and crash
                    }
                    return true;
                }

                // left to right
                if (e2.getX() - e1.getX() > MediaPhone.SWIPE_MIN_DISTANCE
                        && Math.abs(velocityX) > MediaPhone.SWIPE_THRESHOLD_VELOCITY) {
                    if (!mHasSwiped) {
                        mHasSwiped = swipePrevious(); // so that we don't double-swipe and crash
                    }
                    return true;
                }
            } catch (NullPointerException e) {
                // this happens when we get the first or last fling motion event (so only one event) - safe to ignore
            }
            return false;

        }
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent e) {
        if (e.getEventTime() < mResumeTime) {
            if (MediaPhone.DEBUG) {
                Log.d(DebugUtilities.getLogTag(this),
                        "Discarded touch event with start time earlier than onResume()");
            }
            return true;
        }

        if (mGestureDetector != null) {
            if (mGestureDetector.onTouchEvent(e)) {
                e.setAction(MotionEvent.ACTION_CANCEL); // swipe detected - don't do the normal event
            }
        }

        try {
            return super.dispatchTouchEvent(e);
        } catch (NullPointerException ex) {
            if (MediaPhone.DEBUG) {
                Log.d(DebugUtilities.getLogTag(this),
                        "Catching touch event Null Pointer Exception; ignoring touch");
            }
            return true; // reported on Play Store - see: http://stackoverflow.com/a/13031529/1993220
        }
    }

    // overridden where appropriate
    protected boolean swipePrevious() {
        return false;
    }

    // overridden where appropriate
    protected boolean swipeNext() {
        return false;
    }

    protected boolean verifyButtonClick(View currentButton) {
        // handle a problem on some devices where touch events get passed twice if the finger moves slightly
        final int buttonId = currentButton.getId();
        if (mRecentlyClickedButton == buttonId) {
            mRecentlyClickedButton = -1; // just in case - don't want to get stuck in the unclickable state
            if (MediaPhone.DEBUG) {
                Log.d(DebugUtilities.getLogTag(this), "Discarding button click too soon after previous");
            }
            return false;
        }

        // allow button clicks after a tap-length timeout
        mRecentlyClickedButton = buttonId;
        currentButton.postDelayed(new Runnable() {
            @Override
            public void run() {
                mRecentlyClickedButton = -1;
            }
        }, ViewConfiguration.getTapTimeout());

        return true;
    }

    @Override
    protected Dialog onCreateDialog(int id) {
        switch (id) {
        case R.id.dialog_importing_in_progress:
            ProgressDialog importDialog = new ProgressDialog(MediaPhoneActivity.this);
            importDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
            importDialog.setMessage(getString(R.string.import_progress));
            importDialog.setCancelable(false);
            mImportFramesProgressDialog = importDialog;
            mImportFramesDialogShown = true;
            return importDialog;
        case R.id.dialog_export_narrative_in_progress:
            ProgressDialog exportDialog = new ProgressDialog(MediaPhoneActivity.this);
            exportDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
            exportDialog.setMessage(getString(R.string.background_task_progress));
            exportDialog.setCancelable(false);
            exportDialog.setIndeterminate(true);
            mExportNarrativeDialogShown = true;
            return exportDialog;
        case R.id.dialog_mov_creator_in_progress:
            ProgressDialog movDialog = new ProgressDialog(MediaPhoneActivity.this);
            movDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
            movDialog.setMessage(getString(R.string.mov_export_task_progress));
            movDialog.setButton(DialogInterface.BUTTON_POSITIVE, getString(R.string.mov_export_run_in_background),
                    new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            dialog.dismiss();
                            mExportVideoDialogShown = false;
                        }
                    });
            movDialog.setCancelable(false);
            movDialog.setIndeterminate(true);
            mExportVideoDialogShown = true;
            return movDialog;
        case R.id.dialog_background_runner_in_progress:
            ProgressDialog runnerDialog = new ProgressDialog(MediaPhoneActivity.this);
            runnerDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
            runnerDialog.setMessage(getString(R.string.background_task_progress));
            runnerDialog.setCancelable(false);
            runnerDialog.setIndeterminate(true);
            mBackgroundRunnerDialogShown = true;
            return runnerDialog;
        default:
            return super.onCreateDialog(id);
        }
    }

    @Override
    protected void onPrepareDialog(int id, Dialog dialog) {
        super.onPrepareDialog(id, dialog);
        switch (id) {
        case R.id.dialog_importing_in_progress:
            mImportFramesProgressDialog = (ProgressDialog) dialog;
            if (mImportFramesTask != null) {
                mImportFramesProgressDialog.setMax(mImportFramesTask.getMaximumProgress());
                mImportFramesProgressDialog.setProgress(mImportFramesTask.getCurrentProgress());
            }
            mImportFramesDialogShown = true;
            break;
        case R.id.dialog_export_narrative_in_progress:
            mExportNarrativeDialogShown = true;
            break;
        case R.id.dialog_mov_creator_in_progress:
            mExportVideoDialogShown = true;
            break;
        case R.id.dialog_background_runner_in_progress:
            mBackgroundRunnerDialogShown = true;
            break;
        default:
            break;
        }
    }

    private void safeDismissDialog(int id) {
        try {
            dismissDialog(id);
        } catch (IllegalArgumentException e) { // we didn't show the dialog
        } catch (Throwable t) {
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.preferences, menu);
        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        case android.R.id.home:
            onBackPressed(); // to make sure we update frames - home is essentially back in this case
            return true;

        case R.id.menu_preferences:
            final Intent preferencesIntent = new Intent(MediaPhoneActivity.this, PreferencesActivity.class);
            startActivityForResult(preferencesIntent, MediaPhone.R_id_intent_preferences);
            return true;

        default:
            return super.onOptionsItemSelected(item);
        }
    }

    protected void setupMenuNavigationButtonsFromMedia(MenuInflater inflater, Menu menu,
            ContentResolver contentResolver, String mediaId, boolean edited) {
        String parentId = null;
        if (mediaId != null) {
            MediaItem mediaItem = MediaManager.findMediaByInternalId(getContentResolver(), mediaId);
            if (mediaItem != null) {
                parentId = mediaItem.getParentId();
            }
        }
        setupMenuNavigationButtons(inflater, menu, parentId, edited);
    }

    protected void setupMenuNavigationButtons(MenuInflater inflater, Menu menu, String frameId, boolean edited) {
        inflater.inflate(R.menu.previous_frame, menu);
        inflater.inflate(R.menu.next_frame, menu);
        // we should have already got focus by the time this is called, so can try to disable invalid buttons
        if (frameId != null) {
            NavigationMode navigationAllowed = FrameItem.getNavigationAllowed(getContentResolver(), frameId);
            if (navigationAllowed == NavigationMode.PREVIOUS || navigationAllowed == NavigationMode.NONE) {
                menu.findItem(R.id.menu_next_frame).setEnabled(false);
            }
            if (navigationAllowed == NavigationMode.NEXT || navigationAllowed == NavigationMode.NONE) {
                menu.findItem(R.id.menu_previous_frame).setEnabled(false);
            }
        }
        inflater.inflate(R.menu.add_frame, menu);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
            if (edited) {
                inflater.inflate(R.menu.finished_editing, menu);
            } else {
                inflater.inflate(R.menu.back_without_editing, menu);
            }
        }
    }

    protected void setBackButtonIcons(Activity activity, int button1, int button2, boolean isEdited) {
        if (button1 != 0) {
            ((CenteredImageTextButton) findViewById(button1)).setCompoundDrawablesWithIntrinsicBounds(0,
                    (isEdited ? R.drawable.ic_finished_editing : android.R.drawable.ic_menu_revert), 0, 0);
        }
        if (button2 != 0) {
            ((CenteredImageTextButton) findViewById(button2)).setCompoundDrawablesWithIntrinsicBounds(0,
                    (isEdited ? R.drawable.ic_finished_editing : android.R.drawable.ic_menu_revert), 0, 0);
        }
        UIUtilities.refreshActionBar(activity);
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent resultIntent) {
        switch (requestCode) {
        case MediaPhone.R_id_intent_preferences:
            loadAllPreferences();
            break;
        default:
            super.onActivityResult(requestCode, resultCode, resultIntent);
        }
    }

    private void loadAllPreferences() {
        SharedPreferences mediaPhoneSettings = PreferenceManager
                .getDefaultSharedPreferences(MediaPhoneActivity.this);
        Resources res = getResources();

        // bluetooth observer
        configureBluetoothObserver(mediaPhoneSettings, res);

        // importing confirmation
        boolean confirmImporting = res.getBoolean(R.bool.default_confirm_importing);
        try {
            confirmImporting = mediaPhoneSettings.getBoolean(getString(R.string.key_confirm_importing),
                    confirmImporting);
        } catch (Exception e) {
            confirmImporting = res.getBoolean(R.bool.default_confirm_importing);
        }
        MediaPhone.IMPORT_CONFIRM_IMPORTING = confirmImporting;

        // delete after import
        boolean deleteAfterImport = res.getBoolean(R.bool.default_delete_after_importing);
        try {
            deleteAfterImport = mediaPhoneSettings.getBoolean(getString(R.string.key_delete_after_importing),
                    deleteAfterImport);
        } catch (Exception e) {
            deleteAfterImport = res.getBoolean(R.bool.default_delete_after_importing);
        }
        MediaPhone.IMPORT_DELETE_AFTER_IMPORTING = deleteAfterImport;

        // minimum frame duration
        TypedValue resourceValue = new TypedValue();
        res.getValue(R.attr.default_minimum_frame_duration, resourceValue, true);
        float minimumFrameDuration;
        try {
            minimumFrameDuration = mediaPhoneSettings.getFloat(getString(R.string.key_minimum_frame_duration),
                    resourceValue.getFloat());
            if (minimumFrameDuration <= 0) {
                throw new NumberFormatException();
            }
        } catch (Exception e) {
            minimumFrameDuration = resourceValue.getFloat();
        }
        MediaPhone.PLAYBACK_EXPORT_MINIMUM_FRAME_DURATION = Math.round(minimumFrameDuration * 1000);

        // word duration
        res.getValue(R.attr.default_word_duration, resourceValue, true);
        float wordDuration;
        try {
            wordDuration = mediaPhoneSettings.getFloat(getString(R.string.key_word_duration),
                    resourceValue.getFloat());
            if (wordDuration <= 0) {
                throw new NumberFormatException();
            }
        } catch (Exception e) {
            wordDuration = resourceValue.getFloat();
        }
        MediaPhone.PLAYBACK_EXPORT_WORD_DURATION = Math.round(wordDuration * 1000);

        // screen orientation
        int requestedOrientation = res.getInteger(R.integer.default_screen_orientation);
        try {
            String requestedOrientationString = mediaPhoneSettings
                    .getString(getString(R.string.key_screen_orientation), null);
            requestedOrientation = Integer.valueOf(requestedOrientationString);
        } catch (Exception e) {
            requestedOrientation = res.getInteger(R.integer.default_screen_orientation);
        }
        setRequestedOrientation(requestedOrientation);

        // other preferences
        loadPreferences(mediaPhoneSettings);
    }

    protected void configureBluetoothObserver(SharedPreferences mediaPhoneSettings, Resources res) {
        boolean watchForFiles = res.getBoolean(R.bool.default_watch_for_files);
        try {
            watchForFiles = mediaPhoneSettings.getBoolean(getString(R.string.key_watch_for_files), watchForFiles);
        } catch (Exception e) {
            watchForFiles = res.getBoolean(R.bool.default_watch_for_files);
        }
        if (watchForFiles) {
            // file changes are handled in startWatchingBluetooth();
            ((MediaPhoneApplication) getApplication()).startWatchingBluetooth(false); // don't watch if bt not enabled
        } else {
            ((MediaPhoneApplication) getApplication()).stopWatchingBluetooth();
        }
    }

    public void checkDirectoriesExist() {

        // nothing will work, and previously saved files will not load
        if (MediaPhone.DIRECTORY_STORAGE == null) {

            // if we're not in the main activity, quit everything else and launch the narrative browser to exit
            if (!((Object) MediaPhoneActivity.this instanceof NarrativeBrowserActivity)) {
                Intent homeIntent = new Intent(this, NarrativeBrowserActivity.class);
                homeIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
                startActivity(homeIntent);
                Log.d(DebugUtilities.getLogTag(this), "Couldn't open storage directory - clearing top to exit");
                return;
            }

            SharedPreferences mediaPhoneSettings = getSharedPreferences(MediaPhone.APPLICATION_NAME,
                    Context.MODE_PRIVATE);
            final String storageKey = getString(R.string.key_use_external_storage);
            if (mediaPhoneSettings.contains(storageKey)) {
                if (mediaPhoneSettings.getBoolean(storageKey, true)) { // defValue is irrelevant, we know value exists
                    if (!isFinishing()) {
                        UIUtilities.showToast(MediaPhoneActivity.this, R.string.error_opening_narrative_content_sd);
                    }
                    Log.d(DebugUtilities.getLogTag(this), "Couldn't open storage directory (SD card) - exiting");
                    finish();
                    return;
                }
            }

            if (!isFinishing()) {
                UIUtilities.showToast(MediaPhoneActivity.this, R.string.error_opening_narrative_content);
            }
            Log.d(DebugUtilities.getLogTag(this), "Couldn't open storage directory - exiting");
            finish();
            return;
        }

        // thumbnail cache won't work, but not really fatal (thumbnails will be loaded into memory on demand)
        if (MediaPhone.DIRECTORY_THUMBS == null) {
            Log.d(DebugUtilities.getLogTag(this), "Thumbnail directory not found");
        }

        // external narrative sending (Bluetooth/YouTube etc) may not work, but not really fatal (will warn on export)
        if (MediaPhone.DIRECTORY_TEMP == null) {
            Log.d(DebugUtilities.getLogTag(this),
                    "Temporary directory not found - will warn before narrative export");
        }

        // bluetooth directory availability may have changed if we're calling from an SD card availability notification
        configureBluetoothObserver(PreferenceManager.getDefaultSharedPreferences(MediaPhoneActivity.this),
                getResources());
    }

    protected void onBluetoothServiceRegistered() {
        // override this method to get a notification when the bluetooth service has been registered
    }

    public void processIncomingFiles(Message msg) {

        // deal with messages from the BluetoothObserver
        Bundle fileData = msg.peekData();
        if (fileData == null) {
            return; // error - no parameters passed
        }

        String importedFileName = fileData.getString(MediaUtilities.KEY_FILE_NAME);
        if (importedFileName == null) {
            if (msg.what == MediaUtilities.MSG_IMPORT_SERVICE_REGISTERED) {
                onBluetoothServiceRegistered();
            }
            return; // error - no filename
        }

        // get the imported file object
        final File importedFile = new File(importedFileName);
        if (!importedFile.canRead() || !importedFile.canWrite()) {
            if (MediaPhone.IMPORT_DELETE_AFTER_IMPORTING) {
                importedFile.delete(); // error - probably won't work, but might
                // as well try; doesn't throw, so is okay
            }
            return;
        }

        final int messageType = msg.what;
        switch (messageType) {
        case MediaUtilities.MSG_RECEIVED_IMPORT_FILE:
            UIUtilities.showToast(MediaPhoneActivity.this, R.string.import_starting);
            break;

        case MediaUtilities.MSG_RECEIVED_SMIL_FILE:
        case MediaUtilities.MSG_RECEIVED_HTML_FILE:
        case MediaUtilities.MSG_RECEIVED_MOV_FILE:
            if (MediaPhone.IMPORT_CONFIRM_IMPORTING) {
                if (MediaPhoneActivity.this.isFinishing()) {
                    if (!(MediaPhoneActivity.this instanceof NarrativeBrowserActivity)) {
                        // TODO: send a delayed message to the next task? (can't from NarrativeBrowser - app exit)
                    }
                } else {
                    AlertDialog.Builder builder = new AlertDialog.Builder(MediaPhoneActivity.this);
                    builder.setTitle(R.string.import_file_confirmation);
                    // fake that we're using the SMIL file if we're actually using .sync.jpg
                    builder.setMessage(getString(R.string.import_file_hint,
                            importedFile.getName().replace(MediaUtilities.SYNC_FILE_EXTENSION, "")
                                    .replace(MediaUtilities.SMIL_FILE_EXTENSION, "")));
                    builder.setIcon(android.R.drawable.ic_dialog_info);
                    builder.setNegativeButton(R.string.import_not_now, null);
                    builder.setPositiveButton(R.string.import_file, new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int whichButton) {
                            importFiles(messageType, importedFile);
                        }
                    });
                    AlertDialog alert = builder.create();
                    alert.show();
                }
            } else {
                importFiles(messageType, importedFile);
            }
            break;
        }
    }

    private void importFiles(int type, File receivedFile) {
        ArrayList<FrameMediaContainer> narrativeFrames = null;
        int sequenceIncrement = getResources().getInteger(R.integer.frame_narrative_sequence_increment);

        switch (type) {
        case MediaUtilities.MSG_RECEIVED_SMIL_FILE:
            narrativeFrames = ImportedFileParser.importSMILNarrative(getContentResolver(), receivedFile,
                    sequenceIncrement);
            break;
        case MediaUtilities.MSG_RECEIVED_HTML_FILE:
            UIUtilities.showToast(MediaPhoneActivity.this, R.string.html_feature_coming_soon);
            narrativeFrames = ImportedFileParser.importHTMLNarrative(getContentResolver(), receivedFile,
                    sequenceIncrement);
            break;

        case MediaUtilities.MSG_RECEIVED_MOV_FILE:
            narrativeFrames = ImportedFileParser.importMOVNarrative(receivedFile);
            break;
        }

        importFrames(narrativeFrames);
    }

    private void importFrames(ArrayList<FrameMediaContainer> narrativeFrames) {
        // import - start a new task or add to existing
        // TODO: do we need to keep the screen alive? (so cancelled tasks don't get stuck - better to use fragments...)
        if (narrativeFrames != null && narrativeFrames.size() > 0) {
            if (mImportFramesTask != null) {
                mImportFramesTask.addFramesToImport(narrativeFrames);
            } else {
                mImportFramesTask = new ImportFramesTask(MediaPhoneActivity.this);
                mImportFramesTask.addFramesToImport(narrativeFrames);
                mImportFramesTask.execute();
            }
        }
    }

    protected void onImportProgressUpdate(int currentProgress, int newMaximum) {
        if (mImportFramesDialogShown && mImportFramesProgressDialog != null) {
            mImportFramesProgressDialog.setProgress(currentProgress);
            mImportFramesProgressDialog.setMax(newMaximum);
        }
    }

    protected void onImportTaskCompleted() {
        // all frames imported - remove the reference, but start a new thread for any frames added since we finished
        ArrayList<FrameMediaContainer> newFrames = null;
        if (mImportFramesTask != null) {
            if (mImportFramesTask.getFramesSize() > 0) {
                newFrames = (ArrayList<FrameMediaContainer>) mImportFramesTask.getFrames();
            }
            mImportFramesTask = null;
        }

        // can only interact with dialogs this instance actually showed
        if (mImportFramesDialogShown) {
            if (mImportFramesProgressDialog != null) {
                mImportFramesProgressDialog.setProgress(mImportFramesProgressDialog.getMax());
            }
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
                    safeDismissDialog(R.id.dialog_importing_in_progress);
                }
            }, 250); // delayed so the view has time to update with the completed state
            mImportFramesDialogShown = false;
        }
        mImportFramesProgressDialog = null;

        // import any frames that were queued after we finished
        if (newFrames != null) {
            importFrames(newFrames);
        } else {
            UIUtilities.showToast(MediaPhoneActivity.this, R.string.import_finished);
        }
    }

    protected void saveLastEditedFrame(String frameInternalId) {
        SharedPreferences frameIdSettings = getSharedPreferences(MediaPhone.APPLICATION_NAME, Context.MODE_PRIVATE);
        SharedPreferences.Editor prefsEditor = frameIdSettings.edit();
        prefsEditor.putString(getString(R.string.key_last_edited_frame), frameInternalId);
        prefsEditor.commit(); // this *must* happen before we return
    }

    protected String loadLastEditedFrame() {
        SharedPreferences frameIdSettings = getSharedPreferences(MediaPhone.APPLICATION_NAME, Context.MODE_PRIVATE);
        return frameIdSettings.getString(getString(R.string.key_last_edited_frame), null);
    }

    /**
     * Switch from one frame to another. Will call onBackPressed() on the calling activity
     * 
     * @param currentFrameId
     * @param buttonId
     * @param idExtra
     * @param showOptionsMenu
     * @param targetActivityClass
     * @return
     */
    protected boolean switchFrames(String currentFrameId, int buttonId, int idExtra, boolean showOptionsMenu,
            Class<?> targetActivityClass) {
        if (currentFrameId == null) {
            return false;
        }
        ContentResolver contentResolver = getContentResolver();
        FrameItem currentFrame = FramesManager.findFrameByInternalId(contentResolver, currentFrameId);
        ArrayList<String> narrativeFrameIds = FramesManager.findFrameIdsByParentId(contentResolver,
                currentFrame.getParentId());
        int currentPosition = narrativeFrameIds.indexOf(currentFrameId);
        int newFramePosition = -1;
        int inAnimation = R.anim.slide_in_from_right;
        int outAnimation = R.anim.slide_out_to_left;
        switch (buttonId) {
        case R.id.menu_previous_frame:
            if (currentPosition > 0) {
                newFramePosition = currentPosition - 1;
            }
            inAnimation = R.anim.slide_in_from_left;
            outAnimation = R.anim.slide_out_to_right;
            break;
        case R.id.menu_next_frame:
            if (currentPosition < narrativeFrameIds.size() - 1) {
                newFramePosition = currentPosition + 1;
            }
            break;
        }
        if (newFramePosition >= 0) {
            final Intent nextPreviousFrameIntent = new Intent(MediaPhoneActivity.this, targetActivityClass);
            nextPreviousFrameIntent.putExtra(getString(idExtra), narrativeFrameIds.get(newFramePosition));

            // this allows us to prevent showing first activity launch hints repeatedly
            nextPreviousFrameIntent.putExtra(getString(R.string.extra_switched_frames), true);

            // for API 11 and above, buttons are in the action bar, so this is unnecessary
            if (showOptionsMenu && Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) {
                nextPreviousFrameIntent.putExtra(getString(R.string.extra_show_options_menu), true);
            }

            startActivity(nextPreviousFrameIntent); // no result so that the original can exit (TODO: will it?)
            closeOptionsMenu(); // so onBackPressed doesn't just do this
            onBackPressed();
            overridePendingTransition(inAnimation, outAnimation);
            return true;
        } else {
            UIUtilities.showToast(MediaPhoneActivity.this, R.string.next_previous_no_more_frames);
            return false;
        }
    }

    private void sendFiles(final ArrayList<Uri> filesToSend) {
        // send files in a separate task without a dialog so we don't leave the previous progress dialog behind on
        // screen rotation - this is a bit of a hack, but it works
        runImmediateBackgroundTask(new BackgroundRunnable() {
            @Override
            public int getTaskId() {
                return 0;
            }

            @Override
            public boolean getShowDialog() {
                return false;
            }

            @Override
            public void run() {
                if (filesToSend == null || filesToSend.size() <= 0) {
                    return;
                }

                // ensure files are accessible to send - bit of a last-ditch effort for when temp is on internal storage
                for (Uri fileUri : filesToSend) {
                    IOUtilities.setFullyPublic(new File(fileUri.getPath()));
                }

                // also see: http://stackoverflow.com/questions/2344768/
                // could use application/smil+xml (or html), or video/quicktime, but then there's no bluetooth option
                final Intent sendIntent = new Intent(Intent.ACTION_SEND_MULTIPLE);
                sendIntent.setType(getString(R.string.export_mime_type));
                sendIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, filesToSend);

                final Intent chooserIntent = Intent.createChooser(sendIntent,
                        getString(R.string.export_narrative_title));

                // an extra activity at the start of the list that moves exported files to SD, but only if SD available
                if (IOUtilities.externalStorageIsWritable()) {
                    final Intent targetedShareIntent = new Intent(MediaPhoneActivity.this,
                            SaveNarrativeActivity.class);
                    targetedShareIntent.setAction(Intent.ACTION_SEND_MULTIPLE);
                    targetedShareIntent.setType(getString(R.string.export_mime_type));
                    targetedShareIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, filesToSend);
                    chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, new Parcelable[] { targetedShareIntent });
                }

                startActivity(chooserIntent); // single task mode; no return value given
            }
        });
    }

    protected void deleteNarrativeDialog(final String frameInternalId) {
        AlertDialog.Builder builder = new AlertDialog.Builder(MediaPhoneActivity.this);
        builder.setTitle(R.string.delete_narrative_confirmation);
        builder.setMessage(R.string.delete_narrative_hint);
        builder.setIcon(android.R.drawable.ic_dialog_alert);
        builder.setNegativeButton(android.R.string.cancel, null);
        builder.setPositiveButton(R.string.button_delete, new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int whichButton) {
                ContentResolver contentResolver = getContentResolver();
                FrameItem currentFrame = FramesManager.findFrameByInternalId(contentResolver, frameInternalId);
                final String narrativeId = currentFrame.getParentId();
                int numFramesDeleted = FramesManager.countFramesByParentId(contentResolver, narrativeId);
                AlertDialog.Builder builder = new AlertDialog.Builder(MediaPhoneActivity.this);
                builder.setTitle(R.string.delete_narrative_second_confirmation);
                builder.setMessage(getResources().getQuantityString(R.plurals.delete_narrative_second_hint,
                        numFramesDeleted, numFramesDeleted));
                builder.setIcon(android.R.drawable.ic_dialog_alert);
                builder.setNegativeButton(android.R.string.cancel, null);
                builder.setPositiveButton(R.string.button_delete, new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int whichButton) {
                        ContentResolver contentResolver = getContentResolver();
                        NarrativeItem narrativeToDelete = NarrativesManager
                                .findNarrativeByInternalId(contentResolver, narrativeId);
                        narrativeToDelete.setDeleted(true);
                        NarrativesManager.updateNarrative(contentResolver, narrativeToDelete);
                        UIUtilities.showToast(MediaPhoneActivity.this, R.string.delete_narrative_succeeded);
                        onBackPressed();
                    }
                });
                AlertDialog alert = builder.create();
                alert.show();
            }
        });
        AlertDialog alert = builder.create();
        alert.show();
    }

    protected void exportContent(final String narrativeId, final boolean isTemplate) {
        if (MediaPhone.DIRECTORY_TEMP == null) {
            UIUtilities.showToast(MediaPhoneActivity.this, R.string.export_missing_directory, true);
            return;
        }
        if (IOUtilities.isInternalPath(MediaPhone.DIRECTORY_TEMP.getAbsolutePath())) {
            UIUtilities.showToast(MediaPhoneActivity.this, R.string.export_potential_problem, true);
        }

        // important to keep awake to export because we only have one chance to display the export options
        // after creating mov or smil file (will be cancelled on screen unlock; Android is weird)
        // TODO: move to a better (e.g. notification bar) method of exporting?
        UIUtilities.acquireKeepScreenOn(getWindow());

        final CharSequence[] items = { getString(R.string.export_mov), getString(R.string.export_html),
                getString(R.string.export_smil, getString(R.string.app_name)) };

        AlertDialog.Builder builder = new AlertDialog.Builder(MediaPhoneActivity.this);
        builder.setTitle(R.string.export_narrative_title);
        // builder.setMessage(R.string.send_narrative_hint); //breaks dialog
        builder.setIcon(android.R.drawable.ic_dialog_info);
        builder.setNegativeButton(android.R.string.cancel, null);
        builder.setItems(items, new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int item) {
                ContentResolver contentResolver = getContentResolver();

                NarrativeItem thisNarrative;
                if (isTemplate) {
                    thisNarrative = NarrativesManager.findTemplateByInternalId(contentResolver, narrativeId);
                } else {
                    thisNarrative = NarrativesManager.findNarrativeByInternalId(contentResolver, narrativeId);
                }
                final ArrayList<FrameMediaContainer> contentList = thisNarrative.getContentList(contentResolver);

                // random name to counter repeat sending name issues
                String exportId = MediaPhoneProvider.getNewInternalId().substring(0, 8);
                final String exportName = String.format(Locale.ENGLISH, "%s-%s",
                        getString(R.string.app_name).replaceAll("[^a-zA-Z0-9]+", "-").toLowerCase(Locale.ENGLISH),
                        exportId);

                Resources res = getResources();
                final Map<Integer, Object> settings = new Hashtable<Integer, Object>();
                settings.put(MediaUtilities.KEY_AUDIO_RESOURCE_ID, R.raw.ic_audio_playback);

                // some output settings (TODO: make sure HTML version respects these)
                settings.put(MediaUtilities.KEY_BACKGROUND_COLOUR, res.getColor(R.color.export_background));
                settings.put(MediaUtilities.KEY_TEXT_COLOUR_NO_IMAGE, res.getColor(R.color.export_text_no_image));
                settings.put(MediaUtilities.KEY_TEXT_COLOUR_WITH_IMAGE,
                        res.getColor(R.color.export_text_with_image));
                settings.put(MediaUtilities.KEY_TEXT_BACKGROUND_COLOUR,
                        res.getColor(R.color.export_text_background));
                // TODO: do we want to do getDimensionPixelSize for export?
                settings.put(MediaUtilities.KEY_TEXT_SPACING,
                        res.getDimensionPixelSize(R.dimen.export_icon_text_padding));
                settings.put(MediaUtilities.KEY_TEXT_CORNER_RADIUS,
                        res.getDimensionPixelSize(R.dimen.export_icon_text_corner_radius));
                settings.put(MediaUtilities.KEY_TEXT_BACKGROUND_SPAN_WIDTH,
                        Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB);
                settings.put(MediaUtilities.KEY_MAX_TEXT_FONT_SIZE,
                        res.getDimensionPixelSize(R.dimen.export_maximum_text_size));
                settings.put(MediaUtilities.KEY_MAX_TEXT_CHARACTERS_PER_LINE,
                        res.getInteger(R.integer.export_maximum_text_characters_per_line));
                settings.put(MediaUtilities.KEY_MAX_TEXT_HEIGHT_WITH_IMAGE,
                        res.getDimensionPixelSize(R.dimen.export_maximum_text_height_with_image));

                if (contentList != null && contentList.size() > 0) {
                    switch (item) {
                    case 0:
                        settings.put(MediaUtilities.KEY_OUTPUT_WIDTH, res.getInteger(R.integer.export_mov_width));
                        settings.put(MediaUtilities.KEY_OUTPUT_HEIGHT, res.getInteger(R.integer.export_mov_height));
                        settings.put(MediaUtilities.KEY_IMAGE_QUALITY,
                                res.getInteger(R.integer.camera_jpeg_save_quality));

                        // all image files are compatible - we just convert to JPEG when writing the movie,
                        // but we need to check for incompatible audio that we can't convert to PCM
                        boolean incompatibleAudio = false;
                        for (FrameMediaContainer frame : contentList) {
                            for (String audioPath : frame.mAudioPaths) {
                                if (!AndroidUtilities.arrayContains(MediaUtilities.MOV_AUDIO_FILE_EXTENSIONS,
                                        IOUtilities.getFileExtension(audioPath))) {
                                    incompatibleAudio = true;
                                    break;
                                }
                            }
                            if (incompatibleAudio) {
                                break;
                            }
                        }

                        if (incompatibleAudio) {
                            AlertDialog.Builder builder = new AlertDialog.Builder(MediaPhoneActivity.this);
                            builder.setTitle(android.R.string.dialog_alert_title);
                            builder.setMessage(R.string.mov_export_mov_incompatible);
                            builder.setIcon(android.R.drawable.ic_dialog_alert);
                            builder.setNegativeButton(android.R.string.cancel, null);
                            builder.setPositiveButton(R.string.button_continue,
                                    new DialogInterface.OnClickListener() {
                                        @Override
                                        public void onClick(DialogInterface dialog, int whichButton) {
                                            exportMovie(settings, exportName, contentList);
                                        }
                                    });
                            AlertDialog alert = builder.create();
                            alert.show();
                        } else {
                            exportMovie(settings, exportName, contentList);
                        }
                        break;

                    case 1:
                        settings.put(MediaUtilities.KEY_OUTPUT_WIDTH, res.getInteger(R.integer.export_html_width));
                        settings.put(MediaUtilities.KEY_OUTPUT_HEIGHT,
                                res.getInteger(R.integer.export_html_height));
                        runExportNarrativesTask(new BackgroundRunnable() {
                            private int mTaskResult = 0;

                            @Override
                            public int getTaskId() {
                                return mTaskResult;
                            }

                            @Override
                            public boolean getShowDialog() {
                                return true;
                            }

                            @Override
                            public void run() {
                                ArrayList<Uri> filesToSend = HTMLUtilities.generateNarrativeHTML(getResources(),
                                        new File(MediaPhone.DIRECTORY_TEMP,
                                                exportName + MediaUtilities.HTML_FILE_EXTENSION),
                                        contentList, settings);

                                if (filesToSend == null || filesToSend.size() <= 0) {
                                    mTaskResult = R.id.export_creation_failed;
                                } else {
                                    sendFiles(filesToSend);
                                }
                            }
                        });
                        break;

                    case 2:
                        settings.put(MediaUtilities.KEY_OUTPUT_WIDTH, res.getInteger(R.integer.export_smil_width));
                        settings.put(MediaUtilities.KEY_OUTPUT_HEIGHT,
                                res.getInteger(R.integer.export_smil_height));
                        settings.put(MediaUtilities.KEY_PLAYER_BAR_ADJUSTMENT,
                                res.getInteger(R.integer.export_smil_player_bar_adjustment));
                        runExportNarrativesTask(new BackgroundRunnable() {
                            private int mTaskResult = 0;

                            @Override
                            public int getTaskId() {
                                return mTaskResult;
                            }

                            @Override
                            public boolean getShowDialog() {
                                return true;
                            }

                            @Override
                            public void run() {
                                ArrayList<Uri> filesToSend = SMILUtilities.generateNarrativeSMIL(getResources(),
                                        new File(MediaPhone.DIRECTORY_TEMP,
                                                exportName + MediaUtilities.SMIL_FILE_EXTENSION),
                                        contentList, settings);

                                if (filesToSend == null || filesToSend.size() <= 0) {
                                    mTaskResult = R.id.export_creation_failed;
                                } else {
                                    sendFiles(filesToSend);
                                }
                            }
                        });
                        break;
                    }
                } else {
                    UIUtilities.showToast(MediaPhoneActivity.this,
                            (isTemplate ? R.string.export_template_failed : R.string.export_narrative_failed));
                }
                dialog.dismiss();
            }
        });
        AlertDialog alert = builder.create();
        alert.show();
    }

    private void exportMovie(final Map<Integer, Object> settings, final String exportName,
            final ArrayList<FrameMediaContainer> contentList) {
        runExportNarrativesTask(new BackgroundRunnable() {
            // mov export is a special case - the id matters at task start time (so we can show the right dialog)
            private int mTaskResult = R.id.export_mov_task_complete;

            @Override
            public int getTaskId() {
                return mTaskResult;
            }

            @Override
            public boolean getShowDialog() {
                return true;
            }

            @Override
            public void run() {
                ArrayList<Uri> movFiles = MOVUtilities.generateNarrativeMOV(getResources(),
                        new File(MediaPhone.DIRECTORY_TEMP, exportName + MediaUtilities.MOV_FILE_EXTENSION),
                        contentList, settings);

                // must use media store parameters properly, or YouTube export fails
                // see: http://stackoverflow.com/questions/5884092/
                ArrayList<Uri> filesToSend = new ArrayList<Uri>();
                for (Uri fileUri : movFiles) {
                    File outputFile = new File(fileUri.getPath());
                    ContentValues content = new ContentValues(5);
                    content.put(MediaStore.Video.Media.DATA, outputFile.getAbsolutePath());
                    content.put(MediaStore.Video.VideoColumns.SIZE, outputFile.length());
                    content.put(Video.VideoColumns.DATE_ADDED, System.currentTimeMillis() / 1000);
                    content.put(Video.Media.MIME_TYPE, "video/quicktime");
                    content.put(Video.VideoColumns.TITLE, IOUtilities.removeExtension(outputFile.getName()));
                    filesToSend
                            .add(getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, content));
                }

                if (filesToSend == null || filesToSend.size() <= 0) {
                    mTaskResult = R.id.export_creation_failed;
                } else {
                    sendFiles(filesToSend);
                }
            }
        });
    }

    protected void runExportNarrativesTask(BackgroundRunnable r) {
        // import - start a new task or add to existing
        // TODO: do we need to keep the screen alive? (so cancelled tasks don't get stuck - better to use fragments...)
        if (mExportNarrativesTask != null) {
            mExportNarrativesTask.addTask(r);
        } else {
            mExportNarrativesTask = new ExportNarrativesTask(this);
            mExportNarrativesTask.addTask(r);
            mExportNarrativesTask.execute();
        }
    }

    private void onExportNarrativesTaskProgressUpdate(int taskId) {
        // dismiss dialogs first so we don't leak if onBackgroundTaskCompleted finishes the activity
        if (mExportNarrativeDialogShown) {
            safeDismissDialog(R.id.dialog_export_narrative_in_progress);
            mExportNarrativeDialogShown = false;
        }
        if (mExportVideoDialogShown) {
            safeDismissDialog(R.id.dialog_mov_creator_in_progress);
        }

        switch (taskId) {
        case R.id.export_mov_task_complete:
            if (!mExportVideoDialogShown) {
                // if they dismissed the mov export dialog let them know that it has finished
                UIUtilities.showToast(MediaPhoneActivity.this, R.string.mov_export_task_complete);
            }
            break;
        case R.id.export_creation_failed:
            UIUtilities.showToast(MediaPhoneActivity.this, R.string.export_creation_failed, true);
            break;
        }

        mExportVideoDialogShown = false;
    }

    private void onAllExportNarrativesTasksCompleted() {
        // all tasks complete - remove the reference, but start a new thread for any tasks started since we finished
        List<BackgroundRunnable> newTasks = null;
        if (mExportNarrativesTask != null) {
            if (mExportNarrativesTask.getTasksSize() > 0) {
                newTasks = mExportNarrativesTask.getTasks();
            }
            mExportNarrativesTask = null;
        }

        // can only interact with dialogs this instance actually showed
        if (mExportNarrativeDialogShown) {
            safeDismissDialog(R.id.dialog_export_narrative_in_progress);
            mExportNarrativeDialogShown = false;
        }
        if (mExportVideoDialogShown) {
            safeDismissDialog(R.id.dialog_mov_creator_in_progress);
            mExportVideoDialogShown = false;
        }

        // run any tasks that were queued after we finished
        if (newTasks != null) {
            for (BackgroundRunnable task : newTasks) {
                runExportNarrativesTask(task);
            }
        }
    }

    /**
     * Run a BackgroundRunnable immediately (i.e. not queued). After running no result will be given (i.e.
     * onBackgroundTaskCompleted will <b>not</b> be called), and there is no guarantee that any dialogs shown will be
     * cancelled (e.g. if the screen has rotated). This method is therefore most suitable for tasks whose getTaskId()
     * returns 0 (indicating that no result is needed), and whose getShowDialog() returns false.
     */
    protected void runImmediateBackgroundTask(Runnable r) {
        ImmediateBackgroundRunnerTask backgroundTask = new ImmediateBackgroundRunnerTask(r);
        backgroundTask.execute();
    }

    protected void runQueuedBackgroundTask(BackgroundRunnable r) {
        // import - start a new task or add to existing
        // TODO: do we need to keep the screen alive? (so cancelled tasks don't get stuck - better to use fragments...)
        if (mBackgroundRunnerTask != null) {
            mBackgroundRunnerTask.addTask(r);
        } else {
            mBackgroundRunnerTask = new QueuedBackgroundRunnerTask(this);
            mBackgroundRunnerTask.addTask(r);
            mBackgroundRunnerTask.execute();
        }
    }

    protected void onBackgroundTaskCompleted(int taskId) {
        // override this in subclasses to get task updates
    }

    // a single task has completed
    private void onBackgroundTaskProgressUpdate(int taskId) {
        // dismiss dialogs first so we don't leak if onBackgroundTaskCompleted finishes the activity
        if (mBackgroundRunnerDialogShown) {
            safeDismissDialog(R.id.dialog_background_runner_in_progress);
            mBackgroundRunnerDialogShown = false;
        }

        // report any task results
        onBackgroundTaskCompleted(taskId);

        // alert when template creation is complete - here as template creation can happen in several places
        // don't do this from template browser as in that case we're copying the other way (i.e. creating narrative)
        if (taskId == R.id.make_load_template_task_complete
                && !(MediaPhoneActivity.this instanceof TemplateBrowserActivity)
                && !MediaPhoneActivity.this.isFinishing()) {
            AlertDialog.Builder builder = new AlertDialog.Builder(MediaPhoneActivity.this);
            builder.setTitle(R.string.make_template_confirmation);
            builder.setMessage(R.string.make_template_hint);
            builder.setIcon(android.R.drawable.ic_dialog_info);
            builder.setPositiveButton(android.R.string.ok, null);
            AlertDialog alert = builder.create();
            alert.show();
        }
    }

    private void onAllBackgroundTasksCompleted() {
        // all tasks complete - remove the reference, but start a new thread for any tasks started since we finished
        List<BackgroundRunnable> newTasks = null;
        if (mBackgroundRunnerTask != null) {
            if (mBackgroundRunnerTask.getTasksSize() > 0) {
                newTasks = mBackgroundRunnerTask.getTasks();
            }
            mBackgroundRunnerTask = null;
        }

        // can only interact with dialogs this instance actually showed
        if (mBackgroundRunnerDialogShown) {
            safeDismissDialog(R.id.dialog_background_runner_in_progress);
            mBackgroundRunnerDialogShown = false;
        }

        // run any tasks that were queued after we finished
        if (newTasks != null) {
            for (BackgroundRunnable task : newTasks) {
                runQueuedBackgroundTask(task);
            }
        }
    }

    private class ImportFramesTask extends AsyncTask<FrameMediaContainer, Void, Void> {

        private MediaPhoneActivity mParentActivity;
        private boolean mImportTaskCompleted;
        private List<FrameMediaContainer> mFrameItems;
        private int mMaximumListLength;

        private ImportFramesTask(MediaPhoneActivity activity) {
            mParentActivity = activity;
            mImportTaskCompleted = false;
            mFrameItems = Collections.synchronizedList(new ArrayList<FrameMediaContainer>());
            mMaximumListLength = 0;
        }

        private void addFramesToImport(ArrayList<FrameMediaContainer> newFrames) {
            mMaximumListLength += newFrames.size();
            mFrameItems.addAll(0, newFrames); // add at the start for better UI (can be seen as they appear)
            if (!mParentActivity.isFinishing()) {
                mParentActivity.showDialog(R.id.dialog_importing_in_progress);
            }
        }

        private int getFramesSize() {
            return mFrameItems.size();
        }

        private List<FrameMediaContainer> getFrames() {
            return mFrameItems;
        }

        @Override
        protected void onPreExecute() {
            if (!mParentActivity.isFinishing()) {
                mParentActivity.showDialog(R.id.dialog_importing_in_progress);
            }
        }

        @Override
        protected Void doInBackground(FrameMediaContainer... framesToImport) {
            mMaximumListLength += framesToImport.length;
            for (int i = 0, n = framesToImport.length; i < n; i++) {
                mFrameItems.add(framesToImport[i]);
            }

            boolean framesAvailable = mFrameItems.size() > 0;
            while (framesAvailable) {
                // get resources and content resolver each time in case the activity changes
                ImportedFileParser.importNarrativeFrame(mParentActivity.getResources(),
                        mParentActivity.getContentResolver(), mFrameItems.remove(0));
                framesAvailable = mFrameItems.size() > 0;
                publishProgress();
            }

            return null;
        }

        @Override
        protected void onProgressUpdate(Void... unused) {
            if (mParentActivity != null) {
                mParentActivity.onImportProgressUpdate(getCurrentProgress(), getMaximumProgress());
            }
        }

        @Override
        protected void onPostExecute(Void unused) {
            mImportTaskCompleted = true;
            notifyActivityTaskCompleted();
        }

        public int getCurrentProgress() {
            return mMaximumListLength - mFrameItems.size();
        }

        public int getMaximumProgress() {
            return mMaximumListLength;
        }

        private void setActivity(MediaPhoneActivity activity) {
            this.mParentActivity = activity;
            if (mImportTaskCompleted) {
                notifyActivityTaskCompleted();
            }
        }

        private void notifyActivityTaskCompleted() {
            if (mParentActivity != null) {
                mParentActivity.onImportTaskCompleted();
            }
        }
    }

    private class ExportNarrativesTask extends AsyncTask<BackgroundRunnable, int[], Void> {

        private MediaPhoneActivity mParentActivity;
        private boolean mTasksCompleted;
        private List<BackgroundRunnable> mTasks;

        private ExportNarrativesTask(MediaPhoneActivity activity) {
            mParentActivity = activity;
            mTasksCompleted = false;
            mTasks = Collections.synchronizedList(new ArrayList<BackgroundRunnable>());
        }

        private void addTask(BackgroundRunnable task) {
            mTasks.add(task);
        }

        private int getTasksSize() {
            return mTasks.size();
        }

        private List<BackgroundRunnable> getTasks() {
            return mTasks;
        }

        @Override
        protected Void doInBackground(BackgroundRunnable... tasks) {
            for (int i = 0, n = tasks.length; i < n; i++) {
                mTasks.add(tasks[i]);
            }

            while (mTasks.size() > 0) {
                BackgroundRunnable r = mTasks.remove(0);
                if (r != null) {
                    publishProgress(new int[] { r.getTaskId(), r.getShowDialog() ? 1 : 0, 0 });
                    try {
                        r.run();
                    } catch (Throwable t) {
                        Log.d(DebugUtilities.getLogTag(this),
                                "Error running background task: " + t.getLocalizedMessage());
                    }
                    publishProgress(new int[] { r.getTaskId(), r.getShowDialog() ? 1 : 0, 1 });
                }
            }

            return null;
        }

        @Override
        protected void onProgressUpdate(int[]... taskIds) {
            if (mParentActivity != null) {
                for (int i = 0, n = taskIds.length; i < n; i++) {
                    // bit of a hack to tell us when to show a dialog and when to report progress
                    if (taskIds[i][2] == 1) { // 1 == task complete
                        mParentActivity.onExportNarrativesTaskProgressUpdate(taskIds[i][0]);
                    } else if (taskIds[i][1] == 1 && !mParentActivity.isFinishing()) { // 1 == show dialog
                        mParentActivity.showDialog(
                                taskIds[i][0] == R.id.export_mov_task_complete ? R.id.dialog_mov_creator_in_progress
                                        : R.id.dialog_export_narrative_in_progress); // special dialog for mov
                    }
                }
            }
        }

        @Override
        protected void onPostExecute(Void unused) {
            mTasksCompleted = true;
            notifyActivityTaskCompleted();
        }

        private void setActivity(MediaPhoneActivity activity) {
            this.mParentActivity = activity;
            if (mTasksCompleted) {
                notifyActivityTaskCompleted();
            }
        }

        private void notifyActivityTaskCompleted() {
            if (mParentActivity != null) {
                mParentActivity.onAllExportNarrativesTasksCompleted();
            }
        }
    }

    private class ImmediateBackgroundRunnerTask extends AsyncTask<Runnable, Void, Void> {
        private Runnable backgroundTask = null;

        private ImmediateBackgroundRunnerTask(Runnable task) {
            backgroundTask = task;
        }

        @Override
        protected Void doInBackground(Runnable... tasks) {
            if (backgroundTask != null) {
                try {
                    backgroundTask.run();
                } catch (Throwable t) {
                    Log.d(DebugUtilities.getLogTag(this),
                            "Error running background task: " + t.getLocalizedMessage());
                }
            }
            return null;
        }
    }

    private class QueuedBackgroundRunnerTask extends AsyncTask<BackgroundRunnable, int[], Void> {

        private MediaPhoneActivity mParentActivity;
        private boolean mTasksCompleted;
        private List<BackgroundRunnable> mTasks;

        private QueuedBackgroundRunnerTask(MediaPhoneActivity activity) {
            mParentActivity = activity;
            mTasksCompleted = false;
            mTasks = Collections.synchronizedList(new ArrayList<BackgroundRunnable>());
        }

        private void addTask(BackgroundRunnable task) {
            mTasks.add(task);
        }

        private int getTasksSize() {
            return mTasks.size();
        }

        private List<BackgroundRunnable> getTasks() {
            return mTasks;
        }

        @Override
        protected Void doInBackground(BackgroundRunnable... tasks) {
            for (int i = 0, n = tasks.length; i < n; i++) {
                mTasks.add(tasks[i]);
            }

            while (mTasks.size() > 0) {
                BackgroundRunnable r = mTasks.remove(0);
                if (r != null) {
                    publishProgress(new int[] { r.getTaskId(), r.getShowDialog() ? 1 : 0, 0 });
                    try {
                        r.run();
                    } catch (Throwable t) {
                        Log.d(DebugUtilities.getLogTag(this),
                                "Error running background task: " + t.getLocalizedMessage());
                    }
                    publishProgress(new int[] { r.getTaskId(), r.getShowDialog() ? 1 : 0, 1 });
                }
            }

            return null;
        }

        @Override
        protected void onProgressUpdate(int[]... taskIds) {
            if (mParentActivity != null) {
                for (int i = 0, n = taskIds.length; i < n; i++) {
                    // bit of a hack to tell us when to show a dialog and when to report progress
                    if (taskIds[i][2] == 1) { // 1 == task complete
                        mParentActivity.onBackgroundTaskProgressUpdate(taskIds[i][0]);
                    } else if (taskIds[i][1] == 1 && !mParentActivity.isFinishing()) { // 1 == show dialog
                        mParentActivity.showDialog(R.id.dialog_background_runner_in_progress);
                    }
                }
            }
        }

        @Override
        protected void onPostExecute(Void unused) {
            mTasksCompleted = true;
            notifyActivityTaskCompleted();
        }

        private void setActivity(MediaPhoneActivity activity) {
            this.mParentActivity = activity;
            if (mTasksCompleted) {
                notifyActivityTaskCompleted();
            }
        }

        private void notifyActivityTaskCompleted() {
            if (mParentActivity != null) {
                mParentActivity.onAllBackgroundTasksCompleted();
            }
        }
    }

    public interface BackgroundRunnable extends Runnable {
        /**
         * @return The id of this task, for reference in onBackgroundTaskCompleted - this method will be queried both
         *         before and after execution; the value <b>after</b> the task is complete will be returned via
         *         onBackgroundTaskCompleted. Return 0 if no result is needed.
         */
        public abstract int getTaskId();

        /**
         * @return Whether the task should show a generic, un-cancellable progress dialog
         */
        public abstract boolean getShowDialog();
    }

    protected Runnable getMediaCleanupRunnable() {
        return new Runnable() {
            @Override
            public void run() {
                ContentResolver contentResolver = getContentResolver();

                // find narratives and templates marked as deleted
                ArrayList<String> deletedNarratives = NarrativesManager.findDeletedNarratives(contentResolver);
                ArrayList<String> deletedTemplates = NarrativesManager.findDeletedTemplates(contentResolver);
                deletedNarratives.addAll(deletedTemplates); // templates can be handled at the same time as narratives

                // find frames marked as deleted, and also frames whose parent narrative/template is marked as deleted
                ArrayList<String> deletedFrames = FramesManager.findDeletedFrames(contentResolver);
                for (String narrativeId : deletedNarratives) {
                    deletedFrames.addAll(FramesManager.findFrameIdsByParentId(contentResolver, narrativeId));
                }

                // find media marked as deleted, and also media whose parent frame is marked as deleted
                ArrayList<String> deletedMedia = MediaManager.findDeletedMedia(contentResolver);
                for (String frameId : deletedFrames) {
                    deletedMedia.addAll(MediaManager.findMediaIdsByParentId(contentResolver, frameId));
                }

                // delete the actual media items on disk and from the database
                int deletedMediaCount = 0;
                for (String mediaId : deletedMedia) {
                    final MediaItem mediaToDelete = MediaManager.findMediaByInternalId(contentResolver, mediaId);
                    if (mediaToDelete != null) {
                        final File fileToDelete = mediaToDelete.getFile();
                        if (fileToDelete != null && fileToDelete.exists()) {
                            if (fileToDelete.delete()) {
                                deletedMediaCount += 1;
                            }
                        }
                        MediaManager.deleteMediaFromBackgroundTask(contentResolver, mediaId);
                    }
                }

                // delete the actual frame items on disk and from the database
                int deletedFrameCount = 0;
                for (String frameId : deletedFrames) {
                    final FrameItem frameToDelete = FramesManager.findFrameByInternalId(contentResolver, frameId);
                    if (frameToDelete != null) {
                        final File directoryToDelete = frameToDelete.getStorageDirectory();
                        if (directoryToDelete != null && directoryToDelete.exists()) {
                            if (IOUtilities.deleteRecursive(directoryToDelete)) {
                                deletedFrameCount += 1;
                            }
                        }
                        FramesManager.deleteFrameFromBackgroundTask(contentResolver, frameId);
                    }
                }

                // finally, delete the narratives/templates themselves (must do separately)
                deletedNarratives.removeAll(deletedTemplates);
                for (String narrativeId : deletedNarratives) {
                    NarrativesManager.deleteNarrativeFromBackgroundTask(contentResolver, narrativeId);
                }
                for (String templateId : deletedTemplates) {
                    NarrativesManager.deleteTemplateFromBackgroundTask(contentResolver, templateId);
                }

                // report progress
                Log.i(DebugUtilities.getLogTag(this),
                        "Media cleanup: removed " + deletedNarratives.size() + "/" + deletedTemplates.size()
                                + " narratives/templates, " + deletedFrames.size() + " (" + deletedFrameCount
                                + ") frames, and " + deletedMedia.size() + " (" + deletedMediaCount
                                + ") media items");
            }
        };
    }

    protected BackgroundRunnable getFrameSplitterRunnable(final String currentMediaItemInternalId) {
        return new BackgroundRunnable() {
            @Override
            public int getTaskId() {
                return R.id.split_frame_task_complete;
            }

            @Override
            public boolean getShowDialog() {
                return true;
            }

            @Override
            public void run() {
                // get the current media item and its parent
                ContentResolver contentResolver = getContentResolver();
                Resources resources = getResources();
                MediaItem currentMediaItem = MediaManager.findMediaByInternalId(contentResolver,
                        currentMediaItemInternalId);
                FrameItem parentFrame = FramesManager.findFrameByInternalId(contentResolver,
                        currentMediaItem.getParentId());
                String parentFrameInternalId = parentFrame.getInternalId();
                ArrayList<MediaItem> frameComponents = MediaManager.findMediaByParentId(contentResolver,
                        parentFrameInternalId);

                // get all the frames in this narrative
                ArrayList<FrameItem> narrativeFrames = FramesManager.findFramesByParentId(contentResolver,
                        parentFrame.getParentId());

                // if the new frame is the first frame, we need to update the second frame's icon, as it's possible
                // that this task was launched by adding a frame at the start of the narrative
                if (narrativeFrames.size() >= 2) {
                    if (narrativeFrames.get(0).getInternalId().equals(currentMediaItemInternalId)) {
                        FramesManager.reloadFrameIcon(resources, contentResolver, narrativeFrames.get(1), true);
                    }
                }

                // insert new frame - increment necessary frames after the new frame's position
                boolean frameFound = false;
                int newFrameSequenceId = 0;
                int previousNarrativeSequenceId = 0;
                for (FrameItem frame : narrativeFrames) {
                    if (!frameFound && (parentFrameInternalId.equals(frame.getInternalId()))) {
                        frameFound = true;
                        newFrameSequenceId = frame.getNarrativeSequenceId();
                    }
                    if (frameFound) {
                        if (newFrameSequenceId <= frame.getNarrativeSequenceId()
                                || frame.getNarrativeSequenceId() <= previousNarrativeSequenceId) {

                            frame.setNarrativeSequenceId(frame.getNarrativeSequenceId() + 1);
                            FramesManager.updateFrame(contentResolver, frame);
                            previousNarrativeSequenceId = frame.getNarrativeSequenceId();
                        } else {
                            break;
                        }
                    }
                }

                // create a new frame and move all the old media to it
                FrameItem newFrame = new FrameItem(parentFrame.getParentId(), newFrameSequenceId);
                String newFrameInternalId = newFrame.getInternalId();
                for (MediaItem currentItem : frameComponents) {
                    // need to know where the existing file is stored before editing the database record
                    if (!currentMediaItemInternalId.equals(currentItem.getInternalId())) {
                        File tempMediaFile = currentItem.getFile();
                        currentItem.setParentId(newFrameInternalId);
                        tempMediaFile.renameTo(currentItem.getFile());
                        MediaManager.updateMedia(contentResolver, currentItem);
                    }
                }

                // the current media item is a special case - need to silently create a new copy
                String newMediaItemId = MediaPhoneProvider.getNewInternalId();
                File tempMediaFile = currentMediaItem.getFile();

                currentMediaItem.setParentId(newFrameInternalId);
                MediaManager.updateMedia(contentResolver, currentMediaItem);

                MediaManager.changeMediaId(contentResolver, currentMediaItemInternalId, newMediaItemId);
                File newMediaFile = MediaItem.getFile(newFrameInternalId, newMediaItemId,
                        currentMediaItem.getFileExtension());
                tempMediaFile.renameTo(newMediaFile);

                MediaManager.addMedia(contentResolver, new MediaItem(currentMediaItemInternalId,
                        parentFrameInternalId, currentMediaItem.getFileExtension(), currentMediaItem.getType()));

                // add the frame and regenerate the icon
                FramesManager.addFrame(resources, contentResolver, newFrame, true);
            }
        };
    }

    protected BackgroundRunnable getNarrativeTemplateRunnable(final String fromId, final String toId,
            final boolean toTemplate) {
        return new BackgroundRunnable() {
            @Override
            public int getTaskId() {
                return R.id.make_load_template_task_complete;
            }

            @Override
            public boolean getShowDialog() {
                return true;
            }

            @Override
            public void run() {
                ContentResolver contentResolver = getContentResolver();
                Resources resources = getResources();
                ArrayList<FrameItem> narrativeFrames = FramesManager.findFramesByParentId(contentResolver, fromId);

                final NarrativeItem newItem;
                if (toTemplate) {
                    newItem = new NarrativeItem(toId, NarrativesManager.getNextTemplateExternalId(contentResolver));
                    NarrativesManager.addTemplate(contentResolver, newItem);
                } else {
                    newItem = new NarrativeItem(toId,
                            NarrativesManager.getNextNarrativeExternalId(contentResolver));
                    NarrativesManager.addNarrative(contentResolver, newItem);
                }
                final long newCreationDate = newItem.getCreationDate();

                boolean updateFirstFrame = true;
                ArrayList<String> fromFiles = new ArrayList<String>();
                ArrayList<String> toFiles = new ArrayList<String>();
                for (FrameItem frame : narrativeFrames) {
                    final FrameItem newFrame = FrameItem.fromExisting(frame, MediaPhoneProvider.getNewInternalId(),
                            toId, newCreationDate);
                    final String newFrameId = newFrame.getInternalId();
                    if (MediaPhone.DIRECTORY_THUMBS != null) {
                        try {
                            IOUtilities.copyFile(new File(MediaPhone.DIRECTORY_THUMBS, frame.getCacheId()),
                                    new File(MediaPhone.DIRECTORY_THUMBS, newFrame.getCacheId()));
                        } catch (Throwable t) {
                            // thumbnails will be generated on first view
                        }
                    }

                    for (MediaItem media : MediaManager.findMediaByParentId(contentResolver,
                            frame.getInternalId())) {
                        final MediaItem newMedia = MediaItem.fromExisting(media,
                                MediaPhoneProvider.getNewInternalId(), newFrameId, newCreationDate);
                        MediaManager.addMedia(contentResolver, newMedia);
                        if (updateFirstFrame) {
                            // must always copy the first frame's media
                            try {
                                IOUtilities.copyFile(media.getFile(), newMedia.getFile());
                            } catch (IOException e) {
                                // TODO: error
                            }
                        } else {
                            // queue copying other media
                            fromFiles.add(media.getFile().getAbsolutePath());
                            try {
                                newMedia.getFile().createNewFile(); // add an empty file so that if they open the item
                                // before copying completes it won't get deleted
                            } catch (IOException e) {
                                // TODO: error
                            }
                            toFiles.add(newMedia.getFile().getAbsolutePath());
                        }
                    }
                    FramesManager.addFrame(resources, contentResolver, newFrame, updateFirstFrame);
                    updateFirstFrame = false;
                }

                if (fromFiles.size() == toFiles.size()) {
                    runImmediateBackgroundTask(getMediaCopierRunnable(fromFiles, toFiles));
                } else {
                    // TODO: error
                }
            }
        };
    }

    // to speed up template creation - duplicate media in a separate background task
    private BackgroundRunnable getMediaCopierRunnable(final ArrayList<String> fromFiles,
            final ArrayList<String> toFiles) {
        return new BackgroundRunnable() {
            @Override
            public int getTaskId() {
                return 0;
            }

            @Override
            public boolean getShowDialog() {
                return false;
            }

            @Override
            public void run() {
                for (int i = 0, n = fromFiles.size(); i < n; i++) {
                    try {
                        IOUtilities.copyFile(new File(fromFiles.get(i)), new File(toFiles.get(i)));
                    } catch (IOException e) {
                        // TODO: error
                    }
                }
                if (MediaPhone.DEBUG)
                    Log.d(DebugUtilities.getLogTag(this), "Finished copying " + fromFiles.size() + " media items");
            }
        };
    }

    protected BackgroundRunnable getFrameIconUpdaterRunnable(final String frameInternalId) {
        return new BackgroundRunnable() {
            @Override
            public int getTaskId() {
                return 0;
            }

            @Override
            public boolean getShowDialog() {
                return false;
            }

            @Override
            public void run() {
                // update the icon
                ContentResolver contentResolver = getContentResolver();
                FrameItem thisFrame = FramesManager.findFrameByInternalId(contentResolver, frameInternalId);
                if (thisFrame != null) { // if run from switchFrames then the existing frame could have been deleted
                    FramesManager.reloadFrameIcon(getResources(), contentResolver, thisFrame, true);
                }
            }
        };
    }

    protected BackgroundRunnable getMediaLibraryAdderRunnable(final String mediaPath,
            final String outputDirectoryType) {
        return new BackgroundRunnable() {
            @Override
            public int getTaskId() {
                return 0;
            }

            @Override
            public boolean getShowDialog() {
                return false;
            }

            @Override
            public void run() {
                if (IOUtilities.externalStorageIsWritable()) {
                    File outputDirectory = Environment.getExternalStoragePublicDirectory(outputDirectoryType);
                    try {
                        outputDirectory.mkdirs();
                        File mediaFile = new File(mediaPath);
                        // use current time as this happens at creation; newDatedFileName guarantees no collisions
                        File outputFile = IOUtilities.newDatedFileName(outputDirectory,
                                IOUtilities.getFileExtension(mediaFile.getName()));
                        IOUtilities.copyFile(mediaFile, outputFile);
                        MediaScannerConnection.scanFile(MediaPhoneActivity.this,
                                new String[] { outputFile.getAbsolutePath() }, null,
                                new MediaScannerConnection.OnScanCompletedListener() {
                                    @Override
                                    public void onScanCompleted(String path, Uri uri) {
                                        if (MediaPhone.DEBUG)
                                            Log.d(DebugUtilities.getLogTag(this), "MediaScanner imported " + path);
                                    }
                                });
                    } catch (IOException e) {
                        if (MediaPhone.DEBUG)
                            Log.d(DebugUtilities.getLogTag(this), "Unable to save media to " + outputDirectory);
                    }
                }
            }
        };
    }

    protected void loadScreenSizedImageInBackground(ImageView imageView, String imagePath,
            boolean forceReloadSameImage, boolean fadeIn) {
        // forceReloadSameImage is for, e.g., reloading image after rotation (normally this extra load would be ignored)
        if (cancelExistingTask(imagePath, imageView, forceReloadSameImage)) {
            final BitmapLoaderTask task = new BitmapLoaderTask(imageView, fadeIn);
            final BitmapLoaderHolder loaderTaskHolder = new BitmapLoaderHolder(task);
            imageView.setTag(loaderTaskHolder);
            task.execute(imagePath);
        }
    }

    private static boolean cancelExistingTask(String imagePath, ImageView imageView, boolean forceReload) {
        final BitmapLoaderTask bitmapLoaderTask = getBitmapLoaderTask(imageView);
        if (bitmapLoaderTask != null) {
            final String loadingImagePath = bitmapLoaderTask.mImagePath;
            if (imagePath != null && (forceReload || !imagePath.equals(loadingImagePath))) {
                bitmapLoaderTask.cancel(true); // cancel previous task for this ImageView
            } else {
                return false; // already loading the same image (or new path is null)
            }
        }
        return true; // no existing task, or we've cancelled a task
    }

    private static BitmapLoaderTask getBitmapLoaderTask(ImageView imageView) {
        if (imageView != null) {
            final Object loaderTaskHolder = imageView.getTag();
            if (loaderTaskHolder instanceof BitmapLoaderHolder) {
                final BitmapLoaderHolder asyncDrawable = (BitmapLoaderHolder) loaderTaskHolder;
                return asyncDrawable.getBitmapWorkerTask();
            }
        }
        return null;
    }

    private class BitmapLoaderTask extends AsyncTask<String, Void, Bitmap> {
        private final WeakReference<ImageView> mImageView; // WeakReference to allow garbage collection
        private boolean mFadeIn;

        public String mImagePath;

        public BitmapLoaderTask(ImageView imageView, boolean fadeIn) {
            mImageView = new WeakReference<ImageView>(imageView);
            mFadeIn = fadeIn;
        }

        @Override
        protected Bitmap doInBackground(String... params) {
            mImagePath = params[0];
            Point screenSize = UIUtilities.getScreenSize(getWindowManager());
            return BitmapUtilities.loadAndCreateScaledBitmap(mImagePath, screenSize.x, screenSize.y,
                    BitmapUtilities.ScalingLogic.FIT, true);
        }

        @Override
        protected void onPostExecute(Bitmap bitmap) {
            if (isCancelled()) {
                bitmap = null;
            }

            if (mImageView != null && bitmap != null) {
                final ImageView imageView = mImageView.get();
                final BitmapLoaderTask bitmapLoaderTask = getBitmapLoaderTask(imageView);
                if (this == bitmapLoaderTask && imageView != null) {
                    if (mFadeIn) {
                        final CrossFadeDrawable transition = new CrossFadeDrawable(Bitmap.createBitmap(1, 1,
                                ImageCacheUtilities.mBitmapFactoryOptions.inPreferredConfig), bitmap);
                        transition.setCallback(imageView);
                        transition.setCrossFadeEnabled(true);
                        transition.startTransition(MediaPhone.ANIMATION_FADE_TRANSITION_DURATION);
                        imageView.setImageDrawable(transition);
                    } else {
                        imageView.setImageBitmap(bitmap);
                    }
                }
            }
        }
    }

    private static class BitmapLoaderHolder {
        private final WeakReference<BitmapLoaderTask> bitmapWorkerTaskReference;

        public BitmapLoaderHolder(BitmapLoaderTask bitmapWorkerTask) {
            bitmapWorkerTaskReference = new WeakReference<BitmapLoaderTask>(bitmapWorkerTask);
        }

        public BitmapLoaderTask getBitmapWorkerTask() {
            return bitmapWorkerTaskReference.get();
        }
    }
}