Move delete support into base ActionHandler.

Introduce an "Addons" interface that describes all of the
    extra stuff we expose in our concrete Activities allowing
    us to write test doubles for our Activities.
Add new "ui" package for UI specific stuff like:
Introduce DialogController, isolating dialog business in
    a separate class (that can be easily replaced with a double)
    Move delete confirmation and snackbar-error reporting
    into this class.
Introduce a Messages class for building messages, but isolated
    from implementation details.
Add test for delete logic in ActionHandler (now that the
    code is unit-testable.)
Introduce new ClipStore interface to improve testability
    of code that depends on clip storage...but in this
    CL we don't yet cover any of that.

Decouple all ActionHandler impls from their
Change-Id: Ic1449e501c855cdb72bf7666f4b67b9a9e9c1b49
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java
index 65f324e..8b6812b 100644
--- a/src/com/android/documentsui/AbstractActionHandler.java
+++ b/src/com/android/documentsui/AbstractActionHandler.java
@@ -22,31 +22,35 @@
 import android.content.pm.ResolveInfo;
 import android.os.Parcelable;
 
+import com.android.documentsui.AbstractActionHandler.CommonAddons;
 import com.android.documentsui.base.BooleanConsumer;
+import com.android.documentsui.base.ConfirmationCallback;
+import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.DocumentStack;
 import com.android.documentsui.base.RootInfo;
 import com.android.documentsui.base.Shared;
 import com.android.documentsui.dirlist.DocumentDetails;
+import com.android.documentsui.dirlist.Model;
+import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.manager.LauncherActivity;
 import com.android.documentsui.sidebar.EjectRootTask;
 
+import java.util.List;
+
 /**
  * Provides support for specializing the actions (viewDocument etc.) to the host activity.
  */
-public abstract class AbstractActionHandler<T extends Activity> implements ActionHandler {
+public abstract class AbstractActionHandler<T extends Activity & CommonAddons>
+        implements ActionHandler {
 
     protected final T mActivity;
 
     public AbstractActionHandler(T activity) {
+        assert(activity != null);
         mActivity = activity;
     }
 
     @Override
-    public void openSettings(RootInfo root) {
-        throw new UnsupportedOperationException("Can't open settings.");
-    }
-
-    @Override
     public void ejectRoot(RootInfo root, BooleanConsumer listener) {
         new EjectRootTask(
                 mActivity.getContentResolver(),
@@ -56,21 +60,6 @@
     }
 
     @Override
-    public void openRoot(ResolveInfo app) {
-        throw new UnsupportedOperationException("Can't open an app.");
-    }
-
-    @Override
-    public void showAppDetails(ResolveInfo info) {
-        throw new UnsupportedOperationException("Can't show app details.");
-    }
-
-    @Override
-    public boolean dropOn(ClipData data, RootInfo root) {
-        throw new UnsupportedOperationException("Can't open an app.");
-    }
-
-    @Override
     public void openInNewWindow(DocumentStack path) {
         Metrics.logUserAction(mActivity, Metrics.USER_ACTION_NEW_WINDOW);
 
@@ -88,6 +77,26 @@
     }
 
     @Override
+    public void openSettings(RootInfo root) {
+        throw new UnsupportedOperationException("Can't open settings.");
+    }
+
+    @Override
+    public void openRoot(ResolveInfo app) {
+        throw new UnsupportedOperationException("Can't open an app.");
+    }
+
+    @Override
+    public void showAppDetails(ResolveInfo info) {
+        throw new UnsupportedOperationException("Can't show app details.");
+    }
+
+    @Override
+    public boolean dropOn(ClipData data, RootInfo root) {
+        throw new UnsupportedOperationException("Can't open an app.");
+    }
+
+    @Override
     public void pasteIntoFolder(RootInfo root) {
         throw new UnsupportedOperationException("Can't paste into folder.");
     }
@@ -101,4 +110,20 @@
     public boolean previewDocument(DocumentDetails doc) {
         throw new UnsupportedOperationException("Preview not supported!");
     }
+
+    @Override
+    public void deleteDocuments(Model model, Selection selection, ConfirmationCallback callback) {
+        throw new UnsupportedOperationException("Delete not supported!");
+    }
+
+    /**
+     * A class primarily for the support of isolating our tests
+     * from our concrete activity implementations.
+     */
+    public interface CommonAddons {
+       void onRootPicked(RootInfo root);
+       void onDocumentPicked(DocumentInfo doc, Model model);
+       // TODO: Move this to PickAddons.
+       void onDocumentsPicked(List<DocumentInfo> docs);
+    }
 }
diff --git a/src/com/android/documentsui/ActionHandler.java b/src/com/android/documentsui/ActionHandler.java
index dddd374..a654fa0 100644
--- a/src/com/android/documentsui/ActionHandler.java
+++ b/src/com/android/documentsui/ActionHandler.java
@@ -20,9 +20,12 @@
 import android.content.pm.ResolveInfo;
 
 import com.android.documentsui.base.BooleanConsumer;
+import com.android.documentsui.base.ConfirmationCallback;
 import com.android.documentsui.base.DocumentStack;
 import com.android.documentsui.base.RootInfo;
 import com.android.documentsui.dirlist.DocumentDetails;
+import com.android.documentsui.dirlist.Model;
+import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 
 public interface ActionHandler {
 
@@ -54,4 +57,6 @@
     boolean previewDocument(DocumentDetails doc);
 
     boolean openDocument(DocumentDetails doc);
+
+    void deleteDocuments(Model model, Selection selection, ConfirmationCallback callback);
 }
diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java
index 21570e4..b1dfd45 100644
--- a/src/com/android/documentsui/BaseActivity.java
+++ b/src/com/android/documentsui/BaseActivity.java
@@ -52,6 +52,7 @@
 import android.view.MenuItem;
 import android.view.View;
 
+import com.android.documentsui.AbstractActionHandler.CommonAddons;
 import com.android.documentsui.NavigationViewManager.Breadcrumb;
 import com.android.documentsui.SearchViewManager.SearchManagerListener;
 import com.android.documentsui.base.DocumentInfo;
@@ -70,11 +71,10 @@
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.roots.LoadRootTask;
 import com.android.documentsui.roots.RootsCache;
-import com.android.documentsui.services.FileOperationService;
-import com.android.documentsui.services.FileOperations;
 import com.android.documentsui.sidebar.RootsFragment;
 import com.android.documentsui.sorting.SortController;
 import com.android.documentsui.sorting.SortModel;
+import com.android.documentsui.ui.DialogController;
 
 import java.util.ArrayList;
 import java.util.Collection;
@@ -82,33 +82,8 @@
 import java.util.List;
 import java.util.concurrent.Executor;
 
-public abstract class BaseActivity extends Activity implements NavigationViewManager.Environment {
-
-    public final FileOperations.Callback fileOpCallback = (status, opType, docCount) -> {
-        if (status == FileOperations.Callback.STATUS_REJECTED) {
-            Snackbars.showPasteFailed(this);
-            return;
-        }
-
-        if (docCount == 0) {
-            // Nothing has been pasted, so there is no need to show a snackbar.
-            return;
-        }
-
-        switch (opType) {
-            case FileOperationService.OPERATION_MOVE:
-                Snackbars.showMove(this, docCount);
-                break;
-            case FileOperationService.OPERATION_COPY:
-                Snackbars.showCopy(this, docCount);
-                break;
-            case FileOperationService.OPERATION_DELETE:
-                // We don't show anything for deletion.
-                break;
-            default:
-                throw new UnsupportedOperationException("Unsupported Operation: " + opType);
-        }
-    };
+public abstract class BaseActivity
+        extends Activity implements CommonAddons, NavigationViewManager.Environment {
 
     private static final String BENCHMARK_TESTING_PACKAGE = "com.android.documentsui.appperftests";
 
@@ -136,6 +111,12 @@
     private boolean mNavDrawerHasFocus;
     private long mStartTime;
 
+    protected abstract void onTaskFinished(Uri... uris);
+    protected abstract void refreshDirectory(int anim);
+    /** Allows sub-classes to include information in a newly created State instance. */
+    protected abstract void includeState(State initialState);
+    protected abstract void onDirectoryCreated(DocumentInfo doc);
+
     /**
      * Provides Activity a means of injection into and specialization of
      * DirectoryFragment.
@@ -156,6 +137,12 @@
 
     /**
      * Provides Activity a means of injection into and specialization of
+     * DirectoryFragment.
+     */
+    public abstract DialogController getDialogController();
+
+    /**
+     * Provides Activity a means of injection into and specialization of
      * fragment actions.
      *
      * Args can be nullable when called from a context lacking them, such as RootsFragment.
@@ -163,15 +150,6 @@
     public abstract ActionHandler getActionHandler(
             @Nullable Model model, @Nullable MultiSelectManager selectionMgr);
 
-    public abstract void onDocumentPicked(DocumentInfo doc, Model model);
-    public abstract void onDocumentsPicked(List<DocumentInfo> docs);
-
-    protected abstract void onTaskFinished(Uri... uris);
-    protected abstract void refreshDirectory(int anim);
-    /** Allows sub-classes to include information in a newly created State instance. */
-    protected abstract void includeState(State initialState);
-    protected abstract void onDirectoryCreated(DocumentInfo doc);
-
     public BaseActivity(@LayoutRes int layoutId, String tag) {
         mLayoutId = layoutId;
         mTag = tag;
@@ -313,6 +291,7 @@
         mNavigator.revealRootsDrawer(open);
     }
 
+    @Override
     public void onRootPicked(RootInfo root) {
         // Clicking on the current root removes search
         mSearchManager.cancelSearch();
diff --git a/src/com/android/documentsui/CreateDirectoryFragment.java b/src/com/android/documentsui/CreateDirectoryFragment.java
index 486c617..66be663 100644
--- a/src/com/android/documentsui/CreateDirectoryFragment.java
+++ b/src/com/android/documentsui/CreateDirectoryFragment.java
@@ -45,6 +45,7 @@
 
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.Shared;
+import com.android.documentsui.ui.Snackbars;
 
 /**
  * Dialog to create a new directory.
diff --git a/src/com/android/documentsui/DocumentsApplication.java b/src/com/android/documentsui/DocumentsApplication.java
index a751539..b182905 100644
--- a/src/com/android/documentsui/DocumentsApplication.java
+++ b/src/com/android/documentsui/DocumentsApplication.java
@@ -28,8 +28,9 @@
 import android.os.RemoteException;
 import android.text.format.DateUtils;
 
-import com.android.documentsui.clipping.ClipStorage;
+import com.android.documentsui.clipping.ClipStore;
 import com.android.documentsui.clipping.DocumentClipper;
+import com.android.documentsui.clipping.ClipStorage;
 import com.android.documentsui.roots.RootsCache;
 
 public class DocumentsApplication extends Application {
@@ -38,7 +39,7 @@
     private RootsCache mRoots;
 
     private ThumbnailCache mThumbnailCache;
-    private ClipStorage mClipStorage;
+    private ClipStorage mClipStore;
     private DocumentClipper mClipper;
 
     public static RootsCache getRootsCache(Context context) {
@@ -65,8 +66,8 @@
         return ((DocumentsApplication) context.getApplicationContext()).mClipper;
     }
 
-    public static ClipStorage getClipStorage(Context context) {
-        return ((DocumentsApplication) context.getApplicationContext()).mClipStorage;
+    public static ClipStore getClipStore(Context context) {
+        return ((DocumentsApplication) context.getApplicationContext()).mClipStore;
     }
 
     @Override
@@ -81,10 +82,10 @@
 
         mThumbnailCache = new ThumbnailCache(memoryClassBytes / 4);
 
-        mClipStorage = new ClipStorage(
+        mClipStore = new ClipStorage(
                 ClipStorage.prepareStorage(getCacheDir()),
                 getSharedPreferences(ClipStorage.PREF_NAME, 0));
-        mClipper = new DocumentClipper(this, mClipStorage);
+        mClipper = new DocumentClipper(this, mClipStore);
 
         final IntentFilter packageFilter = new IntentFilter();
         packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
diff --git a/src/com/android/documentsui/base/ConfirmationCallback.java b/src/com/android/documentsui/base/ConfirmationCallback.java
new file mode 100644
index 0000000..a6ddb5d
--- /dev/null
+++ b/src/com/android/documentsui/base/ConfirmationCallback.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.base;
+
+import android.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * A functional interface that receives one of two results {@link #CONFIRM} or {@link #REJECT}.
+ */
+@FunctionalInterface
+public interface ConfirmationCallback {
+    @IntDef(value = {
+            CONFIRM,
+            REJECT
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface Result {}
+    public static final int CONFIRM = 0;
+    public static final int REJECT = 1;
+
+    void accept(@Result int code);
+}
diff --git a/src/com/android/documentsui/clipping/ClipStorage.java b/src/com/android/documentsui/clipping/ClipStorage.java
index 84e6adb..9da3d9a 100644
--- a/src/com/android/documentsui/clipping/ClipStorage.java
+++ b/src/com/android/documentsui/clipping/ClipStorage.java
@@ -49,7 +49,7 @@
  *          - [symlink] 1 > primary # copying to location X
  *          - [symlink] 2 > primary # copying to location Y
  */
-public final class ClipStorage {
+public final class ClipStorage implements ClipStore {
 
     public static final int NO_SELECTION_TAG = -1;
 
@@ -62,7 +62,7 @@
 
     private static final long STALENESS_THRESHOLD = TimeUnit.DAYS.toMillis(2);
 
-    private static final String NEXT_POS_TAG = "NextPosTag";
+    private static final String NEXT_AVAIL_SLOT = "NextAvailableSlot";
     private static final String PRIMARY_DATA_FILE_NAME = "primary";
 
     private static final byte[] LINE_SEPARATOR = System.lineSeparator().getBytes();
@@ -71,7 +71,7 @@
     private final SharedPreferences mPref;
 
     private final File[] mSlots = new File[NUM_OF_SLOTS];
-    private int mNextPos;
+    private int mNextSlot;
 
     /**
      * @param outDir see {@link #prepareStorage(File)}.
@@ -81,7 +81,7 @@
         mOutDir = outDir;
         mPref = pref;
 
-        mNextPos = mPref.getInt(NEXT_POS_TAG, 0);
+        mNextSlot = mPref.getInt(NEXT_AVAIL_SLOT, 0);
     }
 
     /**
@@ -97,32 +97,35 @@
      *     <li>Having more than {@link #NUM_OF_SLOTS} queued jumbo file operations, one or more clip
      *     file may be overwritten.</li>
      * </ul>
+     *
+     * Implementations should take caution to serialize access.
      */
+    @VisibleForTesting
     synchronized int claimStorageSlot() {
-        int curPos = mNextPos;
-        for (int i = 0; i < NUM_OF_SLOTS; ++i, curPos = (curPos + 1) % NUM_OF_SLOTS) {
-            createSlotFileObject(curPos);
+        int curSlot = mNextSlot;
+        for (int i = 0; i < NUM_OF_SLOTS; ++i, curSlot = (curSlot + 1) % NUM_OF_SLOTS) {
+            createSlotFileObject(curSlot);
 
-            if (!mSlots[curPos].exists()) {
+            if (!mSlots[curSlot].exists()) {
                 break;
             }
 
             // No file or only primary file exists, we deem it available.
-            if (mSlots[curPos].list().length <= 1) {
+            if (mSlots[curSlot].list().length <= 1) {
                 break;
             }
             // This slot doesn't seem available, but still need to check if it's a legacy of
             // service being killed or a service crash etc. If it's stale, it's available.
-            else if (checkStaleFiles(curPos)) {
+            else if (checkStaleFiles(curSlot)) {
                 break;
             }
         }
 
-        prepareSlot(curPos);
+        prepareSlot(curSlot);
 
-        mNextPos = (curPos + 1) % NUM_OF_SLOTS;
-        mPref.edit().putInt(NEXT_POS_TAG, mNextPos).commit();
-        return curPos;
+        mNextSlot = (curSlot + 1) % NUM_OF_SLOTS;
+        mPref.edit().putInt(NEXT_AVAIL_SLOT, mNextSlot).commit();
+        return curSlot;
     }
 
     private boolean checkStaleFiles(int pos) {
@@ -144,25 +147,19 @@
     /**
      * Returns a writer. Callers must close the writer when finished.
      */
-    private Writer createWriter(int tag) throws IOException {
-        File file = toSlotDataFile(tag);
+    private Writer createWriter(int slot) throws IOException {
+        File file = toSlotDataFile(slot);
         return new Writer(file);
     }
 
-    /**
-     * Gets a {@link File} instance given a tag.
-     *
-     * This method creates a symbolic link in the slot folder to the data file as a reference
-     * counting method. When someone is done using this symlink, it's responsible to delete it.
-     * Therefore we can have a neat way to track how many things are still using this slot.
-     */
-    public synchronized File getFile(int tag) throws IOException {
-        createSlotFileObject(tag);
+    @Override
+    public synchronized File getFile(int slot) throws IOException {
+        createSlotFileObject(slot);
 
-        File primary = toSlotDataFile(tag);
+        File primary = toSlotDataFile(slot);
 
-        String linkFileName = Integer.toString(mSlots[tag].list().length);
-        File link = new File(mSlots[tag], linkFileName);
+        String linkFileName = Integer.toString(mSlots[slot].list().length);
+        File link = new File(mSlots[slot], linkFileName);
 
         try {
             Os.symlink(primary.getAbsolutePath(), link.getAbsolutePath());
@@ -172,10 +169,8 @@
         return link;
     }
 
-    /**
-     * Returns a Reader. Callers must close the reader when finished.
-     */
-    ClipStorageReader createReader(File file) throws IOException {
+    @Override
+    public ClipStorageReader createReader(File file) throws IOException {
         assert(file.getParentFile().getParentFile().equals(mOutDir));
         return new ClipStorageReader(file);
     }
@@ -206,7 +201,7 @@
         return new File(cacheDir, "clippings");
     }
 
-    private static final class Writer implements Closeable {
+    public static final class Writer implements Closeable {
 
         private final FileOutputStream mOut;
         private final FileLock mLock;
@@ -238,24 +233,36 @@
         }
     }
 
+    @Override
+    public int persistUris(Iterable<Uri> uris) {
+        int slot = claimStorageSlot();
+        persistUris(uris, slot);
+        return slot;
+    }
+
+    @VisibleForTesting
+    void persistUris(Iterable<Uri> uris, int slot) {
+        new PersistTask(this, uris, slot).execute();
+    }
+
     /**
      * An {@link AsyncTask} that persists doc uris in {@link ClipStorage}.
      */
-    static final class PersistTask extends AsyncTask<Void, Void, Void> {
+    private static final class PersistTask extends AsyncTask<Void, Void, Void> {
 
-        private final ClipStorage mClipStorage;
+        private final ClipStorage mClipStore;
         private final Iterable<Uri> mUris;
-        private final int mTag;
+        private final int mSlot;
 
-        PersistTask(ClipStorage clipStorage, Iterable<Uri> uris, int tag) {
-            mClipStorage = clipStorage;
+        PersistTask(ClipStorage clipStore, Iterable<Uri> uris, int slot) {
+            mClipStore = clipStore;
             mUris = uris;
-            mTag = tag;
+            mSlot = slot;
         }
 
         @Override
         protected Void doInBackground(Void... params) {
-            try(Writer writer = mClipStorage.createWriter(mTag)){
+            try(Writer writer = mClipStore.createWriter(mSlot)){
                 for (Uri uri: mUris) {
                     assert(uri != null);
                     writer.write(uri);
diff --git a/src/com/android/documentsui/clipping/ClipStore.java b/src/com/android/documentsui/clipping/ClipStore.java
new file mode 100644
index 0000000..31f240c
--- /dev/null
+++ b/src/com/android/documentsui/clipping/ClipStore.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.clipping;
+
+import android.net.Uri;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Interface for clip data URI storage.
+ */
+public interface ClipStore {
+
+    /**
+     * Gets a {@link File} instance given a tag.
+     *
+     * This method creates a symbolic link in the slot folder to the data file as a reference
+     * counting method. When someone is done using this symlink, it's responsible to delete it.
+     * Therefore we can have a neat way to track how many things are still using this slot.
+     */
+    File getFile(int tag) throws IOException;
+
+    /**
+     * Returns a Reader. Callers must close the reader when finished.
+     */
+    ClipStorageReader createReader(File file) throws IOException;
+
+    /**
+     * Writes the uris to the next available slot, returning the tag for that slot.
+     *
+     * @return int the tag used to store the URIs.
+     */
+    int persistUris(Iterable<Uri> uris);
+}
diff --git a/src/com/android/documentsui/clipping/DocumentClipper.java b/src/com/android/documentsui/clipping/DocumentClipper.java
index fa96199..fb453bc 100644
--- a/src/com/android/documentsui/clipping/DocumentClipper.java
+++ b/src/com/android/documentsui/clipping/DocumentClipper.java
@@ -58,12 +58,12 @@
     static final String OP_JUMBO_SELECTION_TAG = "jumboSelection-tag";
 
     private final Context mContext;
-    private final ClipStorage mClipStorage;
+    private final ClipStore mClipStore;
     private final ClipboardManager mClipboard;
 
-    public DocumentClipper(Context context, ClipStorage storage) {
+    public DocumentClipper(Context context, ClipStore clipStore) {
         mContext = context;
-        mClipStorage = storage;
+        mClipStore = clipStore;
         mClipboard = context.getSystemService(ClipboardManager.class);
     }
 
@@ -173,8 +173,8 @@
         bundle.putInt(OP_TYPE_KEY, opType);
         bundle.putInt(OP_JUMBO_SELECTION_SIZE, selection.size());
 
-        // Creates a clip tag
-        int tag = mClipStorage.claimStorageSlot();
+        // Persists clip items and gets the slot they were saved under.
+        int tag = mClipStore.persistUris(uris);
         bundle.putInt(OP_JUMBO_SELECTION_TAG, tag);
 
         ClipDescription description = new ClipDescription(
@@ -182,9 +182,6 @@
                 clipTypes.toArray(new String[0]));
         description.setExtras(bundle);
 
-        // Persists clip items
-        new ClipStorage.PersistTask(mClipStorage, uris, tag).execute();
-
         return new ClipData(description, clipItems);
     }
 
@@ -287,7 +284,7 @@
                 return;
             }
 
-            UrisSupplier uris = UrisSupplier.create(clipData, mContext);
+            UrisSupplier uris = UrisSupplier.create(clipData, mClipStore);
             if (uris.getItemCount() == 0) {
                 callback.onOperationResult(
                         FileOperations.Callback.STATUS_ACCEPTED, opType, 0);
diff --git a/src/com/android/documentsui/clipping/UrisSupplier.java b/src/com/android/documentsui/clipping/UrisSupplier.java
index 152f968..f499653 100644
--- a/src/com/android/documentsui/clipping/UrisSupplier.java
+++ b/src/com/android/documentsui/clipping/UrisSupplier.java
@@ -58,11 +58,11 @@
      * @param context We need context to obtain {@link ClipStorage}. It can't be sent in a parcel.
      */
     public Iterable<Uri> getUris(Context context) throws IOException {
-        return getUris(DocumentsApplication.getClipStorage(context));
+        return getUris(DocumentsApplication.getClipStore(context));
     }
 
     @VisibleForTesting
-    abstract Iterable<Uri> getUris(ClipStorage storage) throws IOException;
+    abstract Iterable<Uri> getUris(ClipStore storage) throws IOException;
 
     public void dispose() {}
 
@@ -71,11 +71,11 @@
         return 0;
     }
 
-    public static UrisSupplier create(ClipData clipData, Context context) throws IOException {
+    public static UrisSupplier create(ClipData clipData, ClipStore storage) throws IOException {
         UrisSupplier uris;
         PersistableBundle bundle = clipData.getDescription().getExtras();
         if (bundle.containsKey(OP_JUMBO_SELECTION_TAG)) {
-            uris = new JumboUrisSupplier(clipData, context);
+            uris = new JumboUrisSupplier(clipData, storage);
         } else {
             uris = new StandardUrisSupplier(clipData);
         }
@@ -84,11 +84,9 @@
     }
 
     public static UrisSupplier create(
-            Selection selection, Function<String, Uri> uriBuilder, Context context)
+            Selection selection, Function<String, Uri> uriBuilder, ClipStore storage)
             throws IOException {
 
-        ClipStorage storage = DocumentsApplication.getClipStorage(context);
-
         List<Uri> uris = new ArrayList<>(selection.size());
         for (String id : selection) {
             uris.add(uriBuilder.apply(id));
@@ -98,7 +96,7 @@
     }
 
     @VisibleForTesting
-    static UrisSupplier create(List<Uri> uris, ClipStorage storage) throws IOException {
+    static UrisSupplier create(List<Uri> uris, ClipStore storage) throws IOException {
         UrisSupplier urisSupplier = (uris.size() > Shared.MAX_DOCS_IN_INTENT)
                 ? new JumboUrisSupplier(uris, storage)
                 : new StandardUrisSupplier(uris);
@@ -115,26 +113,25 @@
         private final transient AtomicReference<ClipStorageReader> mReader =
                 new AtomicReference<>();
 
-        private JumboUrisSupplier(ClipData clipData, Context context) throws IOException {
+        private JumboUrisSupplier(ClipData clipData, ClipStore storage) throws IOException {
             PersistableBundle bundle = clipData.getDescription().getExtras();
             final int tag = bundle.getInt(OP_JUMBO_SELECTION_TAG, ClipStorage.NO_SELECTION_TAG);
             assert(tag != ClipStorage.NO_SELECTION_TAG);
-            mFile = DocumentsApplication.getClipStorage(context).getFile(tag);
+            mFile = storage.getFile(tag);
             assert(mFile.exists());
 
             mSelectionSize = bundle.getInt(OP_JUMBO_SELECTION_SIZE);
             assert(mSelectionSize > Shared.MAX_DOCS_IN_INTENT);
         }
 
-        private JumboUrisSupplier(Collection<Uri> uris, ClipStorage storage) throws IOException {
-            final int tag = storage.claimStorageSlot();
-            new ClipStorage.PersistTask(storage, uris, tag).execute();
+        private JumboUrisSupplier(Collection<Uri> uris, ClipStore clipStore) throws IOException {
+            final int tag = clipStore.persistUris(uris);
 
             // There is a tiny race condition here. A job may starts to read before persist task
             // starts to write, but it has to beat an IPC and background task schedule, which is
             // pretty rare. Creating a symlink doesn't need that file to exist, but we can't assert
             // on its existence.
-            mFile = storage.getFile(tag);
+            mFile = clipStore.getFile(tag);
             mSelectionSize = uris.size();
         }
 
@@ -144,7 +141,7 @@
         }
 
         @Override
-        Iterable<Uri> getUris(ClipStorage storage) throws IOException {
+        Iterable<Uri> getUris(ClipStore storage) throws IOException {
             ClipStorageReader reader = mReader.getAndSet(storage.createReader(mFile));
             if (reader != null) {
                 reader.close();
@@ -243,7 +240,7 @@
         }
 
         @Override
-        Iterable<Uri> getUris(ClipStorage storage) {
+        Iterable<Uri> getUris(ClipStore storage) {
             return mDocs;
         }
 
diff --git a/src/com/android/documentsui/dirlist/ActionModeController.java b/src/com/android/documentsui/dirlist/ActionModeController.java
index 4756e3c..4e89cb1 100644
--- a/src/com/android/documentsui/dirlist/ActionModeController.java
+++ b/src/com/android/documentsui/dirlist/ActionModeController.java
@@ -32,6 +32,8 @@
 
 import com.android.documentsui.MenuManager;
 import com.android.documentsui.R;
+import com.android.documentsui.base.ConfirmationCallback;
+import com.android.documentsui.base.ConfirmationCallback.Result;
 import com.android.documentsui.base.EventHandler;
 import com.android.documentsui.base.Menus;
 import com.android.documentsui.base.Shared;
@@ -44,7 +46,7 @@
 /**
  * A controller that listens to selection changes and manages life cycles of action modes.
  */
-class ActionModeController implements MultiSelectManager.Callback, ActionMode.Callback {
+public class ActionModeController implements MultiSelectManager.Callback, ActionMode.Callback {
 
     private static final String TAG = "ActionModeController";
 
@@ -234,4 +236,10 @@
     private interface AccessibilityImportanceSetter {
         void setAccessibilityImportance(int accessibilityImportance, @IdRes int... viewIds);
     }
+
+    public void finishOnConfirmed(@Result int code) {
+        if (code == ConfirmationCallback.CONFIRM) {
+            finishActionMode();
+        }
+    }
 }
diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 6b86e5f..234b86d 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -26,14 +26,12 @@
 import android.annotation.StringRes;
 import android.app.Activity;
 import android.app.ActivityManager;
-import android.app.AlertDialog;
 import android.app.Fragment;
 import android.app.FragmentManager;
 import android.app.FragmentTransaction;
 import android.app.LoaderManager.LoaderCallbacks;
 import android.content.ClipData;
 import android.content.Context;
-import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.Loader;
 import android.database.Cursor;
@@ -49,7 +47,6 @@
 import android.support.v7.widget.RecyclerView;
 import android.support.v7.widget.RecyclerView.RecyclerListener;
 import android.support.v7.widget.RecyclerView.ViewHolder;
-import android.text.BidiFormatter;
 import android.util.Log;
 import android.util.SparseArray;
 import android.view.ContextMenu;
@@ -76,7 +73,6 @@
 import com.android.documentsui.Metrics;
 import com.android.documentsui.R;
 import com.android.documentsui.RecentsLoader;
-import com.android.documentsui.Snackbars;
 import com.android.documentsui.ThumbnailCache;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.DocumentStack;
@@ -88,6 +84,7 @@
 import com.android.documentsui.base.Shared;
 import com.android.documentsui.base.State;
 import com.android.documentsui.base.State.ViewMode;
+import com.android.documentsui.clipping.ClipStore;
 import com.android.documentsui.clipping.DocumentClipper;
 import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
@@ -100,6 +97,8 @@
 import com.android.documentsui.sorting.SortDimension;
 import com.android.documentsui.sorting.SortDimension.SortDirection;
 import com.android.documentsui.sorting.SortModel;
+import com.android.documentsui.ui.DialogController;
+import com.android.documentsui.ui.Snackbars;
 
 import java.io.IOException;
 import java.lang.annotation.Retention;
@@ -148,11 +147,14 @@
     private FocusManager mFocusManager;
 
     // This dependency is informally "injected" from the owning Activity in our onCreate method.
-    private ActionHandler mActionHandler;
+    private ActionHandler mActions;
 
     // This dependency is informally "injected" from the owning Activity in our onCreate method.
     private MenuManager mMenuManager;
 
+    // This dependency is informally "injected" from the owning Activity in our onCreate method.
+    private DialogController mDialogs;
+
     private MultiSelectManager mSelectionMgr;
     private ActionModeController mActionModeController;
     private SelectionMetadata mSelectionMetadata;
@@ -304,8 +306,9 @@
         final BaseActivity activity = getBaseActivity();
         mTuner = activity.getFragmentTuner(mModel, mConfig.mSearchMode);
         mFocusManager = activity.getFocusManager(mRecView, mModel);
-        mActionHandler = activity.getActionHandler(mModel, mSelectionMgr);
+        mActions = activity.getActionHandler(mModel, mSelectionMgr);
         mMenuManager = activity.getMenuManager();
+        mDialogs = activity.getDialogController();
 
         if (state.allowMultiple) {
             mBandController = new BandController(mRecView, mAdapter, mSelectionMgr);
@@ -328,7 +331,7 @@
                 ? gestureSel::start
                 : EventHandler.createStub(false);
         mInputHandler = new UserInputHandler<>(
-                mActionHandler,
+                mActions,
                 mFocusManager,
                 mSelectionMgr,
                 (MotionEvent t) -> MotionInputEvent.obtain(t, mRecView),
@@ -458,8 +461,7 @@
 
         operation.setDestination(data.getParcelableExtra(Shared.EXTRA_STACK));
 
-        BaseActivity activity = getBaseActivity();
-        FileOperations.start(activity, operation, activity.fileOpCallback);
+        FileOperations.start(getBaseActivity(), operation, mDialogs::showFileOperationFailures);
     }
 
     protected boolean onRightClick(InputEvent e) {
@@ -580,7 +582,8 @@
             case R.id.menu_delete:
                 // deleteDocuments will end action mode if the documents are deleted.
                 // It won't end action mode if user cancels the delete.
-                deleteDocuments(selection);
+                mActions.deleteDocuments(
+                        mModel, selection, mActionModeController::finishOnConfirmed);
                 return true;
 
             case R.id.menu_copy_to:
@@ -679,7 +682,7 @@
         DocumentInfo doc =
                 DocumentInfo.fromDirectoryCursor(mModel.getItem(selected.iterator().next()));
         assert(doc != null);
-        mActionHandler.openInNewWindow(new DocumentStack(getDisplayState().stack, doc));
+        mActions.openInNewWindow(new DocumentStack(getDisplayState().stack, doc));
     }
 
     private void shareDocuments(final Selection selected) {
@@ -730,108 +733,15 @@
         startActivity(intent);
     }
 
-    private String generateDeleteMessage(final List<DocumentInfo> docs) {
-        String message;
-        int dirsCount = 0;
-
-        for (DocumentInfo doc : docs) {
-            if (doc.isDirectory()) {
-                ++dirsCount;
-            }
-        }
-
-        if (docs.size() == 1) {
-            // Deleteing 1 file xor 1 folder in cwd
-
-            // Address b/28772371, where including user strings in message can result in
-            // broken bidirectional support.
-            String displayName = BidiFormatter.getInstance().unicodeWrap(docs.get(0).displayName);
-            message = dirsCount == 0
-                    ? getActivity().getString(R.string.delete_filename_confirmation_message,
-                            displayName)
-                    : getActivity().getString(R.string.delete_foldername_confirmation_message,
-                            displayName);
-        } else if (dirsCount == 0) {
-            // Deleting only files in cwd
-            message = Shared.getQuantityString(getActivity(),
-                    R.plurals.delete_files_confirmation_message, docs.size());
-        } else if (dirsCount == docs.size()) {
-            // Deleting only folders in cwd
-            message = Shared.getQuantityString(getActivity(),
-                    R.plurals.delete_folders_confirmation_message, docs.size());
-        } else {
-            // Deleting mixed items (files and folders) in cwd
-            message = Shared.getQuantityString(getActivity(),
-                    R.plurals.delete_items_confirmation_message, docs.size());
-        }
-        return message;
-    }
-
     private boolean onDeleteSelectedDocuments() {
         if (mSelectionMgr.hasSelection()) {
-            deleteDocuments(mSelectionMgr.getSelection(new Selection()));
+            Selection selection = mSelectionMgr.getSelection(new Selection());
+            mActions.deleteDocuments(
+                    mModel, selection, mActionModeController::finishOnConfirmed);
         }
         return false;
     }
 
-    private void deleteDocuments(final Selection selected) {
-        Metrics.logUserAction(getContext(), Metrics.USER_ACTION_DELETE);
-
-        assert(!selected.isEmpty());
-
-        final DocumentInfo srcParent = getDisplayState().stack.peek();
-
-        // Model must be accessed in UI thread, since underlying cursor is not threadsafe.
-        List<DocumentInfo> docs = mModel.getDocuments(selected);
-
-        TextView message =
-                (TextView) mInflater.inflate(R.layout.dialog_delete_confirmation, null);
-        message.setText(generateDeleteMessage(docs));
-
-        // For now, we implement this dialog NOT
-        // as a fragment (which can survive rotation and have its own state),
-        // but as a simple runtime dialog. So rotating a device with an
-        // active delete dialog...results in that dialog disappearing.
-        // We can do better, but don't have cycles for it now.
-        new AlertDialog.Builder(getActivity())
-            .setView(message)
-            .setPositiveButton(
-                 android.R.string.ok,
-                 new DialogInterface.OnClickListener() {
-                    @Override
-                    public void onClick(DialogInterface dialog, int id) {
-                        // Finish selection mode first which clears selection so we
-                        // don't end up trying to deselect deleted documents.
-                        // This is done here, rather in the onActionItemClicked
-                        // so we can avoid de-selecting items in the case where
-                        // the user cancels the delete.
-                        mActionModeController.finishActionMode();
-
-                        UrisSupplier srcs;
-                        try {
-                            srcs = UrisSupplier.create(
-                                    selected,
-                                    mModel::getItemUri,
-                                    getContext());
-                        } catch(IOException e) {
-                            throw new RuntimeException("Failed to create uri supplier.", e);
-                        }
-
-                        FileOperation operation = new FileOperation.Builder()
-                                .withOpType(FileOperationService.OPERATION_DELETE)
-                                .withDestination(getDisplayState().stack)
-                                .withSrcs(srcs)
-                                .withSrcParent(srcParent.derivedUri)
-                                .build();
-
-                        BaseActivity activity = getBaseActivity();
-                        FileOperations.start(activity, operation, activity.fileOpCallback);
-                    }
-                })
-            .setNegativeButton(android.R.string.cancel, null)
-            .show();
-    }
-
     private void transferDocuments(final Selection selected, final @OpType int mode) {
         if(mode == FileOperationService.OPERATION_COPY) {
             Metrics.logUserAction(getContext(), Metrics.USER_ACTION_COPY_TO);
@@ -849,7 +759,8 @@
 
         UrisSupplier srcs;
         try {
-            srcs = UrisSupplier.create(selected, mModel::getItemUri, getContext());
+            ClipStore clipStorage = DocumentsApplication.getClipStore(getContext());
+            srcs = UrisSupplier.create(selected, mModel::getItemUri, clipStorage);
         } catch(IOException e) {
             throw new RuntimeException("Failed to create uri supplier.", e);
         }
@@ -1031,7 +942,7 @@
         BaseActivity activity = (BaseActivity) getActivity();
         DocumentInfo destination = activity.getCurrentDirectory();
         mClipper.copyFromClipboard(
-                destination, activity.getDisplayState().stack, activity.fileOpCallback);
+                destination, activity.getDisplayState().stack, mDialogs::showFileOperationFailures);
         getActivity().invalidateOptionsMenu();
     }
 
@@ -1047,7 +958,7 @@
         BaseActivity activity = getBaseActivity();
         DocumentInfo destination = DocumentInfo.fromDirectoryCursor(dstCursor);
         mClipper.copyFromClipboard(
-                destination, activity.getDisplayState().stack, activity.fileOpCallback);
+                destination, activity.getDisplayState().stack, mDialogs::showFileOperationFailures);
         getActivity().invalidateOptionsMenu();
     }
 
@@ -1152,7 +1063,8 @@
                         : Metrics.USER_ACTION_DRAG_N_DROP);
 
         DocumentInfo dst = getDestination(v);
-        mClipper.copyFromClipData(dst, getDisplayState().stack, clipData, activity.fileOpCallback);
+        mClipper.copyFromClipData(
+                dst, getDisplayState().stack, clipData, mDialogs::showFileOperationFailures);
         return true;
     }
 
diff --git a/src/com/android/documentsui/dirlist/Model.java b/src/com/android/documentsui/dirlist/Model.java
index 996fce0..7973101 100644
--- a/src/com/android/documentsui/dirlist/Model.java
+++ b/src/com/android/documentsui/dirlist/Model.java
@@ -440,7 +440,7 @@
         return mIsLoading;
     }
 
-    List<DocumentInfo> getDocuments(Selection selection) {
+    public List<DocumentInfo> getDocuments(Selection selection) {
         final int size = (selection != null) ? selection.size() : 0;
 
         final List<DocumentInfo> docs =  new ArrayList<>(size);
diff --git a/src/com/android/documentsui/dirlist/MultiSelectManager.java b/src/com/android/documentsui/dirlist/MultiSelectManager.java
index 9c3aedf..168134a 100644
--- a/src/com/android/documentsui/dirlist/MultiSelectManager.java
+++ b/src/com/android/documentsui/dirlist/MultiSelectManager.java
@@ -635,8 +635,8 @@
         private final Set<String> mProvisionalSelection;
 
         public Selection() {
-            mSelection = new HashSet<String>();
-            mProvisionalSelection = new HashSet<String>();
+            mSelection = new HashSet<>();
+            mProvisionalSelection = new HashSet<>();
         }
 
         /**
@@ -644,7 +644,7 @@
          */
         private Selection(Set<String> selection) {
             mSelection = selection;
-            mProvisionalSelection = new HashSet<String>();
+            mProvisionalSelection = new HashSet<>();
         }
 
         /**
@@ -751,7 +751,7 @@
 
         /** @hide */
         @VisibleForTesting
-        boolean add(String id) {
+        public boolean add(String id) {
             if (!mSelection.contains(id)) {
                 mSelection.add(id);
                 return true;
@@ -849,7 +849,7 @@
                 ArrayList<String> selected = new ArrayList<>();
                 in.readStringList(selected);
 
-                return new Selection(new HashSet<String>(selected));
+                return new Selection(new HashSet<>(selected));
             }
 
             @Override
diff --git a/src/com/android/documentsui/dirlist/RenameDocumentFragment.java b/src/com/android/documentsui/dirlist/RenameDocumentFragment.java
index b129016..0af937e 100644
--- a/src/com/android/documentsui/dirlist/RenameDocumentFragment.java
+++ b/src/com/android/documentsui/dirlist/RenameDocumentFragment.java
@@ -46,9 +46,9 @@
 import com.android.documentsui.DocumentsApplication;
 import com.android.documentsui.Metrics;
 import com.android.documentsui.R;
-import com.android.documentsui.Snackbars;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.Shared;
+import com.android.documentsui.ui.Snackbars;
 
 /**
  * Dialog to rename file or directory.
diff --git a/src/com/android/documentsui/manager/ActionHandler.java b/src/com/android/documentsui/manager/ActionHandler.java
index c6022f2..5cd1a61 100644
--- a/src/com/android/documentsui/manager/ActionHandler.java
+++ b/src/com/android/documentsui/manager/ActionHandler.java
@@ -16,6 +16,7 @@
 
 package com.android.documentsui.manager;
 
+import android.app.Activity;
 import android.content.ClipData;
 import android.content.Intent;
 import android.provider.DocumentsContract;
@@ -26,33 +27,61 @@
 import com.android.documentsui.GetRootDocumentTask;
 import com.android.documentsui.Metrics;
 import com.android.documentsui.ProviderExecutor;
+import com.android.documentsui.base.ConfirmationCallback;
+import com.android.documentsui.base.ConfirmationCallback.Result;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.DocumentStack;
 import com.android.documentsui.base.RootInfo;
+import com.android.documentsui.base.State;
+import com.android.documentsui.clipping.ClipStore;
 import com.android.documentsui.clipping.DocumentClipper;
+import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.dirlist.DocumentDetails;
 import com.android.documentsui.dirlist.FragmentTuner;
 import com.android.documentsui.dirlist.Model;
 import com.android.documentsui.dirlist.MultiSelectManager;
+import com.android.documentsui.dirlist.MultiSelectManager.Selection;
+import com.android.documentsui.manager.ActionHandler.Addons;
+import com.android.documentsui.services.FileOperation;
+import com.android.documentsui.services.FileOperationService;
+import com.android.documentsui.services.FileOperations;
+import com.android.documentsui.ui.DialogController;
+
+import java.io.IOException;
+import java.util.List;
 
 import javax.annotation.Nullable;
 
 /**
  * Provides {@link ManageActivity} action specializations to fragments.
  */
-public class ActionHandler extends AbstractActionHandler<ManageActivity> {
+public class ActionHandler<T extends Activity & Addons> extends AbstractActionHandler<T> {
 
     private static final String TAG = "ManagerActionHandler";
 
+    private final DialogController mDialogs;
+    private final State mState;
     private final FragmentTuner mTuner;
     private final DocumentClipper mClipper;
+    private final ClipStore mClipStore;
+
     private final Config mConfig;
 
-
-    ActionHandler(ManageActivity activity, FragmentTuner tuner, DocumentClipper clipper) {
+    ActionHandler(
+            T activity,
+            DialogController dialogs,
+            State state,
+            FragmentTuner tuner,
+            DocumentClipper clipper,
+            ClipStore clipStore) {
         super(activity);
+
+        mDialogs = dialogs;
+        mState = state;
         mTuner = tuner;
         mClipper = clipper;
+        mClipStore = clipStore;
+
         mConfig = new Config();
     }
 
@@ -63,7 +92,7 @@
                 mActivity,
                 mActivity::isDestroyed,
                 (DocumentInfo doc) -> mClipper.copyFromClipData(
-                        root, doc, data, mActivity.fileOpCallback)
+                        root, doc, data, mDialogs::showFileOperationFailures)
         ).executeOnExecutor(ProviderExecutor.forAuthority(root.authority));
         return true;
     }
@@ -89,7 +118,7 @@
     private void pasteIntoFolder(RootInfo root, DocumentInfo doc) {
         DocumentClipper clipper = DocumentsApplication.getDocumentClipper(mActivity);
         DocumentStack stack = new DocumentStack(root, doc);
-        clipper.copyFromClipboard(doc, stack, mActivity.fileOpCallback);
+        clipper.copyFromClipboard(doc, stack, mDialogs::showFileOperationFailures);
     }
 
     @Override
@@ -130,7 +159,49 @@
         return mActivity.previewDocument(doc, mConfig.model);
     }
 
-    ActionHandler reset(Model model, MultiSelectManager selectionMgr) {
+    @Override
+    public void deleteDocuments(Model model, Selection selected, ConfirmationCallback callback) {
+        Metrics.logUserAction(mActivity, Metrics.USER_ACTION_DELETE);
+
+        assert(!selected.isEmpty());
+
+        final DocumentInfo srcParent = mState.stack.peek();
+
+        // Model must be accessed in UI thread, since underlying cursor is not threadsafe.
+        List<DocumentInfo> docs = model.getDocuments(selected);
+
+        ConfirmationCallback result = (@Result int code) -> {
+            // share the news with our caller, be it good or bad.
+            callback.accept(code);
+
+            if (code != ConfirmationCallback.CONFIRM) {
+                return;
+            }
+
+            UrisSupplier srcs;
+            try {
+                srcs = UrisSupplier.create(
+                        selected,
+                        model::getItemUri,
+                        mClipStore);
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to create uri supplier.", e);
+            }
+
+            FileOperation operation = new FileOperation.Builder()
+                    .withOpType(FileOperationService.OPERATION_DELETE)
+                    .withDestination(mState.stack)
+                    .withSrcs(srcs)
+                    .withSrcParent(srcParent.derivedUri)
+                    .build();
+
+            FileOperations.start(mActivity, operation, mDialogs::showFileOperationFailures);
+        };
+
+        mDialogs.confirmDelete(docs, result);
+    }
+
+    ActionHandler<T> reset(Model model, MultiSelectManager selectionMgr) {
         mConfig.reset(model, selectionMgr);
         return this;
     }
@@ -148,4 +219,9 @@
             this.selectionMgr = selectionMgr;
         }
     }
+
+    public interface Addons extends CommonAddons {
+        boolean viewDocument(DocumentInfo doc);
+        boolean previewDocument(DocumentInfo doc, Model model);
+    }
 }
diff --git a/src/com/android/documentsui/manager/ManageActivity.java b/src/com/android/documentsui/manager/ManageActivity.java
index ff82869..e9ab04a 100644
--- a/src/com/android/documentsui/manager/ManageActivity.java
+++ b/src/com/android/documentsui/manager/ManageActivity.java
@@ -42,7 +42,6 @@
 import com.android.documentsui.OperationDialogFragment.DialogType;
 import com.android.documentsui.ProviderExecutor;
 import com.android.documentsui.R;
-import com.android.documentsui.Snackbars;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.DocumentStack;
 import com.android.documentsui.base.PairedTask;
@@ -58,6 +57,8 @@
 import com.android.documentsui.roots.RootsCache;
 import com.android.documentsui.services.FileOperationService;
 import com.android.documentsui.sidebar.RootsFragment;
+import com.android.documentsui.ui.DialogController;
+import com.android.documentsui.ui.Snackbars;
 
 import java.io.FileNotFoundException;
 import java.util.ArrayList;
@@ -68,14 +69,15 @@
 /**
  * Standalone file management activity.
  */
-public class ManageActivity extends BaseActivity {
+public class ManageActivity extends BaseActivity implements ActionHandler.Addons {
 
     public static final String TAG = "FilesActivity";
 
     private Tuner mTuner;
     private MenuManager mMenuManager;
     private FocusManager mFocusManager;
-    private ActionHandler mActionHandler;
+    private ActionHandler<ManageActivity> mActionHandler;
+    private DialogController mDialogs;
     private DocumentClipper mClipper;
 
     public ManageActivity() {
@@ -96,11 +98,18 @@
                         return mClipper.hasItemsToPaste();
                     }
                 });
+
         mTuner = new Tuner(this, mState);
         // Make sure this is done after the RecyclerView and the Model are set up.
         mFocusManager = new FocusManager(getColor(R.color.accent_dark));
-        mActionHandler = new ActionHandler(this, mTuner, mClipper);
-        mClipper = DocumentsApplication.getDocumentClipper(this);
+        mDialogs = DialogController.create(this);
+        mActionHandler = new ActionHandler<>(
+                this,
+                mDialogs,
+                mState,
+                mTuner,
+                mClipper,
+                DocumentsApplication.getClipStore(this));
 
         RootsFragment.show(getFragmentManager(), null);
 
@@ -326,7 +335,9 @@
         }
     }
 
-    boolean viewDocument(DocumentInfo doc) {
+    // TODO: Move to ActionHandler.
+    @Override
+    public boolean viewDocument(DocumentInfo doc) {
         if (doc.isPartial()) {
             Log.w(TAG, "Can't view partial file.");
             return false;
@@ -358,7 +369,9 @@
         return false;
     }
 
-    boolean previewDocument(DocumentInfo doc, Model model) {
+    // TODO: Move to ActionHandler.
+    @Override
+    public boolean previewDocument(DocumentInfo doc, Model model) {
         if (doc.isPartial()) {
             Log.w(TAG, "Can't view partial file.");
             return false;
@@ -514,7 +527,7 @@
     }
 
     @Override
-    public ActionHandler getActionHandler(
+    public ActionHandler<ManageActivity> getActionHandler(
             Model model, MultiSelectManager selectionMgr) {
 
         // provide our friend, RootsFragment, early access to this special feature!
@@ -526,6 +539,11 @@
         return mActionHandler.reset(model, selectionMgr);
     }
 
+    @Override
+    public DialogController getDialogController() {
+        return mDialogs;
+    }
+
     /**
      * Builds a stack for the specific Uris. Multi roots are not supported, as it's impossible
      * to know which root to select. Also, the stack doesn't contain intermediate directories.
diff --git a/src/com/android/documentsui/picker/ActionHandler.java b/src/com/android/documentsui/picker/ActionHandler.java
index fac8fcf..0b9744a 100644
--- a/src/com/android/documentsui/picker/ActionHandler.java
+++ b/src/com/android/documentsui/picker/ActionHandler.java
@@ -16,6 +16,7 @@
 
 package com.android.documentsui.picker;
 
+import android.app.Activity;
 import android.content.Intent;
 import android.content.pm.ResolveInfo;
 import android.net.Uri;
@@ -31,20 +32,21 @@
 import com.android.documentsui.dirlist.FragmentTuner;
 import com.android.documentsui.dirlist.Model;
 import com.android.documentsui.dirlist.MultiSelectManager;
+import com.android.documentsui.picker.ActionHandler.Addons;
 
 import javax.annotation.Nullable;
 
 /**
  * Provides {@link PickActivity} action specializations to fragments.
  */
-class ActionHandler extends AbstractActionHandler<PickActivity> {
+class ActionHandler<T extends Activity & Addons> extends AbstractActionHandler<T> {
 
     private static final String TAG = "PickerActionHandler";
 
     private final FragmentTuner mTuner;
     private final Config mConfig;
 
-    ActionHandler(PickActivity activity, FragmentTuner tuner) {
+    ActionHandler(T activity, FragmentTuner tuner) {
         super(activity);
         mTuner = tuner;
         mConfig = new Config();
@@ -78,7 +80,6 @@
         mActivity.onAppPicked(info);
     }
 
-
     @Override
     public boolean viewDocument(DocumentDetails details) {
         return openDocument(details);
@@ -101,7 +102,7 @@
         return false;
     }
 
-    ActionHandler reset(Model model, MultiSelectManager selectionMgr) {
+    ActionHandler<T> reset(Model model, MultiSelectManager selectionMgr) {
         mConfig.reset(model, selectionMgr);
         return this;
     }
@@ -119,4 +120,8 @@
             this.selectionMgr = selectionMgr;
         }
     }
+
+    public interface Addons extends CommonAddons {
+        void onAppPicked(ResolveInfo info);
+    }
 }
diff --git a/src/com/android/documentsui/picker/PickActivity.java b/src/com/android/documentsui/picker/PickActivity.java
index 9756c83..7b03a4d 100644
--- a/src/com/android/documentsui/picker/PickActivity.java
+++ b/src/com/android/documentsui/picker/PickActivity.java
@@ -47,7 +47,6 @@
 import com.android.documentsui.FocusManager;
 import com.android.documentsui.MenuManager.DirectoryDetails;
 import com.android.documentsui.R;
-import com.android.documentsui.Snackbars;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.MimePredicate;
 import com.android.documentsui.base.PairedTask;
@@ -61,18 +60,20 @@
 import com.android.documentsui.picker.LastAccessedProvider.Columns;
 import com.android.documentsui.services.FileOperationService;
 import com.android.documentsui.sidebar.RootsFragment;
+import com.android.documentsui.ui.DialogController;
+import com.android.documentsui.ui.Snackbars;
 
 import java.util.Arrays;
 import java.util.List;
 
-public class PickActivity extends BaseActivity {
+public class PickActivity extends BaseActivity implements ActionHandler.Addons {
 
     private static final int CODE_FORWARD = 42;
     private static final String TAG = "DocumentsActivity";
     private Tuner mTuner;
     private FocusManager mFocusManager;
     private MenuManager mMenuManager;
-    private ActionHandler mActionHandler;
+    private ActionHandler<PickActivity> mActionHandler;
 
     public PickActivity() {
         super(R.layout.documents_activity, TAG);
@@ -85,7 +86,7 @@
         mTuner = new Tuner(this, mState);
         mFocusManager = new FocusManager(getColor(R.color.accent_dark));
         mMenuManager = new MenuManager(mSearchManager, mState, new DirectoryDetails(this));
-        mActionHandler = new ActionHandler(this, mTuner);
+        mActionHandler = new ActionHandler<>(this, mTuner);
 
         if (mState.action == ACTION_CREATE) {
             final String mimeType = getIntent().getType();
@@ -168,6 +169,7 @@
         }
     }
 
+    @Override
     public void onAppPicked(ResolveInfo info) {
         final Intent intent = new Intent(getIntent());
         intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_FORWARD_RESULT);
@@ -414,7 +416,7 @@
     }
 
     @Override
-    public ActionHandler getActionHandler(
+    public ActionHandler<PickActivity> getActionHandler(
             Model model, MultiSelectManager selectionMgr) {
 
         // provide our friend, RootsFragment, early access to this special feature!
@@ -426,6 +428,14 @@
         return mActionHandler.reset(model, selectionMgr);
     }
 
+    /* (non-Javadoc)
+     * @see com.android.documentsui.BaseActivity#getDialogController()
+     */
+    @Override
+    public DialogController getDialogController() {
+        return DialogController.STUB;
+    }
+
     private static final class PickFinishTask extends PairedTask<PickActivity, Void, Void> {
         private final Uri mUri;
 
diff --git a/src/com/android/documentsui/ui/DialogController.java b/src/com/android/documentsui/ui/DialogController.java
new file mode 100644
index 0000000..f6adfd3
--- /dev/null
+++ b/src/com/android/documentsui/ui/DialogController.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.documentsui.ui;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.widget.TextView;
+
+import com.android.documentsui.R;
+import com.android.documentsui.base.ConfirmationCallback;
+import com.android.documentsui.base.DocumentInfo;
+import com.android.documentsui.services.FileOperationService;
+import com.android.documentsui.services.FileOperationService.OpType;
+import com.android.documentsui.services.FileOperations;
+import com.android.documentsui.services.FileOperations.Callback.Status;
+
+import java.util.List;
+
+public interface DialogController {
+
+    public static final DialogController STUB = new DialogController() {
+
+        @Override
+        public void confirmDelete(List<DocumentInfo> docs, ConfirmationCallback callback) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void showFileOperationFailures(int status, int opType, int docCount) {
+            throw new UnsupportedOperationException();
+        }
+    };
+
+    void confirmDelete(List<DocumentInfo> docs, ConfirmationCallback callback);
+    void showFileOperationFailures(int status, int opType, int docCount);
+
+    // Should be private, but Java doesn't like me treating an interface like a mini-package.
+    public static final class RuntimeDialogController implements DialogController {
+
+        private final Activity mActivity;
+        private final MessageBuilder mMessages;
+
+        public RuntimeDialogController(Activity activity) {
+            mActivity = activity;
+            mMessages = new MessageBuilder(mActivity);
+        }
+
+        @Override
+        public void confirmDelete(List<DocumentInfo> docs, ConfirmationCallback callback) {
+            assert(!docs.isEmpty());
+
+            TextView message =
+                    (TextView) mActivity.getLayoutInflater().inflate(
+                            R.layout.dialog_delete_confirmation, null);
+            message.setText(mMessages.generateDeleteMessage(docs));
+
+            // For now, we implement this dialog NOT
+            // as a fragment (which can survive rotation and have its own state),
+            // but as a simple runtime dialog. So rotating a device with an
+            // active delete dialog...results in that dialog disappearing.
+            // We can do better, but don't have cycles for it now.
+            new AlertDialog.Builder(mActivity)
+                .setView(message)
+                .setPositiveButton(
+                     android.R.string.ok,
+                     new DialogInterface.OnClickListener() {
+                        @Override
+                        public void onClick(DialogInterface dialog, int id) {
+                            callback.accept(ConfirmationCallback.CONFIRM);
+                        }
+                    })
+                .setNegativeButton(android.R.string.cancel, null)
+                .show();
+        }
+
+        @Override
+        public void showFileOperationFailures(@Status int status, @OpType int opType, int docCount) {
+            if (status == FileOperations.Callback.STATUS_REJECTED) {
+                Snackbars.showPasteFailed(mActivity);
+                return;
+            }
+
+            if (docCount == 0) {
+                // Nothing has been pasted, so there is no need to show a snackbar.
+                return;
+            }
+
+            switch (opType) {
+                case FileOperationService.OPERATION_MOVE:
+                    Snackbars.showMove(mActivity, docCount);
+                    break;
+                case FileOperationService.OPERATION_COPY:
+                    Snackbars.showCopy(mActivity, docCount);
+                    break;
+                case FileOperationService.OPERATION_DELETE:
+                    // We don't show anything for deletion.
+                    break;
+                default:
+                    throw new UnsupportedOperationException("Unsupported Operation: " + opType);
+            }
+        };
+    }
+
+    static DialogController create(Activity activity) {
+        return new RuntimeDialogController(activity);
+    }
+}
diff --git a/src/com/android/documentsui/ui/MessageBuilder.java b/src/com/android/documentsui/ui/MessageBuilder.java
new file mode 100644
index 0000000..7581006
--- /dev/null
+++ b/src/com/android/documentsui/ui/MessageBuilder.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.documentsui.ui;
+
+import android.content.Context;
+import android.text.BidiFormatter;
+
+import com.android.documentsui.R;
+import com.android.documentsui.base.DocumentInfo;
+import com.android.documentsui.base.Shared;
+
+import java.util.List;
+
+public class MessageBuilder {
+
+    private Context mContext;
+
+    public MessageBuilder(Context context) {
+        mContext = context;
+    }
+
+    public String generateDeleteMessage(List<DocumentInfo> docs) {
+        String message;
+        int dirsCount = 0;
+
+        for (DocumentInfo doc : docs) {
+            if (doc.isDirectory()) {
+                ++dirsCount;
+            }
+        }
+
+        if (docs.size() == 1) {
+            // Deleteing 1 file xor 1 folder in cwd
+
+            // Address b/28772371, where including user strings in message can result in
+            // broken bidirectional support.
+            String displayName = BidiFormatter.getInstance().unicodeWrap(docs.get(0).displayName);
+            message = dirsCount == 0
+                    ? mContext.getString(R.string.delete_filename_confirmation_message,
+                            displayName)
+                    : mContext.getString(R.string.delete_foldername_confirmation_message,
+                            displayName);
+        } else if (dirsCount == 0) {
+            // Deleting only files in cwd
+            message = Shared.getQuantityString(mContext,
+                    R.plurals.delete_files_confirmation_message, docs.size());
+        } else if (dirsCount == docs.size()) {
+            // Deleting only folders in cwd
+            message = Shared.getQuantityString(mContext,
+                    R.plurals.delete_folders_confirmation_message, docs.size());
+        } else {
+            // Deleting mixed items (files and folders) in cwd
+            message = Shared.getQuantityString(mContext,
+                    R.plurals.delete_items_confirmation_message, docs.size());
+        }
+        return message;
+    }
+}
diff --git a/src/com/android/documentsui/Snackbars.java b/src/com/android/documentsui/ui/Snackbars.java
similarity index 96%
rename from src/com/android/documentsui/Snackbars.java
rename to src/com/android/documentsui/ui/Snackbars.java
index 90254f1..bab58f0 100644
--- a/src/com/android/documentsui/Snackbars.java
+++ b/src/com/android/documentsui/ui/Snackbars.java
@@ -14,13 +14,14 @@
  * limitations under the License.
  */
 
-package com.android.documentsui;
+package com.android.documentsui.ui;
 
 import android.annotation.StringRes;
 import android.app.Activity;
 import android.support.design.widget.Snackbar;
 import android.view.View;
 
+import com.android.documentsui.R;
 import com.android.documentsui.base.Shared;
 
 public final class Snackbars {