diff options
-rw-r--r-- | Android.mk | 2 | ||||
-rw-r--r-- | res/values/tags.xml | 20 | ||||
-rw-r--r-- | src/com/android/documentsui/BaseActivity.java | 27 | ||||
-rw-r--r-- | src/com/android/documentsui/FilesActivity.java | 7 | ||||
-rw-r--r-- | src/com/android/documentsui/ItemDragListener.java | 153 | ||||
-rw-r--r-- | src/com/android/documentsui/Metrics.java | 5 | ||||
-rw-r--r-- | src/com/android/documentsui/RootsFragment.java | 120 | ||||
-rw-r--r-- | src/com/android/documentsui/dirlist/DirectoryDragListener.java | 45 | ||||
-rw-r--r-- | src/com/android/documentsui/dirlist/DirectoryFragment.java | 164 | ||||
-rw-r--r-- | tests/src/com/android/documentsui/ItemDragListenerTest.java | 183 | ||||
-rw-r--r-- | tests/src/com/android/documentsui/testing/ClipDatas.java | 33 | ||||
-rw-r--r-- | tests/src/com/android/documentsui/testing/DragEvents.java | 44 | ||||
-rw-r--r-- | tests/src/com/android/documentsui/testing/TestTimer.java | 134 | ||||
-rw-r--r-- | tests/src/com/android/documentsui/testing/TestViews.java | 37 |
14 files changed, 848 insertions, 126 deletions
diff --git a/Android.mk b/Android.mk index 568e200fb..9d44a6deb 100644 --- a/Android.mk +++ b/Android.mk @@ -36,7 +36,7 @@ LOCAL_JACK_FLAGS := \ # Only enable asserts on userdebug/eng builds ifneq (,$(filter userdebug eng, $(TARGET_BUILD_VARIANT))) -LOCAL_JACK_FLAGS += -D jack.assert.policy=enable +LOCAL_JACK_FLAGS += -D jack.assert.policy=always endif LOCAL_PACKAGE_NAME := DocumentsUI diff --git a/res/values/tags.xml b/res/values/tags.xml new file mode 100644 index 000000000..1c4b0ca8a --- /dev/null +++ b/res/values/tags.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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. +--> + +<resources> + <item name="drag_hovering_tag" type="id" /> + <item name="item_position_tag" type="id" /> +</resources>
\ No newline at end of file diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java index 50273afec..8ef4530d7 100644 --- a/src/com/android/documentsui/BaseActivity.java +++ b/src/com/android/documentsui/BaseActivity.java @@ -22,6 +22,7 @@ import static com.android.documentsui.State.ACTION_CREATE; import static com.android.documentsui.State.ACTION_GET_CONTENT; import static com.android.documentsui.State.ACTION_OPEN; import static com.android.documentsui.State.ACTION_OPEN_TREE; +import static com.android.documentsui.State.ACTION_PICK_COPY_DESTINATION; import static com.android.documentsui.State.MODE_GRID; import android.app.Activity; @@ -213,20 +214,27 @@ public abstract class BaseActivity extends Activity includeState(state); - // Advanced roots are shown by deafult without menu option if forced by config or intent. + // Advanced roots are shown by default without menu option if forced by config or intent. state.showAdvanced = Shared.shouldShowDeviceRoot(this, intent); // Menu option is shown for whitelisted intents if advanced roots are not shown by default. - state.showAdvancedOption = !state.showAdvanced && - (state.action == ACTION_OPEN || - state.action == ACTION_CREATE || - state.action == ACTION_OPEN_TREE || - state.action == ACTION_GET_CONTENT); + state.showAdvancedOption = !state.showAdvanced && ( + !directLaunch(intent) || + state.action == ACTION_OPEN || + state.action == ACTION_CREATE || + state.action == ACTION_OPEN_TREE || + state.action == ACTION_PICK_COPY_DESTINATION || + state.action == ACTION_GET_CONTENT); if (DEBUG) Log.d(mTag, "Created new state object: " + state); return state; } + private static boolean directLaunch(Intent intent) { + return LauncherActivity.isLaunchUri(intent.getData()) + && intent.hasExtra(Shared.EXTRA_STACK); + } + public void setRootsDrawerOpen(boolean open) { mNavigator.revealRootsDrawer(open); } @@ -390,6 +398,13 @@ public abstract class BaseActivity extends Activity } /** + * This is called when user hovers over a doc for enough time during a drag n' drop, to open a + * folder that accepts drop. We should only open a container that's not an archive. + */ + public void springOpenDirectory(DocumentInfo doc) { + } + + /** * Called when search results changed. * Refreshes the content of the directory. It doesn't refresh elements on the action bar. * e.g. The current directory name displayed on the action bar won't get updated. diff --git a/src/com/android/documentsui/FilesActivity.java b/src/com/android/documentsui/FilesActivity.java index f067d5fcf..60c37aafc 100644 --- a/src/com/android/documentsui/FilesActivity.java +++ b/src/com/android/documentsui/FilesActivity.java @@ -312,6 +312,13 @@ public class FilesActivity extends BaseActivity { } } + @Override + public void springOpenDirectory(DocumentInfo doc) { + assert(doc.isContainer()); + assert(!doc.isArchive()); + openContainerDocument(doc); + } + /** * Launches an intent to view the specified document. */ diff --git a/src/com/android/documentsui/ItemDragListener.java b/src/com/android/documentsui/ItemDragListener.java new file mode 100644 index 000000000..2c018f882 --- /dev/null +++ b/src/com/android/documentsui/ItemDragListener.java @@ -0,0 +1,153 @@ +/* + * 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 android.content.ClipData; +import android.util.Log; +import android.view.DragEvent; +import android.view.View; +import android.view.View.OnDragListener; +import android.view.ViewConfiguration; + +import com.android.documentsui.ItemDragListener.DragHost; +import com.android.internal.annotations.VisibleForTesting; + +import java.util.Timer; +import java.util.TimerTask; + +/** + * An {@link OnDragListener} that adds support for "spring loading views". Use this when you want + * items to pop-open when user hovers on them during a drag n drop. + */ +public class ItemDragListener<H extends DragHost> implements OnDragListener { + + private static final String TAG = "ItemDragListener"; + + @VisibleForTesting + static final int SPRING_TIMEOUT = ViewConfiguration.getLongPressTimeout(); + + protected final H mDragHost; + private final Timer mHoverTimer; + + public ItemDragListener(H dragHost) { + this(dragHost, new Timer()); + } + + @VisibleForTesting + protected ItemDragListener(H dragHost, Timer timer) { + mDragHost = dragHost; + mHoverTimer = timer; + } + + @Override + public boolean onDrag(final View v, DragEvent event) { + switch (event.getAction()) { + case DragEvent.ACTION_DRAG_STARTED: + return true; + case DragEvent.ACTION_DRAG_ENTERED: + handleEnteredEvent(v); + return true; + case DragEvent.ACTION_DRAG_LOCATION: + return true; + case DragEvent.ACTION_DRAG_EXITED: + case DragEvent.ACTION_DRAG_ENDED: + handleExitedEndedEvent(v); + return true; + case DragEvent.ACTION_DROP: + return handleDropEvent(v, event); + } + + return false; + } + + private void handleEnteredEvent(View v) { + mDragHost.setDropTargetHighlight(v, true); + + TimerTask task = createOpenTask(v); + assert (task != null); + v.setTag(R.id.drag_hovering_tag, task); + mHoverTimer.schedule(task, ViewConfiguration.getLongPressTimeout()); + } + + private void handleExitedEndedEvent(View v) { + mDragHost.setDropTargetHighlight(v, false); + + TimerTask task = (TimerTask) v.getTag(R.id.drag_hovering_tag); + if (task != null) { + task.cancel(); + } + } + + private boolean handleDropEvent(View v, DragEvent event) { + ClipData clipData = event.getClipData(); + if (clipData == null) { + Log.w(TAG, "Received invalid drop event with null clipdata. Ignoring."); + return false; + } + + return handleDropEventChecked(v, event); + } + + @VisibleForTesting + TimerTask createOpenTask(final View v) { + TimerTask task = new TimerTask() { + @Override + public void run() { + mDragHost.runOnUiThread(() -> { + mDragHost.onViewHovered(v); + }); + } + }; + return task; + } + + /** + * Handles a drop event. Override it if you want to do something on drop event. It's called when + * {@link DragEvent#ACTION_DROP} happens. ClipData in DragEvent is guaranteed not null. + * + * @param v The view where user drops. + * @param event the drag event. + * @return true if this event is consumed; false otherwise + */ + public boolean handleDropEventChecked(View v, DragEvent event) { + return false; // we didn't handle the drop + } + + /** + * An interface {@link ItemDragListener} uses to make some callbacks. + */ + public interface DragHost { + + /** + * Runs this runnable in main thread. + */ + void runOnUiThread(Runnable runnable); + + /** + * Highlights/unhighlights the view to visually indicate this view is being hovered. + * @param v the view being hovered + * @param highlight true if highlight the view; false if unhighlight it + */ + void setDropTargetHighlight(View v, boolean highlight); + + /** + * Notifies hovering timeout has elapsed + * @param v the view being hovered + */ + void onViewHovered(View v); + } +} diff --git a/src/com/android/documentsui/Metrics.java b/src/com/android/documentsui/Metrics.java index 79123d012..196b0cdea 100644 --- a/src/com/android/documentsui/Metrics.java +++ b/src/com/android/documentsui/Metrics.java @@ -29,7 +29,6 @@ import android.content.pm.ResolveInfo; import android.net.Uri; import android.provider.DocumentsContract; import android.util.Log; -import android.view.KeyEvent; import com.android.documentsui.State.ActionType; import com.android.documentsui.model.DocumentInfo; @@ -349,7 +348,7 @@ public final class Metrics { } /** - * Logs a root visited event. Call this when the user clicks on a root in the RootsFragment. + * Logs a root visited event. Call this when the user visits on a root in the RootsFragment. * * @param context * @param info @@ -359,7 +358,7 @@ public final class Metrics { } /** - * Logs an app visited event. Call this when the user clicks on an app in the RootsFragment. + * Logs an app visited event. Call this when the user visits on an app in the RootsFragment. * * @param context * @param info diff --git a/src/com/android/documentsui/RootsFragment.java b/src/com/android/documentsui/RootsFragment.java index a98b5d037..8f0113c1a 100644 --- a/src/com/android/documentsui/RootsFragment.java +++ b/src/com/android/documentsui/RootsFragment.java @@ -31,12 +31,14 @@ import android.content.pm.ResolveInfo; import android.net.Uri; import android.os.Bundle; import android.provider.Settings; +import android.support.annotation.ColorRes; import android.support.annotation.Nullable; import android.text.TextUtils; import android.text.format.Formatter; import android.util.Log; import android.view.LayoutInflater; import android.view.View; +import android.view.View.OnDragListener; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; @@ -58,7 +60,7 @@ import java.util.Objects; /** * Display list of known storage backend roots. */ -public class RootsFragment extends Fragment { +public class RootsFragment extends Fragment implements ItemDragListener.DragHost { private static final String TAG = "RootsFragment"; private static final String EXTRA_INCLUDE_APPS = "includeApps"; @@ -67,7 +69,6 @@ public class RootsFragment extends Fragment { private RootsAdapter mAdapter; private LoaderCallbacks<Collection<RootInfo>> mCallbacks; - public static void show(FragmentManager fm, Intent includeApps) { final Bundle args = new Bundle(); args.putParcelable(EXTRA_INCLUDE_APPS, includeApps); @@ -118,7 +119,8 @@ public class RootsFragment extends Fragment { Intent handlerAppIntent = getArguments().getParcelable(EXTRA_INCLUDE_APPS); - mAdapter = new RootsAdapter(context, result, handlerAppIntent, state); + mAdapter = new RootsAdapter(context, result, handlerAppIntent, state, + new ItemDragListener<>(RootsFragment.this)); mList.setAdapter(mAdapter); onCurrentRootChanged(); @@ -184,25 +186,53 @@ public class RootsFragment extends Fragment { startActivity(intent); } + private void openItem(int position) { + Item item = mAdapter.getItem(position); + if (item instanceof RootItem) { + BaseActivity activity = BaseActivity.get(this); + RootInfo newRoot = ((RootItem) item).root; + Metrics.logRootVisited(getActivity(), newRoot); + activity.onRootPicked(newRoot); + } else if (item instanceof AppItem) { + DocumentsActivity activity = DocumentsActivity.get(this); + ResolveInfo info = ((AppItem) item).info; + Metrics.logAppVisited(getActivity(), info); + activity.onAppPicked(info); + } else if (item instanceof SpacerItem) { + if (DEBUG) Log.d(TAG, "Ignoring click/hover on spacer item."); + } else { + throw new IllegalStateException("Unknown root: " + item); + } + } + + @Override + public void runOnUiThread(Runnable runnable) { + getActivity().runOnUiThread(runnable); + } + + /** + * {@inheritDoc} + * + * In RootsFragment we open the hovered root. + */ + @Override + public void onViewHovered(View view) { + int position = (Integer) view.getTag(R.id.item_position_tag); + openItem(position); + } + + @Override + public void setDropTargetHighlight(View v, boolean highlight) { + @ColorRes int colorId = highlight ? R.color.item_doc_background_selected + : android.R.color.transparent; + + v.setBackgroundColor(getActivity().getColor(colorId)); + } + private OnItemClickListener mItemListener = new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { - Item item = mAdapter.getItem(position); - if (item instanceof RootItem) { - BaseActivity activity = BaseActivity.get(RootsFragment.this); - RootInfo newRoot = ((RootItem) item).root; - Metrics.logRootVisited(getActivity(), newRoot); - activity.onRootPicked(newRoot); - } else if (item instanceof AppItem) { - DocumentsActivity activity = DocumentsActivity.get(RootsFragment.this); - ResolveInfo info = ((AppItem) item).info; - Metrics.logAppVisited(getActivity(), info); - activity.onAppPicked(info); - } else if (item instanceof SpacerItem) { - if (DEBUG) Log.d(TAG, "Ignoring click on spacer item."); - } else { - throw new IllegalStateException("Unknown root: " + item); - } + openItem(position); } }; @@ -236,7 +266,9 @@ public class RootsFragment extends Fragment { return convertView; } - public abstract void bindView(View convertView); + abstract void bindView(View convertView); + + abstract boolean isDropTarget(); } private static class RootItem extends Item { @@ -267,6 +299,11 @@ public class RootsFragment extends Fragment { summary.setText(summaryText); summary.setVisibility(TextUtils.isEmpty(summaryText) ? View.GONE : View.VISIBLE); } + + @Override + boolean isDropTarget() { + return root.supportsCreate() && !root.isLibrary(); + } } private static class SpacerItem extends Item { @@ -275,9 +312,14 @@ public class RootsFragment extends Fragment { } @Override - public void bindView(View convertView) { + void bindView(View convertView) { // Nothing to bind } + + @Override + boolean isDropTarget() { + return false; + } } private static class AppItem extends Item { @@ -289,7 +331,7 @@ public class RootsFragment extends Fragment { } @Override - public void bindView(View convertView) { + void bindView(View convertView) { final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon); final TextView title = (TextView) convertView.findViewById(android.R.id.title); final TextView summary = (TextView) convertView.findViewById(android.R.id.summary); @@ -301,16 +343,24 @@ public class RootsFragment extends Fragment { // TODO: match existing summary behavior from disambig dialog summary.setVisibility(View.GONE); } + + @Override + boolean isDropTarget() { + // We won't support drag n' drop in DocumentsActivity, and apps only show up there. + return false; + } } private static class RootsAdapter extends ArrayAdapter<Item> { + private OnDragListener mDragListener; + /** - * @param handlerAppIntent When not null, apps capable of handling the original - * intent will be included in list of roots (in special section at bottom). + * @param handlerAppIntent When not null, apps capable of handling the original intent will + * be included in list of roots (in special section at bottom). */ public RootsAdapter(Context context, Collection<RootInfo> roots, - @Nullable Intent handlerAppIntent, State state) { + @Nullable Intent handlerAppIntent, State state, OnDragListener dragListener) { super(context, 0); final List<RootItem> libraries = new ArrayList<>(); @@ -320,7 +370,8 @@ public class RootsFragment extends Fragment { final RootItem item = new RootItem(root); if (root.isHome() && - !Shared.shouldShowDocumentsRoot(context, ((Activity) context).getIntent())) { + !Shared.shouldShowDocumentsRoot(context, + ((Activity) context).getIntent())) { continue; } else if (root.isLibrary()) { if (DEBUG) Log.d(TAG, "Adding " + root + " as library."); @@ -346,11 +397,13 @@ public class RootsFragment extends Fragment { if (handlerAppIntent != null) { includeHandlerApps(context, handlerAppIntent); } + + mDragListener = dragListener; } /** - * Adds apps capable of handling the original intent will be included - * in list of roots (in special section at bottom). + * Adds apps capable of handling the original intent will be included in list of roots (in + * special section at bottom). */ private void includeHandlerApps(Context context, Intent handlerAppIntent) { final PackageManager pm = context.getPackageManager(); @@ -375,7 +428,16 @@ public class RootsFragment extends Fragment { @Override public View getView(int position, View convertView, ViewGroup parent) { final Item item = getItem(position); - return item.getView(convertView, parent); + final View view = item.getView(convertView, parent); + + if (item.isDropTarget()) { + view.setTag(R.id.item_position_tag, position); + view.setOnDragListener(mDragListener); + } else { + view.setTag(R.id.item_position_tag, null); + view.setOnDragListener(null); + } + return view; } @Override diff --git a/src/com/android/documentsui/dirlist/DirectoryDragListener.java b/src/com/android/documentsui/dirlist/DirectoryDragListener.java new file mode 100644 index 000000000..e8361a1c9 --- /dev/null +++ b/src/com/android/documentsui/dirlist/DirectoryDragListener.java @@ -0,0 +1,45 @@ +/* + * 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.dirlist; + +import android.view.DragEvent; +import android.view.View; + +import com.android.documentsui.ItemDragListener; + +class DirectoryDragListener extends ItemDragListener<DirectoryFragment> { + + DirectoryDragListener(DirectoryFragment fragment) { + super(fragment); + } + + @Override + public boolean onDrag(View v, DragEvent event) { + final boolean result = super.onDrag(v, event); + + if (event.getAction() == DragEvent.ACTION_DRAG_ENDED && event.getResult()) { + mDragHost.clearSelection(); + } + + return result; + } + + @Override + public boolean handleDropEventChecked(View v, DragEvent event) { + return mDragHost.handleDropEvent(v, event); + } +} diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java index c41a106c5..eec12c890 100644 --- a/src/com/android/documentsui/dirlist/DirectoryFragment.java +++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java @@ -24,6 +24,8 @@ import static com.android.documentsui.State.SORT_ORDER_UNKNOWN; import static com.android.documentsui.model.DocumentInfo.getCursorInt; import static com.android.documentsui.model.DocumentInfo.getCursorString; +import com.google.common.collect.Lists; + import android.annotation.IntDef; import android.annotation.StringRes; import android.app.Activity; @@ -48,7 +50,6 @@ import android.os.AsyncTask; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; -import android.os.PersistableBundle; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import android.support.annotation.Nullable; @@ -87,6 +88,7 @@ import com.android.documentsui.DocumentsActivity; import com.android.documentsui.DocumentsApplication; import com.android.documentsui.Events; import com.android.documentsui.Events.MotionInputEvent; +import com.android.documentsui.ItemDragListener; import com.android.documentsui.Menus; import com.android.documentsui.MessageBar; import com.android.documentsui.Metrics; @@ -106,8 +108,6 @@ import com.android.documentsui.services.FileOperationService; import com.android.documentsui.services.FileOperationService.OpType; import com.android.documentsui.services.FileOperations; -import com.google.common.collect.Lists; - import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; @@ -121,7 +121,8 @@ import java.util.Set; * Display the documents inside a single directory. */ public class DirectoryFragment extends Fragment - implements DocumentsAdapter.Environment, LoaderCallbacks<DirectoryResult> { + implements DocumentsAdapter.Environment, LoaderCallbacks<DirectoryResult>, + ItemDragListener.DragHost { @IntDef(flag = true, value = { TYPE_NORMAL, @@ -172,10 +173,13 @@ public class DirectoryFragment extends Fragment private RootInfo mRoot; private DocumentInfo mDocument; private String mQuery = null; + // Save selection found during creation so it can be restored during directory loading. private Selection mSelection = null; private boolean mSearchMode = false; private @Nullable ActionMode mActionMode; + private DirectoryDragListener mOnDragListener; + @Override public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -196,6 +200,8 @@ public class DirectoryFragment extends Fragment mRecView.setItemAnimator(new DirectoryItemAnimator(getActivity())); + mOnDragListener = new DirectoryDragListener(this); + // Make the recycler and the empty views responsive to drop events. mRecView.setOnDragListener(mOnDragListener); mEmptyView.setOnDragListener(mOnDragListener); @@ -700,7 +706,7 @@ public class DirectoryFragment extends Fragment public final boolean onBackPressed() { if (mSelectionManager.hasSelection()) { - if (DEBUG) Log.d(TAG, "Clearing selection on back pressed."); + if (DEBUG) Log.d(TAG, "Clearing selection on selection manager."); mSelectionManager.clearSelection(); return true; } @@ -1252,103 +1258,86 @@ public class DirectoryFragment extends Fragment } } - private View.OnDragListener mOnDragListener = new View.OnDragListener() { - @Override - public boolean onDrag(View v, DragEvent event) { - switch (event.getAction()) { - case DragEvent.ACTION_DRAG_STARTED: - // TODO: Check if the event contains droppable data. - return true; + public void clearSelection() { + mSelectionManager.clearSelection(); + } - // TODO: Expand drop target directory on hover? - case DragEvent.ACTION_DRAG_ENTERED: - setDropTargetHighlight(v, true); - return true; - case DragEvent.ACTION_DRAG_EXITED: - setDropTargetHighlight(v, false); - return true; + @Override + public void runOnUiThread(Runnable runnable) { + getActivity().runOnUiThread(runnable); + } - case DragEvent.ACTION_DRAG_LOCATION: - return true; + /** + * {@inheritDoc} + * + * In DirectoryFragment, we spring loads the hovered folder. + */ + @Override + public void onViewHovered(View view) { + if (getModelId(view) != null) { + ((BaseActivity) getActivity()).springOpenDirectory(getDestination(view)); + } + } - case DragEvent.ACTION_DRAG_ENDED: - // After a drop event, always stop highlighting the target. - setDropTargetHighlight(v, false); - if (event.getResult()) { - // Exit selection mode if the drop was handled. - mSelectionManager.clearSelection(); - } - return true; + public boolean handleDropEvent(View v, DragEvent event) { + ClipData clipData = event.getClipData(); + assert (clipData != null); - case DragEvent.ACTION_DROP: - return handleDropEvent(v, event); - } + ClipDetails clipDetails = mClipper.getClipDetails(clipData); + assert(clipDetails.opType == FileOperationService.OPERATION_COPY); + + // Don't copy from the cwd into the cwd. Note: this currently doesn't work for + // multi-window drag, because localState isn't carried over from one process to + // another. + Object src = event.getLocalState(); + DocumentInfo dst = getDestination(v); + if (Objects.equals(src, dst)) { + if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring."); return false; } - private boolean handleDropEvent(View v, DragEvent event) { + // Recognize multi-window drag and drop based on the fact that localState is not + // carried between processes. It will stop working when the localsState behavior + // is changed. The info about window should be passed in the localState then. + // The localState could also be null for copying from Recents in single window + // mode, but Recents doesn't offer this functionality (no directories). + Metrics.logUserAction(getContext(), + src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW + : Metrics.USER_ACTION_DRAG_N_DROP); - ClipData clipData = event.getClipData(); - if (clipData == null) { - Log.w(TAG, "Received invalid drop event with null clipdata. Ignoring."); - return false; - } - - ClipDetails clipDetails = mClipper.getClipDetails(clipData); - assert(clipDetails.opType == FileOperationService.OPERATION_COPY); + copyFromClipData(clipData, dst); + return true; + } - // Don't copy from the cwd into the cwd. Note: this currently doesn't work for - // multi-window drag, because localState isn't carried over from one process to - // another. - Object src = event.getLocalState(); - DocumentInfo dst = getDestination(v); - if (Objects.equals(src, dst)) { - if (DEBUG) Log.d(TAG, "Drop target same as source. Ignoring."); - return false; + private DocumentInfo getDestination(View v) { + String id = getModelId(v); + if (id != null) { + Cursor dstCursor = mModel.getItem(id); + if (dstCursor == null) { + Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id); + return null; } - - // Recognize multi-window drag and drop based on the fact that localState is not - // carried between processes. It will stop working when the localsState behavior - // is changed. The info about window should be passed in the localState then. - // The localState could also be null for copying from Recents in single window - // mode, but Recents doesn't offer this functionality (no directories). - Metrics.logUserAction(getContext(), - src == null ? Metrics.USER_ACTION_DRAG_N_DROP_MULTI_WINDOW - : Metrics.USER_ACTION_DRAG_N_DROP); - - copyFromClipData(clipData, dst); - return true; + return DocumentInfo.fromDirectoryCursor(dstCursor); } - private DocumentInfo getDestination(View v) { - String id = getModelId(v); - if (id != null) { - Cursor dstCursor = mModel.getItem(id); - if (dstCursor == null) { - Log.w(TAG, "Invalid destination. Can't obtain cursor for modelId: " + id); - return null; - } - return DocumentInfo.fromDirectoryCursor(dstCursor); - } - - if (v == mRecView || v == mEmptyView) { - return getDisplayState().stack.peek(); - } - - return null; + if (v == mRecView || v == mEmptyView) { + return getDisplayState().stack.peek(); } - private void setDropTargetHighlight(View v, boolean highlight) { - // Note: use exact comparison - this code is searching for views which are children of - // the RecyclerView instance in the UI. - if (v.getParent() == mRecView) { - RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v); - if (vh instanceof DocumentHolder) { - ((DocumentHolder) vh).setHighlighted(highlight); - } + return null; + } + + @Override + public void setDropTargetHighlight(View v, boolean highlight) { + // Note: use exact comparison - this code is searching for views which are children of + // the RecyclerView instance in the UI. + if (v.getParent() == mRecView) { + RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(v); + if (vh instanceof DocumentHolder) { + ((DocumentHolder) vh).setHighlighted(highlight); } } - }; + } /** * Gets the model ID for a given motion event (using the event position) @@ -1372,7 +1361,7 @@ public class DirectoryFragment extends Fragment * @return The Model ID for the given document, or null if the given view is not associated with * a document item view. */ - private String getModelId(View view) { + protected String getModelId(View view) { View itemView = mRecView.findContainingItemView(view); if (itemView != null) { RecyclerView.ViewHolder vh = mRecView.getChildViewHolder(itemView); @@ -1875,6 +1864,7 @@ public class DirectoryFragment extends Fragment if (mSelection != null) { mSelectionManager.setItemsSelected(mSelection.toList(), true); + mSelection.clear(); } // Restore any previous instance state diff --git a/tests/src/com/android/documentsui/ItemDragListenerTest.java b/tests/src/com/android/documentsui/ItemDragListenerTest.java new file mode 100644 index 000000000..050b8c8e9 --- /dev/null +++ b/tests/src/com/android/documentsui/ItemDragListenerTest.java @@ -0,0 +1,183 @@ +/* + * 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 android.content.ClipData; +import android.view.DragEvent; +import android.view.View; + +import com.android.documentsui.testing.ClipDatas; +import com.android.documentsui.testing.DragEvents; +import com.android.documentsui.testing.TestTimer; +import com.android.documentsui.testing.TestViews; + +import junit.framework.TestCase; + +import org.junit.Test; + +import java.util.Timer; +import java.util.TimerTask; + +public class ItemDragListenerTest extends TestCase { + + private static final long DELAY_AFTER_HOVERING = ItemDragListener.SPRING_TIMEOUT + 1; + + private View mTestView; + private TestDragHost mTestDragHost; + private TestTimer mTestTimer; + + private TestDragListener mListener; + + @Override + public void setUp() { + mTestView = TestViews.createTestView(); + + mTestTimer = new TestTimer(); + mTestDragHost = new TestDragHost(); + mListener = new TestDragListener(mTestDragHost, mTestTimer); + } + + @Test + public void testDragStarted_ReturnsTrue() { + assertTrue(triggerDragEvent(DragEvent.ACTION_DRAG_STARTED)); + } + + @Test + public void testDragEntered_HighlightsView() { + triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED); + assertSame(mTestView, mTestDragHost.mHighlightedView); + } + + @Test + public void testDragExited_UnhighlightsView() { + triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED); + + triggerDragEvent(DragEvent.ACTION_DRAG_EXITED); + assertNull(mTestDragHost.mHighlightedView); + } + + @Test + public void testDragEnded_UnhighlightsView() { + triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED); + + triggerDragEvent(DragEvent.ACTION_DRAG_ENDED); + assertNull(mTestDragHost.mHighlightedView); + } + + @Test + public void testHover_OpensView() { + triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED); + + mTestTimer.fastForwardTo(DELAY_AFTER_HOVERING); + + assertSame(mTestView, mTestDragHost.mLastOpenedView); + } + + @Test + public void testDragExited_CancelsHoverTask() { + triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED); + + triggerDragEvent(DragEvent.ACTION_DRAG_EXITED); + + mTestTimer.fastForwardTo(DELAY_AFTER_HOVERING); + + assertNull(mTestDragHost.mLastOpenedView); + } + + @Test + public void testDragEnded_CancelsHoverTask() { + triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED); + + triggerDragEvent(DragEvent.ACTION_DRAG_ENDED); + + mTestTimer.fastForwardTo(DELAY_AFTER_HOVERING); + + assertNull(mTestDragHost.mLastOpenedView); + } + + @Test + public void testNoDropWithoutClipData() { + triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED); + + final DragEvent dropEvent = DragEvents.createTestDropEvent(null); + assertFalse(mListener.onDrag(mTestView, dropEvent)); + } + + @Test + public void testDoDropWithClipData() { + triggerDragEvent(DragEvent.ACTION_DRAG_ENTERED); + + final ClipData data = ClipDatas.createTestClipData(); + final DragEvent dropEvent = DragEvents.createTestDropEvent(data); + mListener.onDrag(mTestView, dropEvent); + + assertSame(mTestView, mListener.mLastDropOnView); + assertSame(dropEvent, mListener.mLastDropEvent); + } + + protected boolean triggerDragEvent(int actionId) { + final DragEvent testEvent = DragEvents.createTestDragEvent(actionId); + + return mListener.onDrag(mTestView, testEvent); + } + + private static class TestDragListener extends ItemDragListener<TestDragHost> { + + private View mLastDropOnView; + private DragEvent mLastDropEvent; + + protected TestDragListener(TestDragHost dragHost, Timer timer) { + super(dragHost, timer); + } + + @Override + public TimerTask createOpenTask(View v) { + TimerTask task = super.createOpenTask(v); + TestTimer.Task testTask = new TestTimer.Task(task); + + return testTask; + } + + @Override + public boolean handleDropEventChecked(View v, DragEvent event) { + mLastDropOnView = v; + mLastDropEvent = event; + return true; + } + + } + + private static class TestDragHost implements ItemDragListener.DragHost { + private View mHighlightedView; + private View mLastOpenedView; + + @Override + public void setDropTargetHighlight(View v, boolean highlight) { + mHighlightedView = highlight ? v : null; + } + + @Override + public void runOnUiThread(Runnable runnable) { + runnable.run(); + } + + @Override + public void onViewHovered(View v) { + mLastOpenedView = v; + } + } +} diff --git a/tests/src/com/android/documentsui/testing/ClipDatas.java b/tests/src/com/android/documentsui/testing/ClipDatas.java new file mode 100644 index 000000000..6536fb9d2 --- /dev/null +++ b/tests/src/com/android/documentsui/testing/ClipDatas.java @@ -0,0 +1,33 @@ +/* + * 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.testing; + +import android.content.ClipData; + +import org.mockito.Mockito; + +/** + * Test support for working with {@link ClipData} instances. + */ +public final class ClipDatas { + + public static ClipData createTestClipData() { + ClipData data = Mockito.mock(ClipData.class); + + return data; + } +} diff --git a/tests/src/com/android/documentsui/testing/DragEvents.java b/tests/src/com/android/documentsui/testing/DragEvents.java new file mode 100644 index 000000000..e835cafb3 --- /dev/null +++ b/tests/src/com/android/documentsui/testing/DragEvents.java @@ -0,0 +1,44 @@ +/* + * 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.testing; + +import android.content.ClipData; +import android.view.DragEvent; + +import org.mockito.Mockito; + +/** + * Test support for working with {@link DragEvents} instances. + */ +public final class DragEvents { + + private DragEvents() {} + + public static DragEvent createTestDragEvent(int actionId) { + final DragEvent mockEvent = Mockito.mock(DragEvent.class); + Mockito.when(mockEvent.getAction()).thenReturn(actionId); + + return mockEvent; + } + + public static DragEvent createTestDropEvent(ClipData clipData) { + final DragEvent dropEvent = createTestDragEvent(DragEvent.ACTION_DROP); + Mockito.when(dropEvent.getClipData()).thenReturn(clipData); + + return dropEvent; + } +} diff --git a/tests/src/com/android/documentsui/testing/TestTimer.java b/tests/src/com/android/documentsui/testing/TestTimer.java new file mode 100644 index 000000000..428e8bd18 --- /dev/null +++ b/tests/src/com/android/documentsui/testing/TestTimer.java @@ -0,0 +1,134 @@ +/* + * 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.testing; + +import java.util.Date; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.ListIterator; +import java.util.Timer; +import java.util.TimerTask; + +/** + * A {@link Timer} for testing that can dial its clock hands to any future time. + */ +public class TestTimer extends Timer { + + private long mNow = 0; + + private final LinkedList<Task> mTaskList = new LinkedList<>(); + + public void fastForwardTo(long time) { + if (time < mNow) { + throw new IllegalArgumentException("Can't fast forward to past."); + } + + mNow = time; + while (!mTaskList.isEmpty() && mTaskList.getFirst().mExecuteTime <= mNow) { + Task task = mTaskList.getFirst(); + if (!task.isCancelled()) { + task.run(); + } + mTaskList.removeFirst(); + } + } + + @Override + public void cancel() { + mTaskList.clear(); + } + + @Override + public int purge() { + int count = 0; + Iterator<Task> iter = mTaskList.iterator(); + while (iter.hasNext()) { + Task task = iter.next(); + if (task.isCancelled()) { + iter.remove(); + ++count; + } + } + return count; + } + + @Override + public void schedule(TimerTask task, Date time) { + throw new UnsupportedOperationException(); + } + + @Override + public void schedule(TimerTask task, Date firstTime, long period) { + throw new UnsupportedOperationException(); + } + + @Override + public void schedule(TimerTask task, long delay) { + long executeTime = mNow + delay; + Task testTimerTask = (Task) task; + testTimerTask.mExecuteTime = executeTime; + + ListIterator<Task> iter = mTaskList.listIterator(0); + while (iter.hasNext()) { + if (iter.next().mExecuteTime >= executeTime) { + break; + } + } + iter.add(testTimerTask); + } + + @Override + public void schedule(TimerTask task, long delay, long period) { + throw new UnsupportedOperationException(); + } + + @Override + public void scheduleAtFixedRate(TimerTask task, Date firstTime, long period) { + throw new UnsupportedOperationException(); + } + + @Override + public void scheduleAtFixedRate(TimerTask task, long delay, long period) { + throw new UnsupportedOperationException(); + } + + public static class Task extends TimerTask { + private boolean mIsCancelled; + private long mExecuteTime; + + private TimerTask mDelegate; + + public Task(TimerTask delegate) { + mDelegate = delegate; + } + + @Override + public boolean cancel() { + mIsCancelled = true; + return mDelegate.cancel(); + } + + @Override + public void run() { + mDelegate.run(); + } + + boolean isCancelled() { + return mIsCancelled; + } + } +} diff --git a/tests/src/com/android/documentsui/testing/TestViews.java b/tests/src/com/android/documentsui/testing/TestViews.java new file mode 100644 index 000000000..dd2bfec98 --- /dev/null +++ b/tests/src/com/android/documentsui/testing/TestViews.java @@ -0,0 +1,37 @@ +/* + * 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.testing; + +import android.view.View; + +import org.mockito.Mockito; + +/** + * Test support for working with {@link TestViews} instances. + */ +public final class TestViews { + + private TestViews() {} + + public static View createTestView() { + View view = Mockito.mock(View.class); + Mockito.doCallRealMethod().when(view).setTag(Mockito.anyInt(), Mockito.any()); + Mockito.doCallRealMethod().when(view).getTag(Mockito.anyInt()); + + return view; + } +} |