Show a warning notification and a dialog in case of conversion when copying.

Change-Id: I451b0944069deb5f0b49b54c514e7761be449e3d
diff --git a/res/values/strings.xml b/res/values/strings.xml
index aefdc67..afe9336 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -181,6 +181,8 @@
     <string name="copy_failure_alert_content">These files weren\'t copied: <xliff:g id="list">%1$s</xliff:g></string>
     <!-- Contents of the moving failure alert dialog. [CHAR LIMIT=48] -->
     <string name="move_failure_alert_content">These files weren\'t moved: <xliff:g id="list">%1$s</xliff:g></string>
+    <!-- Contents of the copying warning dialog due to converted files. [CHAR LIMIT=64] -->
+    <string name="copy_converted_warning_content">These files were converted to another format: <xliff:g id="list" example="Document.pdf, Photo.jpg, Song.ogg">%1$s</xliff:g></string>
     <!-- Toast shown when a user copies files to clipboard. -->
     <plurals name="clipboard_files_clipped">
         <item quantity="one">Copied <xliff:g id="count" example="1">%1$d</xliff:g> file to clipboard.</item>
@@ -192,6 +194,9 @@
     <string name="menu_rename">Rename</string>
     <!-- Toast shown when renaming document failed with an error [CHAR LIMIT=48] -->
     <string name="rename_error">Failed to rename document</string>
+    <!-- First line for notifications saying that some files were converted to a different format
+         during a copy. [CHAR LIMIT=48] -->
+    <string name="notification_copy_files_converted_title">Some files were converted</string>
 
     <!--  DO NOT TRANSLATE - final phrase has not been decided yet (b/26750152) -->
     <string name="open_external_dialog_request">Grant <xliff:g id="appName" example="System Settings"><b>^1</b></xliff:g>
diff --git a/src/com/android/documentsui/FailureDialogFragment.java b/src/com/android/documentsui/FailureDialogFragment.java
deleted file mode 100644
index b8ef5ac..0000000
--- a/src/com/android/documentsui/FailureDialogFragment.java
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * Copyright (C) 2015 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;
-
-import static com.android.internal.util.Preconditions.checkArgument;
-
-import android.app.AlertDialog;
-import android.app.Dialog;
-import android.app.DialogFragment;
-import android.app.FragmentManager;
-import android.app.FragmentTransaction;
-import android.content.DialogInterface;
-import android.os.Bundle;
-import android.text.Html;
-
-import com.android.documentsui.model.DocumentInfo;
-import com.android.documentsui.model.DocumentStack;
-import com.android.documentsui.services.FileOperationService;
-import com.android.documentsui.services.FileOperations;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Alert dialog for failed operations.
- */
-public class FailureDialogFragment extends DialogFragment {
-    private static final String TAG = "FailureDialogFragment";
-
-    public static void show(FragmentManager fm, int failure,
-            ArrayList<DocumentInfo> failedSrcList, DocumentStack dstStack, int operationType) {
-        // TODO: Add support for other failures than copy.
-        if (failure != FileOperationService.FAILURE_COPY) {
-            return;
-        }
-
-        final Bundle args = new Bundle();
-        args.putInt(FileOperationService.EXTRA_FAILURE, failure);
-        args.putInt(FileOperationService.EXTRA_OPERATION, operationType);
-        args.putParcelableArrayList(FileOperationService.EXTRA_SRC_LIST, failedSrcList);
-
-        final FragmentTransaction ft = fm.beginTransaction();
-        final FailureDialogFragment fragment = new FailureDialogFragment();
-        fragment.setArguments(args);
-
-        ft.add(fragment, TAG);
-        ft.commitAllowingStateLoss();
-    }
-
-    @Override
-    public Dialog onCreateDialog(Bundle inState) {
-        super.onCreate(inState);
-
-        final int operationType = getArguments().getInt(FileOperationService.EXTRA_OPERATION);
-        final List<DocumentInfo> failedSrcList = getArguments().getParcelableArrayList(
-                FileOperationService.EXTRA_SRC_LIST);
-
-        final StringBuilder list = new StringBuilder("<p>");
-        for (DocumentInfo documentInfo : failedSrcList) {
-            list.append(String.format("&#8226; %s<br>", documentInfo.displayName));
-        }
-        list.append("</p>");
-
-        // TODO: Add support for other file operations.
-        checkArgument(
-                operationType == FileOperationService.OPERATION_COPY
-                || operationType == FileOperationService.OPERATION_MOVE);
-
-        int messageId = operationType == FileOperationService.OPERATION_COPY
-                ? R.string.copy_failure_alert_content
-                : R.string.move_failure_alert_content;
-
-        final String messageFormat = getString(
-                messageId);
-
-        final String message = String.format(messageFormat, list.toString());
-
-        return new AlertDialog.Builder(getActivity())
-                .setMessage(Html.fromHtml(message))
-                .setPositiveButton(R.string.close, new DialogInterface.OnClickListener() {
-                    @Override
-                    public void onClick(DialogInterface dialog, int id) {
-                        dialog.dismiss();
-                    }
-                })
-                .create();
-    }
-}
diff --git a/src/com/android/documentsui/FilesActivity.java b/src/com/android/documentsui/FilesActivity.java
index 89a6912..393fdcd 100644
--- a/src/com/android/documentsui/FilesActivity.java
+++ b/src/com/android/documentsui/FilesActivity.java
@@ -20,6 +20,8 @@
 import static com.android.documentsui.dirlist.DirectoryFragment.ANIM_NONE;
 import static com.android.internal.util.Preconditions.checkArgument;
 import static com.android.internal.util.Preconditions.checkState;
+import static com.android.documentsui.OperationDialogFragment.DialogType;
+import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_UNKNOWN;
 
 import android.app.Activity;
 import android.app.FragmentManager;
@@ -121,20 +123,20 @@
                     ProviderExecutor.forAuthority(homeUri.getAuthority()));
         }
 
-        final int failure = intent.getIntExtra(FileOperationService.EXTRA_FAILURE, 0);
-        final int opType = intent.getIntExtra(
-                FileOperationService.EXTRA_OPERATION,
-                FileOperationService.OPERATION_COPY);
-
+        final @DialogType int dialogType = intent.getIntExtra(
+                FileOperationService.EXTRA_DIALOG_TYPE, DIALOG_TYPE_UNKNOWN);
         // DialogFragment takes care of restoring the dialog on configuration change.
         // Only show it manually for the first time (icicle is null).
-        if (icicle == null && failure != 0) {
-            final ArrayList<DocumentInfo> failedSrcList =
+        if (icicle == null && dialogType != DIALOG_TYPE_UNKNOWN) {
+            final int opType = intent.getIntExtra(
+                    FileOperationService.EXTRA_OPERATION,
+                    FileOperationService.OPERATION_COPY);
+            final ArrayList<DocumentInfo> srcList =
                     intent.getParcelableArrayListExtra(FileOperationService.EXTRA_SRC_LIST);
-            FailureDialogFragment.show(
+            OperationDialogFragment.show(
                     getFragmentManager(),
-                    failure,
-                    failedSrcList,
+                    dialogType,
+                    srcList,
                     mState.stack,
                     opType);
         }
diff --git a/src/com/android/documentsui/OperationDialogFragment.java b/src/com/android/documentsui/OperationDialogFragment.java
new file mode 100644
index 0000000..85cc12b
--- /dev/null
+++ b/src/com/android/documentsui/OperationDialogFragment.java
@@ -0,0 +1,132 @@
+/*
+ * 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;
+
+import static com.android.documentsui.services.FileOperationService.OpType;
+import static com.android.internal.util.Preconditions.checkArgument;
+
+import android.annotation.IntDef;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.Html;
+
+import com.android.documentsui.model.DocumentInfo;
+import com.android.documentsui.model.DocumentStack;
+import com.android.documentsui.services.FileOperationService;
+import com.android.documentsui.services.FileOperations;
+import com.android.documentsui.services.Job;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Alert dialog for operation dialogs.
+ */
+public class OperationDialogFragment extends DialogFragment {
+
+    public static final int DIALOG_TYPE_UNKNOWN = 0;
+    public static final int DIALOG_TYPE_FAILURE = 1;
+    public static final int DIALOG_TYPE_CONVERTED = 2;
+
+    @IntDef(flag = true, value = {
+        DIALOG_TYPE_UNKNOWN,
+        DIALOG_TYPE_FAILURE,
+        DIALOG_TYPE_CONVERTED
+    })
+
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface DialogType {}
+
+    private static final String TAG = "OperationDialogFragment";
+
+    public static void show(FragmentManager fm, @DialogType int dialogType,
+            ArrayList<DocumentInfo> failedSrcList, DocumentStack dstStack,
+            @OpType int operationType) {
+        final Bundle args = new Bundle();
+        args.putInt(FileOperationService.EXTRA_DIALOG_TYPE, dialogType);
+        args.putInt(FileOperationService.EXTRA_OPERATION, operationType);
+        args.putParcelableArrayList(FileOperationService.EXTRA_SRC_LIST, failedSrcList);
+
+        final FragmentTransaction ft = fm.beginTransaction();
+        final OperationDialogFragment fragment = new OperationDialogFragment();
+        fragment.setArguments(args);
+
+        ft.add(fragment, TAG);
+        ft.commitAllowingStateLoss();
+    }
+
+    @Override
+    public Dialog onCreateDialog(Bundle inState) {
+        super.onCreate(inState);
+
+        final @DialogType int dialogType =
+              getArguments().getInt(FileOperationService.EXTRA_DIALOG_TYPE);
+        final @OpType int operationType =
+              getArguments().getInt(FileOperationService.EXTRA_OPERATION);
+        final ArrayList<DocumentInfo> srcList = getArguments().getParcelableArrayList(
+                FileOperationService.EXTRA_SRC_LIST);
+
+        final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
+        String messageFormat;
+
+        switch (dialogType) {
+            case DIALOG_TYPE_CONVERTED:
+                messageFormat = getString(R.string.copy_converted_warning_content);
+                break;
+
+            case DIALOG_TYPE_FAILURE:
+                switch (operationType) {
+                    case FileOperationService.OPERATION_COPY:
+                        messageFormat = getString(R.string.copy_failure_alert_content);
+                        break;
+                    case FileOperationService.OPERATION_MOVE:
+                        messageFormat = getString(R.string.move_failure_alert_content);
+                        break;
+                    default:
+                        throw new UnsupportedOperationException();
+                }
+                break;
+
+            default:
+                throw new UnsupportedOperationException();
+        }
+
+        final StringBuilder list = new StringBuilder("<p>");
+        for (DocumentInfo documentInfo : srcList) {
+            list.append(String.format("&#8226; %s<br>", documentInfo.displayName));
+        }
+        list.append("</p>");
+        builder.setMessage(Html.fromHtml(String.format(messageFormat, list.toString())));
+        builder.setPositiveButton(
+                R.string.close,
+                new DialogInterface.OnClickListener() {
+                    @Override
+                    public void onClick(DialogInterface dialog, int id) {
+                        dialog.dismiss();
+                    }
+                });
+
+        return builder.create();
+    }
+}
diff --git a/src/com/android/documentsui/services/CopyJob.java b/src/com/android/documentsui/services/CopyJob.java
index 1d8eba5..e5e66f8 100644
--- a/src/com/android/documentsui/services/CopyJob.java
+++ b/src/com/android/documentsui/services/CopyJob.java
@@ -21,17 +21,23 @@
 import static android.provider.DocumentsContract.buildDocumentUri;
 import static android.provider.DocumentsContract.getDocumentId;
 import static android.provider.DocumentsContract.isChildDocument;
+import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_CONVERTED;
 import static com.android.documentsui.Shared.DEBUG;
 import static com.android.documentsui.model.DocumentInfo.getCursorLong;
 import static com.android.documentsui.model.DocumentInfo.getCursorString;
+import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE;
+import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION;
+import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST;
 import static com.android.documentsui.services.FileOperationService.OPERATION_COPY;
 import static com.google.common.base.Preconditions.checkArgument;
 
 import android.annotation.StringRes;
 import android.app.Notification;
 import android.app.Notification.Builder;
+import android.app.PendingIntent;
 import android.content.ContentProviderClient;
 import android.content.Context;
+import android.content.Intent;
 import android.content.res.AssetFileDescriptor;
 import android.database.Cursor;
 import android.net.Uri;
@@ -56,12 +62,14 @@
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.text.NumberFormat;
+import java.util.ArrayList;
 import java.util.List;
 
 class CopyJob extends Job {
     private static final String TAG = "CopyJob";
     private static final int PROGRESS_INTERVAL_MILLIS = 1000;
     final List<DocumentInfo> mSrcs;
+    final ArrayList<DocumentInfo> convertedFiles = new ArrayList<>();
 
     private long mStartTime = -1;
     private long mBatchSize;
@@ -176,6 +184,29 @@
     }
 
     @Override
+    Notification getWarningNotification() {
+        final Intent navigateIntent = buildNavigateIntent();
+        navigateIntent.putExtra(EXTRA_DIALOG_TYPE, DIALOG_TYPE_CONVERTED);
+        navigateIntent.putExtra(EXTRA_OPERATION, operationType);
+
+        navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, convertedFiles);
+
+        // TODO: Consider adding a dialog on tapping the notification with a list of
+        // converted files.
+        final Notification.Builder warningBuilder = new Notification.Builder(service)
+                .setContentTitle(service.getResources().getString(
+                        R.string.notification_copy_files_converted_title))
+                .setContentText(service.getString(
+                        R.string.notification_touch_for_details))
+                .setContentIntent(PendingIntent.getActivity(appContext, 0, navigateIntent,
+                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT))
+                .setCategory(Notification.CATEGORY_ERROR)
+                .setSmallIcon(R.drawable.ic_menu_copy)
+                .setAutoCancel(true);
+        return warningBuilder.build();
+    }
+
+    @Override
     void start() throws RemoteException {
         mStartTime = elapsedRealtime();
 
@@ -203,6 +234,11 @@
         }
     }
 
+    @Override
+    boolean hasWarnings() {
+        return !convertedFiles.isEmpty();
+    }
+
     /**
      * Logs progress on the current copy operation. Displays/Updates the progress notification.
      *
@@ -425,6 +461,10 @@
             }
         }
 
+        if (src.isVirtualDocument() && success) {
+           convertedFiles.add(src);
+        }
+
         return success;
     }
 
diff --git a/src/com/android/documentsui/services/DeleteJob.java b/src/com/android/documentsui/services/DeleteJob.java
index eef696a..24eb987 100644
--- a/src/com/android/documentsui/services/DeleteJob.java
+++ b/src/com/android/documentsui/services/DeleteJob.java
@@ -75,6 +75,11 @@
     }
 
     @Override
+    Notification getWarningNotification() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
     void start() throws RemoteException {
         for (DocumentInfo doc : mSrcs) {
             if (DEBUG) Log.d(TAG, "Deleting document @ " + doc.derivedUri);
diff --git a/src/com/android/documentsui/services/FileOperationService.java b/src/com/android/documentsui/services/FileOperationService.java
index 5d74cdc..3a025c2 100644
--- a/src/com/android/documentsui/services/FileOperationService.java
+++ b/src/com/android/documentsui/services/FileOperationService.java
@@ -55,6 +55,7 @@
     private static final int POOL_SIZE = 2;  // "pool size", not *max* "pool size".
     private static final int NOTIFICATION_ID_PROGRESS = 0;
     private static final int NOTIFICATION_ID_FAILURE = 1;
+    private static final int NOTIFICATION_ID_WARNING = 2;
 
     public static final String TAG = "FileOperationService";
 
@@ -63,7 +64,7 @@
     public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION";
     public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL";
     public static final String EXTRA_SRC_LIST = "com.android.documentsui.SRC_LIST";
-    public static final String EXTRA_FAILURE = "com.android.documentsui.FAILURE";
+    public static final String EXTRA_DIALOG_TYPE = "com.android.documentsui.DIALOG_TYPE";
 
     // This extra is used only for moving and deleting. Currently it's not the case,
     // but in the future those files may be from multiple different parents. In
@@ -306,6 +307,18 @@
         // Dismiss the ongoing copy notification when the copy is done.
         mNotificationManager.cancel(job.id, NOTIFICATION_ID_PROGRESS);
 
+        if (job.hasFailures()) {
+            Log.e(TAG, "Job failed on files: " + job.failedFiles.size() + ".");
+            mNotificationManager.notify(
+                job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification());
+        }
+
+        if (job.hasWarnings()) {
+            if (DEBUG) Log.d(TAG, "Job finished with warnings.");
+            mNotificationManager.notify(
+                    job.id, NOTIFICATION_ID_WARNING, job.getWarningNotification());
+        }
+
         synchronized (mRunning) {
             deleteJob(job);
         }
@@ -318,15 +331,6 @@
                 job.id, NOTIFICATION_ID_PROGRESS, job.getProgressNotification());
     }
 
-    @Override
-    public void onFailed(Job job) {
-        if (DEBUG) Log.d(TAG, "onFailed: " + job.id);
-        checkArgument(job.failed());
-        Log.e(TAG, "Job failed on files: " + job.failedFiles.size() + ".");
-        mNotificationManager.notify(job.id, NOTIFICATION_ID_FAILURE, job.getFailureNotification());
-        onFinished(job);  // Failed jobs don't call finished, so we do.
-    }
-
     private static final class JobRecord {
         private final Job job;
         private final ScheduledFuture<?> future;
diff --git a/src/com/android/documentsui/services/Job.java b/src/com/android/documentsui/services/Job.java
index 9534d6c..7b8011a 100644
--- a/src/com/android/documentsui/services/Job.java
+++ b/src/com/android/documentsui/services/Job.java
@@ -18,11 +18,10 @@
 
 import static com.android.documentsui.DocumentsApplication.acquireUnstableProviderOrThrow;
 import static com.android.documentsui.services.FileOperationService.EXTRA_CANCEL;
-import static com.android.documentsui.services.FileOperationService.EXTRA_FAILURE;
+import static com.android.documentsui.services.FileOperationService.EXTRA_DIALOG_TYPE;
 import static com.android.documentsui.services.FileOperationService.EXTRA_JOB_ID;
 import static com.android.documentsui.services.FileOperationService.EXTRA_OPERATION;
 import static com.android.documentsui.services.FileOperationService.EXTRA_SRC_LIST;
-import static com.android.documentsui.services.FileOperationService.FAILURE_COPY;
 import static com.android.documentsui.services.FileOperationService.OPERATION_UNKNOWN;
 import static com.android.internal.util.Preconditions.checkArgument;
 import static com.android.internal.util.Preconditions.checkNotNull;
@@ -42,6 +41,7 @@
 import android.util.Log;
 
 import com.android.documentsui.FilesActivity;
+import com.android.documentsui.OperationDialogFragment;
 import com.android.documentsui.R;
 import com.android.documentsui.Shared;
 import com.android.documentsui.model.DocumentInfo;
@@ -57,8 +57,7 @@
  * A mashup of work item and ui progress update factory. Used by {@link FileOperationService}
  * to do work and show progress relating to this work.
  */
-abstract class Job implements Runnable {
-
+abstract public class Job implements Runnable {
     private static final String TAG = "Job";
     final Context service;
     final Context appContext;
@@ -114,13 +113,9 @@
             // to resolve business in an orderly fashion. That'll
             // ensure the service is shut down and notifications
             // shown/closed.
-            listener.onFailed(this);
+            Log.e(TAG, "Operation failed due to an exception.", e);
         } finally {
-            if (failed()) {
-                listener.onFailed(this);
-            } else {
-                listener.onFinished(this);
-            }
+            listener.onFinished(this);
         }
     }
 
@@ -131,6 +126,8 @@
     // abstract Notification getProgressNotification(long bytesCopied);
     abstract Notification getFailureNotification();
 
+    abstract Notification getWarningNotification();
+
     ContentProviderClient getClient(DocumentInfo doc) throws RemoteException {
         ContentProviderClient client = mClients.get(doc.authority);
         if (client == null) {
@@ -167,10 +164,14 @@
         failedFiles.add(file);
     }
 
-    final boolean failed() {
+    final boolean hasFailures() {
         return !failedFiles.isEmpty();
     }
 
+    boolean hasWarnings() {
+        return false;
+    }
+
     final boolean deleteDocument(DocumentInfo doc) {
         try {
             DocumentsContract.deleteDocument(getClient(doc), doc.derivedUri);
@@ -190,7 +191,7 @@
 
     Notification getFailureNotification(@PluralsRes int titleId, @DrawableRes int icon) {
         final Intent navigateIntent = buildNavigateIntent();
-        navigateIntent.putExtra(EXTRA_FAILURE, FAILURE_COPY);
+        navigateIntent.putExtra(EXTRA_DIALOG_TYPE, OperationDialogFragment.DIALOG_TYPE_FAILURE);
         navigateIntent.putExtra(EXTRA_OPERATION, operationType);
 
         navigateIntent.putParcelableArrayListExtra(EXTRA_SRC_LIST, failedFiles);
@@ -291,7 +292,6 @@
      */
     interface Listener {
         void onStart(Job job);
-        void onFailed(Job job);
         void onFinished(Job job);
         void onProgress(CopyJob job);
     }
diff --git a/tests/src/com/android/documentsui/services/TestJob.java b/tests/src/com/android/documentsui/services/TestJob.java
index 9c58780..a1c6dab 100644
--- a/tests/src/com/android/documentsui/services/TestJob.java
+++ b/tests/src/com/android/documentsui/services/TestJob.java
@@ -63,6 +63,11 @@
     }
 
     @Override
+    Notification getWarningNotification() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
     Builder createProgressBuilder() {
         // the "copy" stuff was just convenient and available :)
         return super.createProgressBuilder(
diff --git a/tests/src/com/android/documentsui/services/TestJobListener.java b/tests/src/com/android/documentsui/services/TestJobListener.java
index 0110197..46b093d 100644
--- a/tests/src/com/android/documentsui/services/TestJobListener.java
+++ b/tests/src/com/android/documentsui/services/TestJobListener.java
@@ -33,7 +33,6 @@
     private final CountDownLatch latch = new CountDownLatch(1);
     private final List<Job> progress = new ArrayList<>();
     @Nullable private Job started;
-    @Nullable private Job failed;
     @Nullable private Job finished;
 
     @Override
@@ -42,11 +41,6 @@
     }
 
     @Override
-    public void onFailed(Job job) {
-        failed = job;
-    }
-
-    @Override
     public void onFinished(Job job) {
         this.finished = job;
         latch.countDown();
@@ -70,28 +64,28 @@
     }
 
     public void assertFailed() {
-        if (failed == null) {
+        if (finished == null || !finished.hasFailures()) {
             fail("Job didn't fail. onFailed never called.");
         }
     }
 
     public void assertFilesFailed(ArrayList<String> names) {
-        if (failed == null) {
+        if (finished == null || !finished.hasFailures()) {
             fail("Can't test failed documetns. Job didn't fail.");
         }
 
-        assertEquals(failed.failedFiles.size(), names.size());
+        assertEquals(finished.failedFiles.size(), names.size());
         for (String name : names) {
             assertFileFailed(name);
         }
     }
 
     public void assertFileFailed(String name) {
-        if (failed == null) {
+        if (finished == null || !finished.hasFailures()) {
             fail("Can't test failed documetns. Job didn't fail.");
         }
 
-        for (DocumentInfo failed : failed.failedFiles) {
+        for (DocumentInfo failed : finished.failedFiles) {
             if (name.equals(failed.displayName)) {
                 return;
             }