diff options
author | 2018-08-20 16:20:43 +0800 | |
---|---|---|
committer | 2018-08-31 22:13:50 +0800 | |
commit | 65bbe7934b8c1811b3bb65416ea4a37cf62d6d58 (patch) | |
tree | c686a2ab4f4f2e3a3cd51dbe6c69c8259bf922d8 | |
parent | bda5932d34493ad4070f1ab6cc7b83764cec0c50 (diff) |
Fixed files flickering in & out during a deletion.
Clear selection when all ids in it are either no longer in the current cursor or in the failure list.
Change-Id: Idc1de86f8173626765236e0bf6bd40d421cef51b
Fixes: 112741407
Test: manul, add some files, delete files, observe the deletion process
Test: atest DocumentsUITests
11 files changed, 300 insertions, 32 deletions
diff --git a/src/com/android/documentsui/Model.java b/src/com/android/documentsui/Model.java index 7141ab90e..f2de80228 100644 --- a/src/com/android/documentsui/Model.java +++ b/src/com/android/documentsui/Model.java @@ -23,7 +23,6 @@ import static com.android.documentsui.base.SharedMinimal.VERBOSE; import androidx.annotation.IntDef; import android.app.AuthenticationRequiredException; import android.database.Cursor; -import android.database.MergeCursor; import android.net.Uri; import android.os.Bundle; import android.provider.DocumentsContract; @@ -38,7 +37,6 @@ import com.android.documentsui.base.DocumentFilters; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.EventListener; import com.android.documentsui.base.Features; -import com.android.documentsui.roots.RootCursorWrapper; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -75,6 +73,7 @@ public class Model { private int mCursorCount; private String mIds[] = new String[0]; private Set<Selection<String>> mDocumentsToBeDeleted = new HashSet<>(); + private HashMap<Integer, ArrayList<String>> mDeletionFailedDocIds = new HashMap<>(); public Model(Features features) { mFeatures = features; @@ -112,6 +111,7 @@ public class Model { mIsLoading = false; mFileNames.clear(); mDocumentsToBeDeleted.clear(); + mDeletionFailedDocIds.clear(); notifyUpdateListeners(); } @@ -152,7 +152,7 @@ public class Model { notifyUpdateListeners(); } - public void restoreDocumentsToBeDeleted(Selection<String> selection) { + public void clearDocumentsToBeDeleted(Selection<String> selection) { if (!mDocumentsToBeDeleted.contains(selection)) { return; } @@ -161,27 +161,60 @@ public class Model { notifyUpdateListeners(); } - private boolean isDocumentToBeDeleted(String id) { - for (Selection<String> s : mDocumentsToBeDeleted) { - if (s.contains(id)) { - return true; - } + public void setDeletionFailedUris(Selection<String> selection, + ArrayList<Uri> deletionFailedUris) { + if (!mDocumentsToBeDeleted.contains(selection)) { + return; } - return false; + + mDeletionFailedDocIds.put(selection.hashCode(), ModelId.build(deletionFailedUris)); + updateModelData(); + notifyUpdateListeners(); } private void updateDocumentsToBeDeleted() { for (Iterator<Selection<String>> i = mDocumentsToBeDeleted.iterator(); i.hasNext();) { Selection<String> selection = i.next(); + int size = selection.size(); + ArrayList<String> failedDocIds = mDeletionFailedDocIds.get(selection.hashCode()); for (String id : selection) { - if (!mPositions.containsKey(id)) { + // Check whether the id is in the current cursor or in the deletion failed list. + // If all ids are either not in the current cursor or in the deletion failed list, + // it means the deletion of this selection is done, and we can clear this selection. + if (!mPositions.containsKey(id) || + (failedDocIds != null && failedDocIds.contains(id))) { + size--; + } + if (size == 0) { i.remove(); + mDeletionFailedDocIds.remove(selection.hashCode()); break; } } } } + private int getVisibleCount() { + int count = mPositions.size(); + for (Selection<String> selection : mDocumentsToBeDeleted) { + for (String id : selection) { + if (mPositions.containsKey(id)) { + count--; + } + } + } + return count; + } + + private boolean isDocumentToBeDeleted(String id) { + for (Selection<String> s : mDocumentsToBeDeleted) { + if (s.contains(id)) { + return true; + } + } + return false; + } + private int getDocumentsToBeDeletedCount() { int count = 0; for (Selection<String> s : mDocumentsToBeDeleted) { @@ -211,21 +244,15 @@ public class Model { } // Generates a Model ID for a cursor entry that refers to a document. The Model ID is a // unique string that can be used to identify the document referred to by the cursor. - // If the cursor is a merged cursor over multiple authorities, then prefix the ids - // with the authority to avoid collisions. - if (mCursor instanceof MergeCursor) { - tmpIds[pos] = getCursorString(mCursor, RootCursorWrapper.COLUMN_AUTHORITY) - + "|" + getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID); - } else { - tmpIds[pos] = getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID); - } + // Prefix the ids with the authority to avoid collisions. + tmpIds[pos] = ModelId.build(mCursor); mPositions.put(tmpIds[pos], pos); mFileNames.add(getCursorString(mCursor, Document.COLUMN_DISPLAY_NAME)); } updateDocumentsToBeDeleted(); - mIds = new String[mCursorCount - getDocumentsToBeDeletedCount()]; + mIds = new String[getVisibleCount()]; int index = 0; for (int i = 0; i < mCursorCount; ++i) { if (!isDocumentToBeDeleted(tmpIds[i])) { diff --git a/src/com/android/documentsui/ModelId.java b/src/com/android/documentsui/ModelId.java new file mode 100644 index 000000000..e178c6318 --- /dev/null +++ b/src/com/android/documentsui/ModelId.java @@ -0,0 +1,68 @@ +package com.android.documentsui; + +import static com.android.documentsui.base.DocumentInfo.getCursorString; + +import android.database.Cursor; +import android.net.Uri; +import android.provider.DocumentsContract; +import android.util.Log; + +import com.android.documentsui.base.DocumentInfo; +import com.android.documentsui.roots.RootCursorWrapper; + +import java.util.ArrayList; +import java.util.List; + +public class ModelId { + private final static String TAG = "ModelId"; + + public static final String build(Uri uri) { + String documentId; + try { + documentId = DocumentsContract.getDocumentId(uri); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Failed to get document id.", e); + return null; + } + String authority; + authority = uri.getAuthority(); + return ModelId.build(authority, documentId); + } + + public static final String build(DocumentInfo docInfo) { + if (docInfo == null) { + return null; + } + return ModelId.build(docInfo.authority, docInfo.documentId); + } + + public static final String build(Cursor cursor) { + if (cursor == null) { + return null; + } + return ModelId.build(getCursorString(cursor, RootCursorWrapper.COLUMN_AUTHORITY), + getCursorString(cursor, DocumentsContract.Document.COLUMN_DOCUMENT_ID)); + } + + public static final ArrayList<String> build(ArrayList<Uri> uris) { + if (uris == null || uris.isEmpty()) { + return null; + } + ArrayList<String> ids = new ArrayList<>(); + String id; + for (Uri uri : uris) { + id = ModelId.build(uri); + if (id != null) { + ids.add(id); + } + } + return ids; + } + + public static final String build(String authority, String docId) { + if (authority == null || authority.isEmpty() || docId == null || docId.isEmpty()) { + return null; + } + return authority + "|" + docId; + } +} diff --git a/src/com/android/documentsui/files/ActionHandler.java b/src/com/android/documentsui/files/ActionHandler.java index 347bdc845..c1c280c53 100644 --- a/src/com/android/documentsui/files/ActionHandler.java +++ b/src/com/android/documentsui/files/ActionHandler.java @@ -25,6 +25,8 @@ import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.Intent; import android.net.Uri; +import android.os.Handler; +import android.os.Message; import android.provider.DocumentsContract; import android.text.TextUtils; import android.util.Log; @@ -64,6 +66,7 @@ import com.android.documentsui.files.ActionHandler.Addons; import com.android.documentsui.inspector.InspectorActivity; import com.android.documentsui.queries.SearchViewManager; import com.android.documentsui.roots.ProvidersAccess; +import com.android.documentsui.services.DeleteJob; import com.android.documentsui.services.FileOperation; import com.android.documentsui.services.FileOperationService; import com.android.documentsui.services.FileOperations; @@ -298,7 +301,7 @@ public class ActionHandler<T extends Activity & Addons> extends AbstractActionHa @Override public void deleteSelectedDocuments() { Metrics.logUserAction(mActivity, Metrics.USER_ACTION_DELETE); - Selection<String> selection = getSelectedOrFocused(); + final Selection<String> selection = getSelectedOrFocused(); if (selection.isEmpty()) { return; @@ -323,7 +326,7 @@ public class ActionHandler<T extends Activity & Addons> extends AbstractActionHa mModel.markDocumentsToBeDeleted(selection); Consumer<View> action = v -> { Metrics.logUserAction(mActivity, Metrics.USER_ACTION_UNDO_DELETE); - mModel.restoreDocumentsToBeDeleted(selection); + mModel.clearDocumentsToBeDeleted(selection); }; Snackbar.Callback callback = new Snackbar.Callback() { @Override @@ -336,9 +339,31 @@ public class ActionHandler<T extends Activity & Addons> extends AbstractActionHa .withSrcs(srcs) .withSrcParent(srcParent == null ? null : srcParent.derivedUri) .build(); - - FileOperations.start(mActivity, operation, null, - FileOperations.createJobId()); + operation.addMessageListener(new Handler.Callback() { + @Override + public boolean handleMessage(Message message) { + if (message.what == FileOperationService.MESSAGE_FINISH) { + operation.removeMessageListener(this); + + // If failure count equals selection size, + // it means all deletions failed. Just clear the selection. + final int failureCount = message.arg1; + if (failureCount == selection.size()) { + mModel.clearDocumentsToBeDeleted(selection); + return true; + } + + ArrayList<Uri> failureUris = message.getData() + .getParcelableArrayList(DeleteJob.KEY_FAILED_URIS); + if (failureUris != null) { + mModel.setDeletionFailedUris(selection, failureUris); + } + return true; + } + return false; + } + }); + FileOperations.start(mActivity, operation, null, FileOperations.createJobId()); } if (mDeletionSnackbar == snackbar) { mDeletionSnackbar = null; diff --git a/src/com/android/documentsui/services/DeleteJob.java b/src/com/android/documentsui/services/DeleteJob.java index 041eba721..04f1955b8 100644 --- a/src/com/android/documentsui/services/DeleteJob.java +++ b/src/com/android/documentsui/services/DeleteJob.java @@ -17,6 +17,7 @@ package com.android.documentsui.services; import static com.android.documentsui.base.SharedMinimal.DEBUG; +import static com.android.documentsui.services.FileOperationService.MESSAGE_FINISH; import static com.android.documentsui.services.FileOperationService.OPERATION_DELETE; import android.app.Notification; @@ -24,6 +25,10 @@ import android.app.Notification.Builder; import android.content.ContentResolver; import android.content.Context; import android.net.Uri; +import android.os.Bundle; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; import android.util.Log; import com.android.documentsui.Metrics; @@ -34,16 +39,22 @@ import com.android.documentsui.base.Features; import com.android.documentsui.clipping.UrisSupplier; import java.io.FileNotFoundException; +import java.util.ArrayList; import javax.annotation.Nullable; -final class DeleteJob extends ResolvedResourcesJob { +public final class DeleteJob extends ResolvedResourcesJob { private static final String TAG = "DeleteJob"; + public final static String KEY_FAILED_URIS = "deletion_failed_uris"; + private final Uri mParentUri; private volatile int mDocsProcessed = 0; + + private final Messenger mMessenger; + /** * Moves files to a destination identified by {@code destination}. * Performs most work by delegating to CopyJob, then deleting @@ -52,9 +63,10 @@ final class DeleteJob extends ResolvedResourcesJob { * @see @link {@link Job} constructor for most param descriptions. */ DeleteJob(Context service, Listener listener, String id, DocumentStack stack, - UrisSupplier srcs, @Nullable Uri srcParent, Features features) { + UrisSupplier srcs, Messenger messenger, @Nullable Uri srcParent, Features features) { super(service, listener, id, OPERATION_DELETE, stack, srcs, features); mParentUri = srcParent; + mMessenger = messenger; } @Override @@ -130,6 +142,29 @@ final class DeleteJob extends ResolvedResourcesJob { } @Override + void finish() { + super.finish(); + try { + Message message = Message.obtain(); + message.what = MESSAGE_FINISH; + message.arg1 = failureCount; + if (failureCount > 0 && failureCount < mResourceUris.getItemCount()) { + Bundle b = new Bundle(); + ArrayList<Uri> uris = new ArrayList<>(); + uris.addAll(failedUris); + for (DocumentInfo documentInfo : failedDocs) { + uris.add(documentInfo.derivedUri); + } + b.putParcelableArrayList(KEY_FAILED_URIS, uris); + message.setData(b); + } + mMessenger.send(message); + } catch (RemoteException e) { + // Ignore. Most likely the frontend was killed. + } + } + + @Override public String toString() { return new StringBuilder() .append("DeleteJob") diff --git a/src/com/android/documentsui/services/FileOperation.java b/src/com/android/documentsui/services/FileOperation.java index 29393a81e..9ff16543d 100644 --- a/src/com/android/documentsui/services/FileOperation.java +++ b/src/com/android/documentsui/services/FileOperation.java @@ -261,7 +261,7 @@ public abstract class FileOperation implements Parcelable { getMessenger(), features); case OPERATION_DELETE: return new DeleteJob(service, listener, id, getDestination(), getSrc(), - mSrcParent, features); + getMessenger(), mSrcParent, features); default: throw new UnsupportedOperationException("Unsupported op type: " + getOpType()); } diff --git a/tests/common/com/android/documentsui/testing/TestEnv.java b/tests/common/com/android/documentsui/testing/TestEnv.java index 93cc032a1..d30453649 100644 --- a/tests/common/com/android/documentsui/testing/TestEnv.java +++ b/tests/common/com/android/documentsui/testing/TestEnv.java @@ -23,6 +23,7 @@ import android.test.mock.MockContentResolver; import com.android.documentsui.DocsSelectionHelper; import com.android.documentsui.FocusManager; import com.android.documentsui.Injector; +import com.android.documentsui.ModelId; import com.android.documentsui.SelectionHelpers; import com.android.documentsui.archives.ArchivesProvider; import com.android.documentsui.base.DocumentInfo; @@ -172,7 +173,8 @@ public class TestEnv { } public void populateStack() { - DocumentInfo rootDoc = model.getDocument("1"); + DocumentInfo rootDoc = model.getDocument( + ModelId.build(TestProvidersAccess.HOME.authority, "1")); // These are test setup sanity checks, not test assertions. assert rootDoc != null; @@ -193,7 +195,7 @@ public class TestEnv { public void selectDocument(DocumentInfo info) { List<String> ids = new ArrayList<>(1); - ids.add(info.documentId); + ids.add(ModelId.build(info.authority, info.documentId)); selectionMgr.setItemsSelected(ids, true); } diff --git a/tests/unit/com/android/documentsui/ModelTest.java b/tests/unit/com/android/documentsui/ModelTest.java index 8d3a33ba2..7d46f189d 100644 --- a/tests/unit/com/android/documentsui/ModelTest.java +++ b/tests/unit/com/android/documentsui/ModelTest.java @@ -22,10 +22,13 @@ import static junit.framework.Assert.fail; import android.database.Cursor; import android.database.MatrixCursor; import android.database.MergeCursor; +import android.net.Uri; import android.provider.DocumentsContract.Document; import android.support.test.filters.SmallTest; import android.support.test.runner.AndroidJUnit4; +import androidx.recyclerview.selection.MutableSelection; + import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.roots.RootCursorWrapper; import com.android.documentsui.testing.TestEventListener; @@ -35,7 +38,10 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.ArrayList; +import java.util.Arrays; import java.util.BitSet; +import java.util.List; import java.util.Random; @RunWith(AndroidJUnit4.class) @@ -180,4 +186,44 @@ public class ModelTest { assertEquals(0, model.getItemCount()); } + + @Test + public void testDeletion_showFailedItem() { + // set all items to be deleted. + List<String> idsTobeDeleted = Arrays.asList(model.getModelIds()); + MutableSelection<String> selection = new MutableSelection<>(); + for (String id : idsTobeDeleted) { + selection.add(id); + } + + // set the first item as the deletion failed one. + String failedId = idsTobeDeleted.get(0); + ArrayList<Uri> failedUris = new ArrayList<>(); + failedUris.add(model.getItemUri(failedId)); + + // mark items to be deleted + model.markDocumentsToBeDeleted(selection); + String[] ids = model.getModelIds(); + assertEquals(0, ids.length); + + // simulate deletion but keep the failed item. + cursor.moveToFirst(); + MatrixCursor c = new MatrixCursor(COLUMNS); + MatrixCursor.RowBuilder row = c.newRow(); + row.add(RootCursorWrapper.COLUMN_AUTHORITY, AUTHORITY); + row.add(Document.COLUMN_DOCUMENT_ID, 0); + row.add(Document.COLUMN_FLAGS, Document.FLAG_SUPPORTS_DELETE); + row.add(Document.COLUMN_DISPLAY_NAME, NAMES[0]); + row.add(Document.COLUMN_SIZE, cursor.getColumnIndex(Document.COLUMN_SIZE)); + DirectoryResult r = new DirectoryResult(); + r.cursor = c; + model.update(r); + + // simulate receiving failed uris + model.setDeletionFailedUris(selection, failedUris); + ids = model.getModelIds(); + + assertEquals(1, ids.length); + assertEquals(failedId, ids[0]); + } } diff --git a/tests/unit/com/android/documentsui/dirlist/DirectoryAddonsAdapterTest.java b/tests/unit/com/android/documentsui/dirlist/DirectoryAddonsAdapterTest.java index 0c26623f2..06912e13d 100644 --- a/tests/unit/com/android/documentsui/dirlist/DirectoryAddonsAdapterTest.java +++ b/tests/unit/com/android/documentsui/dirlist/DirectoryAddonsAdapterTest.java @@ -27,6 +27,7 @@ import android.view.ViewGroup; import com.android.documentsui.ActionHandler; import com.android.documentsui.Model; +import com.android.documentsui.ModelId; import com.android.documentsui.base.Features; import com.android.documentsui.base.State; import com.android.documentsui.testing.TestActionHandler; @@ -71,10 +72,10 @@ public class DirectoryAddonsAdapterTest extends AndroidTestCase { mEnv.model.createFile("b"); // id will be "2" mEnv.model.update(); - assertEquals(0, mAdapter.getPosition("1")); + assertEquals(0, mAdapter.getPosition(ModelId.build(AUTHORITY, "1"))); // adapter inserts a view between item 0 and 1 to force layout // break between folders and files. This is reflected by an offset position. - assertEquals(2, mAdapter.getPosition("2")); + assertEquals(2, mAdapter.getPosition(ModelId.build(AUTHORITY, "2"))); } // Tests that the item count is correct for a directory containing only subdirs. diff --git a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java index 00ca81bf0..4dfc64223 100644 --- a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java +++ b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java @@ -51,6 +51,7 @@ import android.view.DragEvent; import android.view.View; import com.android.documentsui.AbstractActionHandler; +import com.android.documentsui.ModelId; import com.android.documentsui.R; import com.android.documentsui.TestActionModeAddons; import com.android.documentsui.archives.ArchivesProvider; @@ -525,7 +526,8 @@ public class ActionHandlerTest { public void testRefresh() throws Exception { refreshAnswer = false; mEnv.populateStack(); - mHandler.refreshDocument(mEnv.model.getDocument("1"), (boolean answer) -> { + mHandler.refreshDocument(mEnv.model.getDocument( + ModelId.build(TestProvidersAccess.HOME.authority, "1")), (boolean answer) -> { refreshAnswer = answer; }); diff --git a/tests/unit/com/android/documentsui/services/AbstractJobTest.java b/tests/unit/com/android/documentsui/services/AbstractJobTest.java index 7ca913dda..ce850dd9c 100644 --- a/tests/unit/com/android/documentsui/services/AbstractJobTest.java +++ b/tests/unit/com/android/documentsui/services/AbstractJobTest.java @@ -94,6 +94,26 @@ public abstract class AbstractJobTest<T extends Job> extends AndroidTestCase { mDestRoot = mDocs.getRoot(ROOT_1_ID); } + FileOperation createOperation(@OpType int opType, List<Uri> srcs, Uri srcParent, + Uri destination) throws Exception { + DocumentStack stack = + new DocumentStack(mSrcRoot, DocumentInfo.fromUri(mResolver, destination)); + + UrisSupplier urisSupplier = DocsProviders.createDocsProvider(srcs); + FileOperation operation = new FileOperation.Builder() + .withOpType(opType) + .withSrcs(urisSupplier) + .withDestination(stack) + .withSrcParent(srcParent) + .build(); + return operation; + } + + final T createJob(FileOperation operation) { + return (T) operation.createJob( + mContext, mJobListener, FileOperations.createJobId(), mFeatures); + } + final T createJob(@OpType int opType, List<Uri> srcs, Uri srcParent, Uri destination) throws Exception { DocumentStack stack = diff --git a/tests/unit/com/android/documentsui/services/DeleteJobTest.java b/tests/unit/com/android/documentsui/services/DeleteJobTest.java index 0d8d39be8..e02b94aea 100644 --- a/tests/unit/com/android/documentsui/services/DeleteJobTest.java +++ b/tests/unit/com/android/documentsui/services/DeleteJobTest.java @@ -21,10 +21,15 @@ import static com.android.documentsui.services.FileOperationService.OPERATION_DE import static com.google.common.collect.Lists.newArrayList; import android.net.Uri; +import android.os.Handler; +import android.os.Message; import android.provider.DocumentsContract; import android.support.test.filters.MediumTest; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; @MediumTest public class DeleteJobTest extends AbstractJobTest<DeleteJob> { @@ -56,6 +61,43 @@ public class DeleteJobTest extends AbstractJobTest<DeleteJob> { mDocs.assertChildCount(mSrcRoot, 0); } + public void testDeleteFile_SendDeletionFailedUris() throws Exception { + Uri invalidUri1 = Uri.parse("content://poodles/chuckleberry/ham"); + Uri validUri = mDocs.createDocument(mSrcRoot, "text/plain", "test2.txt"); + Uri invalidUri2 = Uri.parse("content://poodles/chuckleberry/ham2"); + mDocs.writeDocument(validUri, FRUITY_BYTES); + + Uri stack = DocumentsContract.buildDocumentUri(AUTHORITY, mSrcRoot.documentId); + FileOperation operation = createOperation(OPERATION_DELETE, + newArrayList(invalidUri1, validUri, invalidUri2), + DocumentsContract.buildDocumentUri(AUTHORITY, mSrcRoot.documentId), stack); + + CountDownLatch latch = new CountDownLatch(1); + final ArrayList<Uri> deletionFailedUris = new ArrayList<>(); + operation.addMessageListener( + new Handler.Callback() { + @Override + public boolean handleMessage(Message message) { + if (message.what == FileOperationService.MESSAGE_FINISH) { + operation.removeMessageListener(this); + deletionFailedUris.addAll(message.getData() + .getParcelableArrayList(DeleteJob.KEY_FAILED_URIS)); + latch.countDown(); + return true; + } + return false; + } + } + ); + + createJob(operation).run(); + latch.await(10, TimeUnit.SECONDS); + + for (Uri uri : deletionFailedUris) { + assertTrue("Not receiving failed uri:" + uri, deletionFailedUris.contains(uri)); + } + } + /** * Creates a job with a stack consisting to the default src directory. */ |