diff options
author | 2018-08-08 18:54:14 +0800 | |
---|---|---|
committer | 2018-12-11 13:31:26 +0800 | |
commit | 282baa4f69a76091974e547a316fc65b762e3569 (patch) | |
tree | 79c453136ba3d0c831371f45fe91c896c2347414 | |
parent | 52b0b2828c79345fd01cb3d39ff634897bd6b29f (diff) |
Do the refactor in RecentsLoader and support global search
1. Do refactor in RecentsLoader.
2. Create MultiRootDocumentsLoader extracted from RecentsLoader.
3. Support only query Root.FLAG_LOCAL_ONLY roots in RecentsLoader.
4. Add GlobalSearchLoader to support global search in Recents root.
Bug: 111695441
Fix: 119109832
Test: atest DocumentsUITests
Change-Id: I19df6d58a7d1df042eef925575dc8d56cf81fa09
10 files changed, 907 insertions, 359 deletions
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java index 828e14ea1..a956b1717 100644 --- a/src/com/android/documentsui/AbstractActionHandler.java +++ b/src/com/android/documentsui/AbstractActionHandler.java @@ -567,14 +567,29 @@ public abstract class AbstractActionHandler<T extends FragmentActivity & CommonA if (mState.stack.isRecents()) { - if (DEBUG) Log.d(TAG, "Creating new loader recents."); - return new RecentsLoader( - context, - mProviders, - mState, - mInjector.features, - mExecutors, - mInjector.fileTypeLookup); + if (mSearchMgr.isSearching()) { + if (DEBUG) { + Log.d(TAG, "Creating new GlobalSearchloader."); + } + + return new GlobalSearchLoader( + context, + mProviders, + mState, + mExecutors, + mInjector.fileTypeLookup, + mSearchMgr.getCurrentSearch()); + } else { + if (DEBUG) { + Log.d(TAG, "Creating new loader recents."); + } + return new RecentsLoader( + context, + mProviders, + mState, + mExecutors, + mInjector.fileTypeLookup); + } } else { Uri contentsUri = mSearchMgr.isSearching() diff --git a/src/com/android/documentsui/GlobalSearchLoader.java b/src/com/android/documentsui/GlobalSearchLoader.java new file mode 100644 index 000000000..364d5ebc4 --- /dev/null +++ b/src/com/android/documentsui/GlobalSearchLoader.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2018 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.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.DocumentsContract; + +import androidx.annotation.NonNull; + +import com.android.documentsui.base.Lookup; +import com.android.documentsui.base.RootInfo; +import com.android.documentsui.base.State; +import com.android.documentsui.roots.ProvidersAccess; +import com.android.documentsui.roots.RootCursorWrapper; + +import java.util.List; +import java.util.concurrent.Executor; + +/* + * The class to query multiple roots support {@link DocumentsContract.Root#FLAG_LOCAL_ONLY} + * and {@link DocumentsContract.Root#FLAG_SUPPORTS_SEARCH} from + * {@link android.provider.DocumentsProvider}. + */ +public class GlobalSearchLoader extends MultiRootDocumentsLoader { + private final String mSearchString; + + /* + * Create the loader to query multiple roots support + * {@link DocumentsContract.Root#FLAG_LOCAL_ONLY} and + * {@link DocumentsContract.Root#FLAG_SUPPORTS_SEARCH} from + * {@link android.provider.DocumentsProvider}. + * + * @param context the context + * @param providers the providers + * @param state current state + * @param features the feature flags + * @param executors the executors of authorities + * @param fileTypeMap the map of mime types and file types. + * @param searchString the string for searching + */ + public GlobalSearchLoader(Context context, ProvidersAccess providers, State state, + Lookup<String, Executor> executors, Lookup<String, String> fileTypeMap, + String searchString) { + super(context, providers, state, executors, fileTypeMap); + mSearchString = searchString; + } + + @Override + protected boolean shouldIgnoreRoot(RootInfo root) { + // Only support local search in GlobalSearchLoader + if (!root.isLocalOnly() || !root.supportsSearch()) { + return true; + } + + // If the value of showAdvanced is true, + // don't query media roots and downloads root to avoid showing + // duplicated files. + if (mState.showAdvanced && (root.isLibrary() || root.isDownloads())) { + return true; + } + return false; + } + + @Override + protected QueryTask getQueryTask(String authority, List<RootInfo> rootInfos) { + return new SearchTask(authority, rootInfos); + } + + private class SearchTask extends QueryTask { + + public SearchTask(String authority, List<RootInfo> rootInfos) { + super(authority, rootInfos); + } + + @Override + protected void addQueryArgs(@NonNull Bundle queryArgs) { + queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, mSearchString); + queryArgs.putBoolean(DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA, true); + } + + @Override + protected Uri getQueryUri(RootInfo rootInfo) { + return DocumentsContract.buildSearchDocumentsUri(authority, + rootInfo.rootId, mSearchString); + } + + @Override + protected RootCursorWrapper generateResultCursor(RootInfo rootInfo, Cursor oriCursor) { + return new RootCursorWrapper(authority, rootInfo.rootId, oriCursor, -1 /* maxCount */); + } + } +} diff --git a/src/com/android/documentsui/MultiRootDocumentsLoader.java b/src/com/android/documentsui/MultiRootDocumentsLoader.java new file mode 100644 index 000000000..e2b7e297d --- /dev/null +++ b/src/com/android/documentsui/MultiRootDocumentsLoader.java @@ -0,0 +1,443 @@ +/* + * Copyright (C) 2018 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.base.SharedMinimal.DEBUG; +import static com.android.documentsui.base.SharedMinimal.TAG; + +import android.app.ActivityManager; +import android.content.ContentProviderClient; +import android.content.Context; +import android.database.Cursor; +import android.database.CursorWrapper; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.FileUtils; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; +import android.util.Log; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.loader.content.AsyncTaskLoader; + +import com.android.documentsui.base.DocumentInfo; +import com.android.documentsui.base.FilteringCursorWrapper; +import com.android.documentsui.base.Lookup; +import com.android.documentsui.base.RootInfo; +import com.android.documentsui.base.State; +import com.android.documentsui.roots.ProvidersAccess; +import com.android.documentsui.roots.RootCursorWrapper; + +import com.google.common.util.concurrent.AbstractFuture; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/* + * The abstract class to query multiple roots from {@link android.provider.DocumentsProvider} + * and return the combined result. + */ +public abstract class MultiRootDocumentsLoader extends AsyncTaskLoader<DirectoryResult> { + // TODO: clean up cursor ownership so background thread doesn't traverse + // previously returned cursors for filtering/sorting; this currently races + // with the UI thread. + + private static final int MAX_OUTSTANDING_TASK = 4; + private static final int MAX_OUTSTANDING_TASK_SVELTE = 2; + + /** + * Time to wait for first pass to complete before returning partial results. + */ + private static final int MAX_FIRST_PASS_WAIT_MILLIS = 500; + + protected final State mState; + + private final Semaphore mQueryPermits; + private final ProvidersAccess mProviders; + private final Lookup<String, Executor> mExecutors; + private final Lookup<String, String> mFileTypeMap; + + @GuardedBy("mTasks") + /** A authority -> QueryTask map */ + private final Map<String, QueryTask> mTasks = new HashMap<>(); + + private CountDownLatch mFirstPassLatch; + private volatile boolean mFirstPassDone; + + private DirectoryResult mResult; + + /* + * Create the loader to query roots from {@link android.provider.DocumentsProvider}. + * + * @param context the context + * @param providers the providers + * @param state current state + * @param executors the executors of authorities + * @param fileTypeMap the map of mime types and file types. + */ + public MultiRootDocumentsLoader(Context context, ProvidersAccess providers, State state, + Lookup<String, Executor> executors, Lookup<String, String> fileTypeMap) { + + super(context); + mProviders = providers; + mState = state; + mExecutors = executors; + mFileTypeMap = fileTypeMap; + + // Keep clients around on high-RAM devices, since we'd be spinning them + // up moments later to fetch thumbnails anyway. + final ActivityManager am = (ActivityManager) getContext().getSystemService( + Context.ACTIVITY_SERVICE); + mQueryPermits = new Semaphore( + am.isLowRamDevice() ? MAX_OUTSTANDING_TASK_SVELTE : MAX_OUTSTANDING_TASK); + } + + @Override + public DirectoryResult loadInBackground() { + synchronized (mTasks) { + return loadInBackgroundLocked(); + } + } + + private DirectoryResult loadInBackgroundLocked() { + if (mFirstPassLatch == null) { + // First time through we kick off all the recent tasks, and wait + // around to see if everyone finishes quickly. + Map<String, List<RootInfo>> rootsIndex = indexRoots(); + + for (Map.Entry<String, List<RootInfo>> rootEntry : rootsIndex.entrySet()) { + mTasks.put(rootEntry.getKey(), + getQueryTask(rootEntry.getKey(), rootEntry.getValue())); + } + + mFirstPassLatch = new CountDownLatch(mTasks.size()); + for (QueryTask task : mTasks.values()) { + mExecutors.lookup(task.authority).execute(task); + } + + try { + mFirstPassLatch.await(MAX_FIRST_PASS_WAIT_MILLIS, TimeUnit.MILLISECONDS); + mFirstPassDone = true; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + final long rejectBefore = getRejectBeforeTime(); + + // Collect all finished tasks + boolean allDone = true; + int totalQuerySize = 0; + List<Cursor> cursors = new ArrayList<>(mTasks.size()); + for (QueryTask task : mTasks.values()) { + if (task.isDone()) { + try { + final Cursor[] taskCursors = task.get(); + if (taskCursors == null || taskCursors.length == 0) { + continue; + } + + totalQuerySize += taskCursors.length; + for (Cursor cursor : taskCursors) { + if (cursor == null) { + // It's possible given an authority, some roots fail to return a cursor + // after a query. + continue; + } + final FilteringCursorWrapper filtered = new FilteringCursorWrapper( + cursor, mState.acceptMimes, getRejectMimes(), rejectBefore) { + @Override + public void close() { + // Ignored, since we manage cursor lifecycle internally + } + }; + cursors.add(filtered); + } + + } catch (InterruptedException e) { + throw new RuntimeException(e); + } catch (ExecutionException e) { + // We already logged on other side + } catch (Exception e) { + // Catch exceptions thrown when we read the cursor. + Log.e(TAG, "Failed to query documents for authority: " + task.authority + + ". Skip this authority.", e); + } + } else { + allDone = false; + } + } + + if (DEBUG) { + Log.d(TAG, + "Found " + cursors.size() + " of " + totalQuerySize + " queries done"); + } + + final DirectoryResult result = new DirectoryResult(); + result.doc = new DocumentInfo(); + + final Cursor merged; + if (cursors.size() > 0) { + merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()])); + } else { + // Return something when nobody is ready + merged = new MatrixCursor(new String[0]); + } + + final Cursor sorted; + if (isDocumentsMovable()) { + sorted = mState.sortModel.sortCursor(merged, mFileTypeMap); + } else { + final Cursor notMovableMasked = new NotMovableMaskCursor(merged); + sorted = mState.sortModel.sortCursor(notMovableMasked, mFileTypeMap); + } + + // Tell the UI if this is an in-progress result. When loading is complete, another update is + // sent with EXTRA_LOADING set to false. + Bundle extras = new Bundle(); + extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone); + sorted.setExtras(extras); + + result.cursor = sorted; + + return result; + } + + /** + * Returns a map of Authority -> rootInfos. + */ + private Map<String, List<RootInfo>> indexRoots() { + final Collection<RootInfo> roots = mProviders.getMatchingRootsBlocking(mState); + HashMap<String, List<RootInfo>> rootsIndex = new HashMap<>(); + for (RootInfo root : roots) { + // ignore the root with authority is null. e.g. Recent + if (root.authority == null || shouldIgnoreRoot(root)) { + continue; + } + + if (!rootsIndex.containsKey(root.authority)) { + rootsIndex.put(root.authority, new ArrayList<>()); + } + rootsIndex.get(root.authority).add(root); + } + + return rootsIndex; + } + + protected long getRejectBeforeTime() { + return -1; + } + + protected String[] getRejectMimes() { + return null; + } + + protected boolean shouldIgnoreRoot(RootInfo root) { + return false; + } + + protected boolean isDocumentsMovable() { + return true; + } + + protected abstract QueryTask getQueryTask(String authority, List<RootInfo> rootInfos); + + @Override + public void deliverResult(DirectoryResult result) { + if (isReset()) { + FileUtils.closeQuietly(result); + return; + } + DirectoryResult oldResult = mResult; + mResult = result; + + if (isStarted()) { + super.deliverResult(result); + } + + if (oldResult != null && oldResult != result) { + FileUtils.closeQuietly(oldResult); + } + } + + @Override + protected void onStartLoading() { + if (mResult != null) { + deliverResult(mResult); + } + if (takeContentChanged() || mResult == null) { + forceLoad(); + } + } + + @Override + protected void onStopLoading() { + cancelLoad(); + } + + @Override + public void onCanceled(DirectoryResult result) { + FileUtils.closeQuietly(result); + } + + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped + onStopLoading(); + + synchronized (mTasks) { + for (QueryTask task : mTasks.values()) { + FileUtils.closeQuietly(task); + } + } + + FileUtils.closeQuietly(mResult); + mResult = null; + } + + // TODO: create better transfer of ownership around cursor to ensure its + // closed in all edge cases. + + private static class NotMovableMaskCursor extends CursorWrapper { + private static final int NOT_MOVABLE_MASK = + ~(Document.FLAG_SUPPORTS_DELETE + | Document.FLAG_SUPPORTS_REMOVE + | Document.FLAG_SUPPORTS_MOVE); + + private NotMovableMaskCursor(Cursor cursor) { + super(cursor); + } + + @Override + public int getInt(int index) { + final int flagIndex = getWrappedCursor().getColumnIndex(Document.COLUMN_FLAGS); + final int value = super.getInt(index); + return (index == flagIndex) ? (value & NOT_MOVABLE_MASK) : value; + } + } + + protected abstract class QueryTask extends AbstractFuture<Cursor[]> implements Runnable, + Closeable { + public final String authority; + public final List<RootInfo> rootInfos; + + private Cursor[] mCursors; + private boolean mIsClosed = false; + + public QueryTask(String authority, List<RootInfo> rootInfos) { + this.authority = authority; + this.rootInfos = rootInfos; + } + + @Override + public void run() { + if (isCancelled()) { + return; + } + + try { + mQueryPermits.acquire(); + } catch (InterruptedException e) { + return; + } + + try { + runInternal(); + } finally { + mQueryPermits.release(); + } + } + + protected abstract Uri getQueryUri(RootInfo rootInfo); + + protected abstract RootCursorWrapper generateResultCursor(RootInfo rootInfo, + Cursor oriCursor); + + protected void addQueryArgs(@NonNull Bundle queryArgs) { + } + + private synchronized void runInternal() { + if (mIsClosed) { + return; + } + + ContentProviderClient client = null; + try { + client = DocumentsApplication.acquireUnstableProviderOrThrow( + getContext().getContentResolver(), authority); + + final int rootInfoCount = rootInfos.size(); + final Cursor[] res = new Cursor[rootInfoCount]; + mCursors = new Cursor[rootInfoCount]; + + for (int i = 0; i < rootInfoCount; i++) { + final Uri uri = getQueryUri(rootInfos.get(i)); + try { + final Bundle queryArgs = new Bundle(); + mState.sortModel.addQuerySortArgs(queryArgs); + addQueryArgs(queryArgs); + res[i] = client.query(uri, null, queryArgs, null); + mCursors[i] = generateResultCursor(rootInfos.get(i), res[i]); + } catch (Exception e) { + Log.w(TAG, "Failed to load " + authority + ", " + rootInfos.get(i).rootId, + e); + } + } + + } catch (Exception e) { + Log.w(TAG, "Failed to acquire content resolver for authority: " + authority); + } finally { + ContentProviderClient.closeQuietly(client); + } + + set(mCursors); + + mFirstPassLatch.countDown(); + if (mFirstPassDone) { + onContentChanged(); + } + } + + @Override + public synchronized void close() throws IOException { + if (mCursors == null) { + return; + } + + for (Cursor cursor : mCursors) { + FileUtils.closeQuietly(cursor); + } + + mIsClosed = true; + } + } +} diff --git a/src/com/android/documentsui/RecentsLoader.java b/src/com/android/documentsui/RecentsLoader.java index 0db270d96..4d29f7861 100644 --- a/src/com/android/documentsui/RecentsLoader.java +++ b/src/com/android/documentsui/RecentsLoader.java @@ -16,395 +16,78 @@ package com.android.documentsui; -import static com.android.documentsui.base.SharedMinimal.DEBUG; -import static com.android.documentsui.base.SharedMinimal.TAG; - -import android.app.ActivityManager; -import android.content.ContentProviderClient; import android.content.Context; import android.database.Cursor; -import android.database.CursorWrapper; -import android.database.MatrixCursor; -import android.database.MergeCursor; import android.net.Uri; -import android.os.Bundle; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import android.text.format.DateUtils; -import android.util.Log; -import com.android.documentsui.base.Features; -import com.android.documentsui.base.FilteringCursorWrapper; import com.android.documentsui.base.Lookup; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.State; import com.android.documentsui.roots.ProvidersAccess; import com.android.documentsui.roots.RootCursorWrapper; -import androidx.annotation.GuardedBy; -import androidx.loader.content.AsyncTaskLoader; - -import com.google.common.util.concurrent.AbstractFuture; - -import android.os.FileUtils; -import java.io.Closeable; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; - -public class RecentsLoader extends AsyncTaskLoader<DirectoryResult> { - // TODO: clean up cursor ownership so background thread doesn't traverse - // previously returned cursors for filtering/sorting; this currently races - // with the UI thread. - - private static final int MAX_OUTSTANDING_RECENTS = 4; - private static final int MAX_OUTSTANDING_RECENTS_SVELTE = 2; - /** - * Time to wait for first pass to complete before returning partial results. - */ - private static final int MAX_FIRST_PASS_WAIT_MILLIS = 500; - - /** Maximum documents from a single root. */ - private static final int MAX_DOCS_FROM_ROOT = 64; +public class RecentsLoader extends MultiRootDocumentsLoader { /** Ignore documents older than this age. */ private static final long REJECT_OLDER_THAN = 45 * DateUtils.DAY_IN_MILLIS; /** MIME types that should always be excluded from recents. */ - private static final String[] RECENT_REJECT_MIMES = new String[] { Document.MIME_TYPE_DIR }; - - private final Semaphore mQueryPermits; - - private final ProvidersAccess mProviders; - private final State mState; - private final Features mFeatures; - private final Lookup<String, Executor> mExecutors; - private final Lookup<String, String> mFileTypeMap; + private static final String[] REJECT_MIMES = new String[]{Document.MIME_TYPE_DIR}; - @GuardedBy("mTasks") - /** A authority -> RecentsTask map */ - private final Map<String, RecentsTask> mTasks = new HashMap<>(); - - private CountDownLatch mFirstPassLatch; - private volatile boolean mFirstPassDone; - - private DirectoryResult mResult; + /** Maximum documents from a single root. */ + private static final int MAX_DOCS_FROM_ROOT = 64; - public RecentsLoader(Context context, ProvidersAccess providers, State state, Features features, + public RecentsLoader(Context context, ProvidersAccess providers, State state, Lookup<String, Executor> executors, Lookup<String, String> fileTypeMap) { - - super(context); - mProviders = providers; - mState = state; - mFeatures = features; - mExecutors = executors; - mFileTypeMap = fileTypeMap; - - // Keep clients around on high-RAM devices, since we'd be spinning them - // up moments later to fetch thumbnails anyway. - final ActivityManager am = (ActivityManager) getContext().getSystemService( - Context.ACTIVITY_SERVICE); - mQueryPermits = new Semaphore( - am.isLowRamDevice() ? MAX_OUTSTANDING_RECENTS_SVELTE : MAX_OUTSTANDING_RECENTS); + super(context, providers, state, executors, fileTypeMap); } @Override - public DirectoryResult loadInBackground() { - synchronized (mTasks) { - return loadInBackgroundLocked(); - } - } - - private DirectoryResult loadInBackgroundLocked() { - if (mFirstPassLatch == null) { - // First time through we kick off all the recent tasks, and wait - // around to see if everyone finishes quickly. - Map<String, List<String>> rootsIndex = indexRecentsRoots(); - - for (String authority : rootsIndex.keySet()) { - mTasks.put(authority, new RecentsTask(authority, rootsIndex.get(authority))); - } - - mFirstPassLatch = new CountDownLatch(mTasks.size()); - for (RecentsTask task : mTasks.values()) { - mExecutors.lookup(task.authority).execute(task); - } - - try { - mFirstPassLatch.await(MAX_FIRST_PASS_WAIT_MILLIS, TimeUnit.MILLISECONDS); - mFirstPassDone = true; - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - - final long rejectBefore = System.currentTimeMillis() - REJECT_OLDER_THAN; - - // Collect all finished tasks - boolean allDone = true; - int totalQuerySize = 0; - List<Cursor> cursors = new ArrayList<>(mTasks.size()); - for (RecentsTask task : mTasks.values()) { - if (task.isDone()) { - try { - final Cursor[] taskCursors = task.get(); - if (taskCursors == null || taskCursors.length == 0) continue; - - totalQuerySize += taskCursors.length; - for (Cursor cursor : taskCursors) { - if (cursor == null) { - // It's possible given an authority, some roots fail to return a cursor - // after a query. - continue; - } - final FilteringCursorWrapper filtered = new FilteringCursorWrapper( - cursor, mState.acceptMimes, RECENT_REJECT_MIMES, rejectBefore) { - @Override - public void close() { - // Ignored, since we manage cursor lifecycle internally - } - }; - cursors.add(filtered); - } - - } catch (InterruptedException e) { - throw new RuntimeException(e); - } catch (ExecutionException e) { - // We already logged on other side - } catch (Exception e) { - // Catch exceptions thrown when we read the cursor. - Log.e(TAG, "Failed to query Recents for authority: " + task.authority - + ". Skip this authority in Recents.", e); - } - } else { - allDone = false; - } - } - - if (DEBUG) { - Log.d(TAG, - "Found " + cursors.size() + " of " + totalQuerySize + " recent queries done"); - } - - final DirectoryResult result = new DirectoryResult(); - - final Cursor merged; - if (cursors.size() > 0) { - merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()])); - } else { - // Return something when nobody is ready - merged = new MatrixCursor(new String[0]); - } - - final Cursor notMovableMasked = new NotMovableMaskCursor(merged); - final Cursor sorted = mState.sortModel.sortCursor(notMovableMasked, mFileTypeMap); - - // Tell the UI if this is an in-progress result. When loading is complete, another update is - // sent with EXTRA_LOADING set to false. - Bundle extras = new Bundle(); - extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone); - sorted.setExtras(extras); - - result.cursor = sorted; - - return result; - } - - /** - * Returns a map of Authority -> rootIds - */ - private Map<String, List<String>> indexRecentsRoots() { - final Collection<RootInfo> roots = mProviders.getMatchingRootsBlocking(mState); - HashMap<String, List<String>> rootsIndex = new HashMap<>(); - for (RootInfo root : roots) { - if (!root.supportsRecents()) { - continue; - } - - if (!rootsIndex.containsKey(root.authority)) { - rootsIndex.put(root.authority, new ArrayList<>()); - } - rootsIndex.get(root.authority).add(root.rootId); - } - - return rootsIndex; - } - - @Override - public void cancelLoadInBackground() { - super.cancelLoadInBackground(); + protected long getRejectBeforeTime() { + return System.currentTimeMillis() - REJECT_OLDER_THAN; } @Override - public void deliverResult(DirectoryResult result) { - if (isReset()) { - FileUtils.closeQuietly(result); - return; - } - DirectoryResult oldResult = mResult; - mResult = result; - - if (isStarted()) { - super.deliverResult(result); - } - - if (oldResult != null && oldResult != result) { - FileUtils.closeQuietly(oldResult); - } + protected String[] getRejectMimes() { + return REJECT_MIMES; } @Override - protected void onStartLoading() { - if (mResult != null) { - deliverResult(mResult); - } - if (takeContentChanged() || mResult == null) { - forceLoad(); - } + protected boolean shouldIgnoreRoot(RootInfo root) { + // only query the root is local only and support recents + return !root.isLocalOnly() || !root.supportsRecents(); } @Override - protected void onStopLoading() { - cancelLoad(); + protected boolean isDocumentsMovable() { + return false; } @Override - public void onCanceled(DirectoryResult result) { - FileUtils.closeQuietly(result); + protected QueryTask getQueryTask(String authority, List<RootInfo> rootInfos) { + return new RecentsTask(authority, rootInfos); } - @Override - protected void onReset() { - super.onReset(); + private class RecentsTask extends QueryTask { - // Ensure the loader is stopped - onStopLoading(); - - synchronized (mTasks) { - for (RecentsTask task : mTasks.values()) { - FileUtils.closeQuietly(task); - } - } - - FileUtils.closeQuietly(mResult); - mResult = null; - } - - // TODO: create better transfer of ownership around cursor to ensure its - // closed in all edge cases. - - private class RecentsTask extends AbstractFuture<Cursor[]> implements Runnable, Closeable { - public final String authority; - public final List<String> rootIds; - - private Cursor[] mCursors; - private boolean mIsClosed = false; - - public RecentsTask(String authority, List<String> rootIds) { - this.authority = authority; - this.rootIds = rootIds; + public RecentsTask(String authority, List<RootInfo> rootInfos) { + super(authority, rootInfos); } @Override - public void run() { - if (isCancelled()) return; - - try { - mQueryPermits.acquire(); - } catch (InterruptedException e) { - return; - } - - try { - runInternal(); - } finally { - mQueryPermits.release(); - } - } - - private synchronized void runInternal() { - if (mIsClosed) { - return; - } - - ContentProviderClient client = null; - try { - client = DocumentsApplication.acquireUnstableProviderOrThrow( - getContext().getContentResolver(), authority); - - final Cursor[] res = new Cursor[rootIds.size()]; - mCursors = new Cursor[rootIds.size()]; - for (int i = 0; i < rootIds.size(); i++) { - final Uri uri = - DocumentsContract.buildRecentDocumentsUri(authority, rootIds.get(i)); - try { - if (mFeatures.isContentPagingEnabled()) { - final Bundle queryArgs = new Bundle(); - mState.sortModel.addQuerySortArgs(queryArgs); - res[i] = client.query(uri, null, queryArgs, null); - } else { - res[i] = client.query( - uri, null, null, null, mState.sortModel.getDocumentSortQuery()); - } - mCursors[i] = new RootCursorWrapper(authority, rootIds.get(i), res[i], - MAX_DOCS_FROM_ROOT); - } catch (Exception e) { - Log.w(TAG, "Failed to load " + authority + ", " + rootIds.get(i), e); - } - } - - } catch (Exception e) { - Log.w(TAG, "Failed to acquire content resolver for authority: " + authority); - } finally { - ContentProviderClient.closeQuietly(client); - } - - set(mCursors); - - mFirstPassLatch.countDown(); - if (mFirstPassDone) { - onContentChanged(); - } - } - - @Override - public synchronized void close() throws IOException { - if (mCursors == null) { - return; - } - - for (Cursor cursor : mCursors) { - FileUtils.closeQuietly(cursor); - } - - mIsClosed = true; - } - } - - private static class NotMovableMaskCursor extends CursorWrapper { - private static final int NOT_MOVABLE_MASK = - ~(Document.FLAG_SUPPORTS_DELETE - | Document.FLAG_SUPPORTS_REMOVE - | Document.FLAG_SUPPORTS_MOVE); - - private NotMovableMaskCursor(Cursor cursor) { - super(cursor); + protected Uri getQueryUri(RootInfo rootInfo) { + return DocumentsContract.buildRecentDocumentsUri(authority, rootInfo.rootId); } @Override - public int getInt(int index) { - final int flagIndex = getWrappedCursor().getColumnIndex(Document.COLUMN_FLAGS); - final int value = super.getInt(index); - return (index == flagIndex) ? (value & NOT_MOVABLE_MASK) : value; + protected RootCursorWrapper generateResultCursor(RootInfo rootInfo, Cursor oriCursor) { + return new RootCursorWrapper(authority, rootInfo.rootId, oriCursor, MAX_DOCS_FROM_ROOT); } } } diff --git a/src/com/android/documentsui/roots/ProvidersCache.java b/src/com/android/documentsui/roots/ProvidersCache.java index 6fcc8d887..266829047 100644 --- a/src/com/android/documentsui/roots/ProvidersCache.java +++ b/src/com/android/documentsui/roots/ProvidersCache.java @@ -107,13 +107,13 @@ public class ProvidersCache implements ProvidersAccess { // Create a new anonymous "Recents" RootInfo. It's a faker. mRecentsRoot = new RootInfo() {{ - // Special root for recents - derivedIcon = R.drawable.ic_root_recent; - derivedType = RootInfo.TYPE_RECENTS; - flags = Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_IS_CHILD; - title = mContext.getString(R.string.root_recent); - availableBytes = -1; - }}; + // Special root for recents + derivedIcon = R.drawable.ic_root_recent; + derivedType = RootInfo.TYPE_RECENTS; + flags = Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_IS_CHILD | Root.FLAG_SUPPORTS_SEARCH; + title = mContext.getString(R.string.root_recent); + availableBytes = -1; + }}; } private class RootsChangedObserver extends ContentObserver { diff --git a/tests/common/com/android/documentsui/testing/TestDocumentsProvider.java b/tests/common/com/android/documentsui/testing/TestDocumentsProvider.java index 9f30326a9..794d1c9fe 100644 --- a/tests/common/com/android/documentsui/testing/TestDocumentsProvider.java +++ b/tests/common/com/android/documentsui/testing/TestDocumentsProvider.java @@ -16,6 +16,7 @@ package com.android.documentsui.testing; +import android.annotation.NonNull; import android.content.pm.ProviderInfo; import android.database.Cursor; import android.database.MatrixCursor; @@ -90,6 +91,15 @@ public class TestDocumentsProvider extends DocumentsProvider { } @Override + public Cursor querySearchDocuments(String rootId, String query, String[] projection) { + if (mNextChildDocuments != null) { + return filterCursorByString(mNextChildDocuments, query); + } + + return mNextChildDocuments; + } + + @Override public boolean onCreate() { return true; } @@ -122,4 +132,39 @@ public class TestDocumentsProvider extends DocumentsProvider { return cursor; } + + private static Cursor filterCursorByString(@NonNull Cursor cursor, String query) { + final int count = cursor.getCount(); + final String[] columnNames = cursor.getColumnNames(); + + final MatrixCursor resultCursor = new MatrixCursor(columnNames, count); + cursor.moveToPosition(-1); + for (int i = 0; i < count; i++) { + cursor.moveToNext(); + final int index = cursor.getColumnIndex(Document.COLUMN_DISPLAY_NAME); + if (!cursor.getString(index).contains(query)) { + continue; + } + + final MatrixCursor.RowBuilder builder = resultCursor.newRow(); + final int columnCount = cursor.getColumnCount(); + for (int j = 0; j < columnCount; j++) { + final int type = cursor.getType(j); + switch (type) { + case Cursor.FIELD_TYPE_INTEGER: + builder.add(cursor.getLong(j)); + break; + + case Cursor.FIELD_TYPE_STRING: + builder.add(cursor.getString(j)); + break; + + default: + break; + } + } + } + cursor.moveToPosition(-1); + return resultCursor; + } } diff --git a/tests/common/com/android/documentsui/testing/TestProvidersAccess.java b/tests/common/com/android/documentsui/testing/TestProvidersAccess.java index 71d7d8233..13c5c3427 100644 --- a/tests/common/com/android/documentsui/testing/TestProvidersAccess.java +++ b/tests/common/com/android/documentsui/testing/TestProvidersAccess.java @@ -62,6 +62,7 @@ public class TestProvidersAccess implements ProvidersAccess { HOME.authority = Providers.AUTHORITY_STORAGE; HOME.rootId = Providers.ROOT_ID_HOME; HOME.title = "Home"; + HOME.derivedType = RootInfo.TYPE_LOCAL; HOME.flags = Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_IS_CHILD @@ -71,6 +72,8 @@ public class TestProvidersAccess implements ProvidersAccess { HAMMY.authority = "yummies"; HAMMY.rootId = "hamsandwich"; HAMMY.title = "Ham Sandwich"; + HAMMY.derivedType = RootInfo.TYPE_LOCAL; + HAMMY.flags = Root.FLAG_LOCAL_ONLY; PICKLES = new RootInfo(); PICKLES.authority = "yummies"; diff --git a/tests/unit/com/android/documentsui/GlobalSearchLoaderTest.java b/tests/unit/com/android/documentsui/GlobalSearchLoaderTest.java new file mode 100644 index 000000000..a0663f5f9 --- /dev/null +++ b/tests/unit/com/android/documentsui/GlobalSearchLoaderTest.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2018 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 junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertTrue; + +import android.database.Cursor; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; +import android.support.test.filters.MediumTest; +import android.support.test.runner.AndroidJUnit4; +import android.text.TextUtils; + +import com.android.documentsui.base.DocumentInfo; +import com.android.documentsui.base.State; +import com.android.documentsui.roots.RootCursorWrapper; +import com.android.documentsui.sorting.SortDimension; +import com.android.documentsui.sorting.SortModel; +import com.android.documentsui.testing.ActivityManagers; +import com.android.documentsui.testing.TestEnv; +import com.android.documentsui.testing.TestFileTypeLookup; +import com.android.documentsui.testing.TestImmediateExecutor; +import com.android.documentsui.testing.TestProvidersAccess; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@MediumTest +public class GlobalSearchLoaderTest { + + private static final String SEARCH_STRING = "freddy"; + private static int FILE_FLAG = Document.FLAG_SUPPORTS_MOVE | Document.FLAG_SUPPORTS_DELETE + | Document.FLAG_SUPPORTS_REMOVE; + + private TestEnv mEnv; + private TestActivity mActivity; + private GlobalSearchLoader mLoader; + + @Before + public void setUp() { + mEnv = TestEnv.create(); + mActivity = TestActivity.create(mEnv); + mActivity.activityManager = ActivityManagers.create(false); + + mEnv.state.action = State.ACTION_BROWSE; + mEnv.state.acceptMimes = new String[]{"*/*"}; + + mLoader = new GlobalSearchLoader(mActivity, mEnv.providers, mEnv.state, + TestImmediateExecutor.createLookup(), new TestFileTypeLookup(), SEARCH_STRING); + + final DocumentInfo doc = mEnv.model.createFile(SEARCH_STRING + ".jpg", FILE_FLAG); + doc.lastModified = System.currentTimeMillis(); + mEnv.mockProviders.get(TestProvidersAccess.HOME.authority) + .setNextChildDocumentsReturns(doc); + + TestProvidersAccess.HOME.flags |= DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + mEnv.state.showAdvanced = true; + } + + @After + public void tearDown() { + TestProvidersAccess.HOME.flags &= ~DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + mEnv.state.showAdvanced = false; + } + + @Test + public void testNotSearchableRoot_beIgnored() { + TestProvidersAccess.PICKLES.flags |= DocumentsContract.Root.FLAG_LOCAL_ONLY; + assertTrue(mLoader.shouldIgnoreRoot(TestProvidersAccess.PICKLES)); + TestProvidersAccess.PICKLES.flags &= ~DocumentsContract.Root.FLAG_LOCAL_ONLY; + } + + @Test + public void testNotLocalOnlyRoot_beIgnored() { + TestProvidersAccess.PICKLES.flags |= DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + assertTrue(mLoader.shouldIgnoreRoot(TestProvidersAccess.PICKLES)); + TestProvidersAccess.PICKLES.flags &= ~DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + } + + @Test + public void testShowAdvance_recentRoot_beIgnored() { + TestProvidersAccess.IMAGE.flags |= DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + assertTrue(mLoader.shouldIgnoreRoot(TestProvidersAccess.IMAGE)); + TestProvidersAccess.IMAGE.flags &= ~DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + } + + @Test + public void testShowAdvance_imageRoot_beIgnored() { + TestProvidersAccess.IMAGE.flags |= DocumentsContract.Root.FLAG_SUPPORTS_SEARCH + | DocumentsContract.Root.FLAG_LOCAL_ONLY; + assertTrue(mLoader.shouldIgnoreRoot(TestProvidersAccess.IMAGE)); + TestProvidersAccess.IMAGE.flags &= ~(DocumentsContract.Root.FLAG_SUPPORTS_SEARCH + | DocumentsContract.Root.FLAG_LOCAL_ONLY); + } + + @Test + public void testShowAdvance_videoRoot_beIgnored() { + TestProvidersAccess.VIDEO.flags |= DocumentsContract.Root.FLAG_SUPPORTS_SEARCH + | DocumentsContract.Root.FLAG_LOCAL_ONLY; + assertTrue(mLoader.shouldIgnoreRoot(TestProvidersAccess.VIDEO)); + TestProvidersAccess.VIDEO.flags &= ~(DocumentsContract.Root.FLAG_SUPPORTS_SEARCH + | DocumentsContract.Root.FLAG_LOCAL_ONLY); + } + + @Test + public void testShowAdvance_audioRoot_beIgnored() { + TestProvidersAccess.AUDIO.flags |= DocumentsContract.Root.FLAG_SUPPORTS_SEARCH + | DocumentsContract.Root.FLAG_LOCAL_ONLY; + assertTrue(mLoader.shouldIgnoreRoot(TestProvidersAccess.AUDIO)); + TestProvidersAccess.AUDIO.flags &= ~(DocumentsContract.Root.FLAG_SUPPORTS_SEARCH + | DocumentsContract.Root.FLAG_LOCAL_ONLY); + } + + @Test + public void testShowAdvance_downloadRoot_beIgnored() { + TestProvidersAccess.DOWNLOADS.flags |= DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + assertTrue(mLoader.shouldIgnoreRoot(TestProvidersAccess.DOWNLOADS)); + TestProvidersAccess.DOWNLOADS.flags &= ~DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + } + + @Test + public void testSearchResult_includeDirectory() { + final DocumentInfo doc = mEnv.model.createFolder(SEARCH_STRING); + doc.lastModified = System.currentTimeMillis(); + + mEnv.mockProviders.get(TestProvidersAccess.HOME.authority) + .setNextChildDocumentsReturns(doc); + + final DirectoryResult result = mLoader.loadInBackground(); + + final Cursor c = result.cursor; + assertEquals(1, c.getCount()); + + c.moveToNext(); + final String mimeType = c.getString(c.getColumnIndex(Document.COLUMN_MIME_TYPE)); + assertEquals(Document.MIME_TYPE_DIR, mimeType); + } + + @Test + public void testSearchResult_isMovable() { + final DirectoryResult result = mLoader.loadInBackground(); + + final Cursor c = result.cursor; + assertEquals(1, c.getCount()); + + c.moveToNext(); + final int flags = c.getInt(c.getColumnIndex(Document.COLUMN_FLAGS)); + assertEquals(FILE_FLAG, flags); + } + + @Test + public void testSearchResult_includeSearchString() { + final DocumentInfo pdfDoc = mEnv.model.createFile(SEARCH_STRING + ".pdf"); + pdfDoc.lastModified = System.currentTimeMillis(); + + final DocumentInfo apkDoc = mEnv.model.createFile(SEARCH_STRING + ".apk"); + apkDoc.lastModified = System.currentTimeMillis(); + + final DocumentInfo testApkDoc = mEnv.model.createFile("test.apk"); + testApkDoc.lastModified = System.currentTimeMillis(); + + mEnv.mockProviders.get(TestProvidersAccess.HOME.authority) + .setNextChildDocumentsReturns(pdfDoc, apkDoc, testApkDoc); + + mEnv.state.sortModel.sortByUser( + SortModel.SORT_DIMENSION_ID_TITLE, SortDimension.SORT_DIRECTION_ASCENDING); + + final DirectoryResult result = mLoader.loadInBackground(); + final Cursor c = result.cursor; + + assertEquals(2, c.getCount()); + + c.moveToNext(); + String displayName = c.getString(c.getColumnIndex(Document.COLUMN_DISPLAY_NAME)); + assertTrue(displayName.contains(SEARCH_STRING)); + + c.moveToNext(); + displayName = c.getString(c.getColumnIndex(Document.COLUMN_DISPLAY_NAME)); + assertTrue(displayName.contains(SEARCH_STRING)); + } + + @Test + public void testSearchResult_includeDifferentRoot() { + final DocumentInfo pdfDoc = mEnv.model.createFile(SEARCH_STRING + ".pdf"); + pdfDoc.lastModified = System.currentTimeMillis(); + + final DocumentInfo apkDoc = mEnv.model.createFile(SEARCH_STRING + ".apk"); + apkDoc.lastModified = System.currentTimeMillis(); + + final DocumentInfo testApkDoc = mEnv.model.createFile("test.apk"); + testApkDoc.lastModified = System.currentTimeMillis(); + + mEnv.mockProviders.get(TestProvidersAccess.HAMMY.authority) + .setNextChildDocumentsReturns(pdfDoc, apkDoc, testApkDoc); + + TestProvidersAccess.HAMMY.flags |= DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + + mEnv.state.sortModel.sortByUser( + SortModel.SORT_DIMENSION_ID_TITLE, SortDimension.SORT_DIRECTION_ASCENDING); + + final DirectoryResult result = mLoader.loadInBackground(); + final Cursor c = result.cursor; + + assertEquals(3, c.getCount()); + + TestProvidersAccess.HAMMY.flags &= ~DocumentsContract.Root.FLAG_SUPPORTS_SEARCH; + } +} diff --git a/tests/unit/com/android/documentsui/RecentsLoaderTests.java b/tests/unit/com/android/documentsui/RecentsLoaderTests.java index 657432fef..eb7528019 100644 --- a/tests/unit/com/android/documentsui/RecentsLoaderTests.java +++ b/tests/unit/com/android/documentsui/RecentsLoaderTests.java @@ -17,6 +17,7 @@ package com.android.documentsui; import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; import static junit.framework.Assert.assertTrue; import android.database.Cursor; @@ -28,7 +29,6 @@ import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.State; import com.android.documentsui.testing.ActivityManagers; import com.android.documentsui.testing.TestEnv; -import com.android.documentsui.testing.TestFeatures; import com.android.documentsui.testing.TestFileTypeLookup; import com.android.documentsui.testing.TestImmediateExecutor; import com.android.documentsui.testing.TestProvidersAccess; @@ -54,11 +54,35 @@ public class RecentsLoaderTests { mEnv.state.action = State.ACTION_BROWSE; mEnv.state.acceptMimes = new String[] { "*/*" }; - mLoader = new RecentsLoader(mActivity, mEnv.providers, mEnv.state, mEnv.features, + mLoader = new RecentsLoader(mActivity, mEnv.providers, mEnv.state, TestImmediateExecutor.createLookup(), new TestFileTypeLookup()); } @Test + public void testNotLocalOnlyRoot_beIgnored() { + assertTrue(mLoader.shouldIgnoreRoot(TestProvidersAccess.PICKLES)); + } + + @Test + public void testLocalOnlyRoot_supportRecent_notIgnored() { + assertFalse(mLoader.shouldIgnoreRoot(TestProvidersAccess.DOWNLOADS)); + } + + @Test + public void testDocumentsNotIncludeDirectory() { + final DocumentInfo doc = mEnv.model.createFolder("test"); + doc.lastModified = System.currentTimeMillis(); + + mEnv.mockProviders.get(TestProvidersAccess.HOME.authority) + .setNextChildDocumentsReturns(doc); + + final DirectoryResult result = mLoader.loadInBackground(); + + final Cursor c = result.cursor; + assertEquals(0, c.getCount()); + } + + @Test public void testDocumentsNotMovable() { final DocumentInfo doc = mEnv.model.createFile("freddy.jpg", Document.FLAG_SUPPORTS_MOVE diff --git a/tests/unit/com/android/documentsui/sidebar/RootsFragmentTest.java b/tests/unit/com/android/documentsui/sidebar/RootsFragmentTest.java index 6028eacdf..16414e55b 100644 --- a/tests/unit/com/android/documentsui/sidebar/RootsFragmentTest.java +++ b/tests/unit/com/android/documentsui/sidebar/RootsFragmentTest.java @@ -53,8 +53,8 @@ public class RootsFragmentTest { TestProvidersAccess.DOWNLOADS.title, "" /* SpacerItem */, TestProvidersAccess.EXTERNALSTORAGE.title, - "" /* SpacerItem */, TestProvidersAccess.HAMMY.title, + "" /* SpacerItem */, TestProvidersAccess.INSPECTOR.title, TestProvidersAccess.PICKLES.title}; |