summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Bo Majewski <majewski@google.com> 2025-01-23 01:37:50 -0800
committer Android (Google) Code Review <android-gerrit@google.com> 2025-01-23 01:37:50 -0800
commit2f300d81446a984538126dac78456ebc0a0978fd (patch)
tree33e5da271016765d44fdfccc9e00fc2617f8aa24
parent41256da564bf24d564eb97d31008d0ec511e47d5 (diff)
parent7e6cb71ea41b14206bacf5b5fe57d99530d42b65 (diff)
Merge "[DocsUI, Search]: Introduces new loaders for search v2." into main
-rw-r--r--src/com/android/documentsui/AbstractActionHandler.java88
-rw-r--r--src/com/android/documentsui/MultiRootDocumentsLoader.java2
-rw-r--r--src/com/android/documentsui/RecentsLoader.java6
-rw-r--r--src/com/android/documentsui/loaders/BaseFileLoader.kt208
-rw-r--r--src/com/android/documentsui/loaders/FolderLoader.kt79
-rw-r--r--src/com/android/documentsui/loaders/QueryOptions.kt82
-rw-r--r--src/com/android/documentsui/loaders/SearchLoader.kt246
-rw-r--r--tests/unit/com/android/documentsui/loaders/BaseLoaderTest.kt64
-rw-r--r--tests/unit/com/android/documentsui/loaders/FolderLoaderTest.kt55
-rw-r--r--tests/unit/com/android/documentsui/loaders/SearchLoaderTest.kt71
10 files changed, 895 insertions, 6 deletions
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java
index da599d47f..8e1a51301 100644
--- a/src/com/android/documentsui/AbstractActionHandler.java
+++ b/src/com/android/documentsui/AbstractActionHandler.java
@@ -20,6 +20,7 @@ import static com.android.documentsui.base.DocumentInfo.getCursorInt;
import static com.android.documentsui.base.DocumentInfo.getCursorString;
import static com.android.documentsui.base.SharedMinimal.DEBUG;
import static com.android.documentsui.flags.Flags.desktopFileHandling;
+import static com.android.documentsui.flags.Flags.useSearchV2;
import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
@@ -38,6 +39,7 @@ import android.util.Log;
import android.util.Pair;
import android.view.DragEvent;
+import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.FragmentActivity;
import androidx.loader.app.LoaderManager.LoaderCallbacks;
@@ -63,6 +65,9 @@ import com.android.documentsui.dirlist.AnimationView.AnimationType;
import com.android.documentsui.dirlist.FocusHandler;
import com.android.documentsui.files.LauncherActivity;
import com.android.documentsui.files.QuickViewIntentBuilder;
+import com.android.documentsui.loaders.FolderLoader;
+import com.android.documentsui.loaders.QueryOptions;
+import com.android.documentsui.loaders.SearchLoader;
import com.android.documentsui.queries.SearchViewManager;
import com.android.documentsui.roots.GetRootDocumentTask;
import com.android.documentsui.roots.LoadFirstRootTask;
@@ -73,10 +78,14 @@ import com.android.documentsui.sorting.SortListFragment;
import com.android.documentsui.ui.DialogController;
import com.android.documentsui.ui.Snackbars;
+import java.time.Duration;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
@@ -894,16 +903,28 @@ public abstract class AbstractActionHandler<T extends FragmentActivity & CommonA
private final class LoaderBindings implements LoaderCallbacks<DirectoryResult> {
+ private ExecutorService mExecutorService = null;
+ private static final long MAX_SEARCH_TIME_MS = 3000;
+ private static final int MAX_RESULTS = 500;
+
+ @NonNull
@Override
public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) {
- Context context = mActivity;
-
// If document stack is not initialized, i.e. if the root is null, create "Recents" root
// with the selected user.
if (!mState.stack.isInitialized()) {
mState.stack.changeRoot(mActivity.getCurrentRoot());
}
+ if (useSearchV2()) {
+ return onCreateLoaderV2(id, args);
+ }
+ return onCreateLoaderV1(id, args);
+ }
+
+ private Loader<DirectoryResult> onCreateLoaderV1(int id, Bundle args) {
+ Context context = mActivity;
+
if (mState.stack.isRecents()) {
final LockingContentObserver observer = new LockingContentObserver(
mContentLock, AbstractActionHandler.this::loadDocumentsForCurrentStack);
@@ -980,6 +1001,69 @@ public abstract class AbstractActionHandler<T extends FragmentActivity & CommonA
}
}
+ private Loader<DirectoryResult> onCreateLoaderV2(int id, Bundle args) {
+ if (mExecutorService == null) {
+ // TODO(b:388130971): Fine tune the size of the thread pool.
+ mExecutorService = Executors.newFixedThreadPool(
+ GlobalSearchLoader.MAX_OUTSTANDING_TASK);
+ }
+ DocumentStack stack = mState.stack;
+ RootInfo root = stack.getRoot();
+ List<UserId> userIdList = DocumentsApplication.getUserIdManager(mActivity).getUserIds();
+
+ Duration lastModifiedDelta = stack.isRecents()
+ ? Duration.ofMillis(RecentsLoader.REJECT_OLDER_THAN)
+ : null;
+ int maxResults = (root == null || root.isRecents())
+ ? RecentsLoader.MAX_DOCS_FROM_ROOT : MAX_RESULTS;
+ QueryOptions options = new QueryOptions(
+ maxResults, lastModifiedDelta, Duration.ofMillis(MAX_SEARCH_TIME_MS),
+ mState.showHiddenFiles, mState.acceptMimes);
+
+ if (stack.isRecents() || mSearchMgr.isSearching()) {
+ Log.d(TAG, "Creating search loader V2");
+ // For search and recent we create an observer that restart the loader every time
+ // one of the searched content providers reports a change.
+ final LockingContentObserver observer = new LockingContentObserver(
+ mContentLock, AbstractActionHandler.this::loadDocumentsForCurrentStack);
+ Collection<RootInfo> rootList = new ArrayList<>();
+ if (root == null || root.isRecents()) {
+ // TODO(b:381346575): Pass roots based on user selection.
+ rootList.addAll(mProviders.getMatchingRootsBlocking(mState).stream().filter(
+ r -> r.supportsSearch() && r.authority != null
+ && r.rootId != null).toList());
+ } else {
+ rootList.add(root);
+ }
+ return new SearchLoader(
+ mActivity,
+ userIdList,
+ mInjector.fileTypeLookup,
+ observer,
+ rootList,
+ mSearchMgr.getCurrentSearch(),
+ options,
+ mState.sortModel,
+ mExecutorService
+ );
+ }
+ Log.d(TAG, "Creating folder loader V2");
+ // For folder scan we pass the content lock to the loader so that it can register
+ // an a callback to its internal method that forces a reload of the folder, every
+ // time the content provider reports a change.
+ return new FolderLoader(
+ mActivity,
+ userIdList,
+ mInjector.fileTypeLookup,
+ mContentLock,
+ root,
+ stack.peek(),
+ options,
+ mState.sortModel
+ );
+
+ }
+
@Override
public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) {
if (DEBUG) {
diff --git a/src/com/android/documentsui/MultiRootDocumentsLoader.java b/src/com/android/documentsui/MultiRootDocumentsLoader.java
index db78daa48..1213a6711 100644
--- a/src/com/android/documentsui/MultiRootDocumentsLoader.java
+++ b/src/com/android/documentsui/MultiRootDocumentsLoader.java
@@ -71,7 +71,7 @@ public abstract class MultiRootDocumentsLoader extends AsyncTaskLoader<Directory
// previously returned cursors for filtering/sorting; this currently races
// with the UI thread.
- private static final int MAX_OUTSTANDING_TASK = 4;
+ public static final int MAX_OUTSTANDING_TASK = 4;
private static final int MAX_OUTSTANDING_TASK_SVELTE = 2;
/**
diff --git a/src/com/android/documentsui/RecentsLoader.java b/src/com/android/documentsui/RecentsLoader.java
index b3cfa0180..9a3e06fba 100644
--- a/src/com/android/documentsui/RecentsLoader.java
+++ b/src/com/android/documentsui/RecentsLoader.java
@@ -37,13 +37,13 @@ public class RecentsLoader extends MultiRootDocumentsLoader {
private static final String TAG = "RecentsLoader";
/** Ignore documents older than this age. */
- private static final long REJECT_OLDER_THAN = 45 * DateUtils.DAY_IN_MILLIS;
+ public static final long REJECT_OLDER_THAN = 45 * DateUtils.DAY_IN_MILLIS;
- /** MIME types that should always be excluded from recents. */
+ /** MIME types that should always be excluded from the Recents view. */
private static final String[] REJECT_MIMES = new String[]{Document.MIME_TYPE_DIR};
/** Maximum documents from a single root. */
- private static final int MAX_DOCS_FROM_ROOT = 64;
+ public static final int MAX_DOCS_FROM_ROOT = 64;
private final UserId mUserId;
diff --git a/src/com/android/documentsui/loaders/BaseFileLoader.kt b/src/com/android/documentsui/loaders/BaseFileLoader.kt
new file mode 100644
index 000000000..dd76217ac
--- /dev/null
+++ b/src/com/android/documentsui/loaders/BaseFileLoader.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright (C) 2024 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.loaders
+
+import android.content.Context
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.database.MergeCursor
+import android.net.Uri
+import android.os.Bundle
+import android.os.CancellationSignal
+import android.os.RemoteException
+import android.provider.DocumentsContract.Document
+import android.util.Log
+import androidx.loader.content.AsyncTaskLoader
+import com.android.documentsui.DirectoryResult
+import com.android.documentsui.base.Lookup
+import com.android.documentsui.base.UserId
+import com.android.documentsui.roots.RootCursorWrapper
+
+const val TAG = "SearchV2"
+
+val FILE_ENTRY_COLUMNS = arrayOf(
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_LAST_MODIFIED,
+ Document.COLUMN_FLAGS,
+ Document.COLUMN_SUMMARY,
+ Document.COLUMN_SIZE,
+ Document.COLUMN_ICON,
+)
+
+fun emptyCursor(): Cursor {
+ return MatrixCursor(FILE_ENTRY_COLUMNS)
+}
+
+/**
+ * Helper function that returns a single, non-null cursor constructed from the given list of
+ * cursors.
+ */
+fun toSingleCursor(cursorList: List<Cursor>): Cursor {
+ if (cursorList.isEmpty()) {
+ return emptyCursor()
+ }
+ if (cursorList.size == 1) {
+ return cursorList[0]
+ }
+ return MergeCursor(cursorList.toTypedArray())
+}
+
+/**
+ * The base class for search and directory loaders. This class implements common functionality
+ * shared by these loaders. The extending classes should implement loadInBackground, which
+ * should call the queryLocation method.
+ */
+abstract class BaseFileLoader(
+ context: Context,
+ private val mUserIdList: List<UserId>,
+ protected val mMimeTypeLookup: Lookup<String, String>,
+) : AsyncTaskLoader<DirectoryResult>(context) {
+
+ private var mSignal: CancellationSignal? = null
+ private var mResult: DirectoryResult? = null
+
+ override fun cancelLoadInBackground() {
+ Log.d(TAG, "BasedFileLoader.cancelLoadInBackground")
+ super.cancelLoadInBackground()
+
+ synchronized(this) {
+ mSignal?.cancel()
+ }
+ }
+
+ override fun deliverResult(result: DirectoryResult?) {
+ Log.d(TAG, "BasedFileLoader.deliverResult")
+ if (isReset) {
+ closeResult(result)
+ return
+ }
+ val oldResult: DirectoryResult? = mResult
+ mResult = result
+
+ if (isStarted) {
+ super.deliverResult(result)
+ }
+
+ if (oldResult != null && oldResult !== result) {
+ closeResult(oldResult)
+ }
+ }
+
+ override fun onStartLoading() {
+ Log.d(TAG, "BasedFileLoader.onStartLoading")
+ val isCursorStale: Boolean = checkIfCursorStale(mResult)
+ if (mResult != null && !isCursorStale) {
+ deliverResult(mResult)
+ }
+ if (takeContentChanged() || mResult == null || isCursorStale) {
+ forceLoad()
+ }
+ }
+
+ override fun onStopLoading() {
+ Log.d(TAG, "BasedFileLoader.onStopLoading")
+ cancelLoad()
+ }
+
+ override fun onCanceled(result: DirectoryResult?) {
+ Log.d(TAG, "BasedFileLoader.onCanceled")
+ closeResult(result)
+ }
+
+ override fun onReset() {
+ Log.d(TAG, "BasedFileLoader.onReset")
+ super.onReset()
+
+ // Ensure the loader is stopped
+ onStopLoading()
+
+ closeResult(mResult)
+ mResult = null
+ }
+
+ /**
+ * Quietly closes the result cursor, if results are still available.
+ */
+ fun closeResult(result: DirectoryResult?) {
+ try {
+ result?.close()
+ } catch (e: Exception) {
+ Log.d(TAG, "Failed to close result", e)
+ }
+ }
+
+ private fun checkIfCursorStale(result: DirectoryResult?): Boolean {
+ if (result == null) {
+ return true
+ }
+ val cursor = result.cursor ?: return true
+ if (cursor.isClosed) {
+ return true
+ }
+ Log.d(TAG, "Long check of cursor staleness")
+ val count = cursor.count
+ if (!cursor.moveToPosition(-1)) {
+ return true
+ }
+ for (i in 1..count) {
+ if (!cursor.moveToNext()) {
+ return true
+ }
+ }
+ return false
+ }
+
+ /**
+ * A function that, for the specified location rooted in the root with the given rootId
+ * attempts to obtain a non-null cursor from the content provider client obtained for the
+ * given locationUri. It returns the first non-null cursor, if one can be found, or null,
+ * if it fails to query the given location for all known users.
+ */
+ fun queryLocation(
+ rootId: String,
+ locationUri: Uri,
+ queryArgs: Bundle?,
+ maxResults: Int,
+ ): Cursor? {
+ val authority = locationUri.authority ?: return null
+ for (userId in mUserIdList) {
+ Log.d(TAG, "BaseFileLoader.queryLocation for $userId at $locationUri")
+ val resolver = userId.getContentResolver(context)
+ try {
+ resolver.acquireUnstableContentProviderClient(
+ authority
+ ).use { client ->
+ if (client == null) {
+ return null
+ }
+ try {
+ val cursor =
+ client.query(locationUri, null, queryArgs, mSignal) ?: return null
+ return RootCursorWrapper(userId, authority, rootId, cursor, maxResults)
+ } catch (e: RemoteException) {
+ Log.d(TAG, "Failed to get cursor for $locationUri", e)
+ }
+ }
+ } catch (e: Exception) {
+ Log.d(TAG, "Failed to get a content provider client for $locationUri", e)
+ }
+ }
+
+ return null
+ }
+}
diff --git a/src/com/android/documentsui/loaders/FolderLoader.kt b/src/com/android/documentsui/loaders/FolderLoader.kt
new file mode 100644
index 000000000..2bfcd895d
--- /dev/null
+++ b/src/com/android/documentsui/loaders/FolderLoader.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2024 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.loaders
+
+import android.content.Context
+import android.provider.DocumentsContract
+import com.android.documentsui.ContentLock
+import com.android.documentsui.DirectoryResult
+import com.android.documentsui.LockingContentObserver
+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.UserId
+import com.android.documentsui.sorting.SortModel
+
+/**
+ * A specialization of the BaseFileLoader that loads the children of a single folder. To list
+ * a directory you need to provide:
+ *
+ * - The current application context
+ * - A content lock for which a locking content observer is built
+ * - A list of user IDs on behalf of which the search is conducted
+ * - The root info of the listed directory
+ * - The document info of the listed directory
+ * - a lookup from file extension to file type
+ * - The model capable of sorting results
+ */
+class FolderLoader(
+ context: Context,
+ userIdList: List<UserId>,
+ mimeTypeLookup: Lookup<String, String>,
+ contentLock: ContentLock,
+ private val mRoot: RootInfo,
+ private val mListedDir: DocumentInfo,
+ private val mOptions: QueryOptions,
+ private val mSortModel: SortModel,
+) : BaseFileLoader(context, userIdList, mimeTypeLookup) {
+
+ // An observer registered on the cursor to force a reload if the cursor reports a change.
+ private val mObserver = LockingContentObserver(contentLock, this::onContentChanged)
+
+ // Creates a directory result object corresponding to the current parameters of the loader.
+ override fun loadInBackground(): DirectoryResult? {
+ val rejectBeforeTimestamp = mOptions.getRejectBeforeTimestamp()
+ val folderChildrenUri = DocumentsContract.buildChildDocumentsUri(
+ mListedDir.authority,
+ mListedDir.documentId
+ )
+ var cursor =
+ queryLocation(mRoot.rootId, folderChildrenUri, null, ALL_RESULTS) ?: emptyCursor()
+ val filteredCursor = FilteringCursorWrapper(cursor)
+ filteredCursor.filterHiddenFiles(mOptions.showHidden)
+ if (rejectBeforeTimestamp > 0L) {
+ filteredCursor.filterLastModified(rejectBeforeTimestamp)
+ }
+ // TODO(b:380945065): Add filtering by category, such as images, audio, video.
+ val sortedCursor = mSortModel.sortCursor(filteredCursor, mMimeTypeLookup)
+ sortedCursor.registerContentObserver(mObserver)
+
+ val result = DirectoryResult()
+ result.doc = mListedDir
+ result.cursor = sortedCursor
+ return result
+ }
+}
diff --git a/src/com/android/documentsui/loaders/QueryOptions.kt b/src/com/android/documentsui/loaders/QueryOptions.kt
new file mode 100644
index 000000000..1e098b288
--- /dev/null
+++ b/src/com/android/documentsui/loaders/QueryOptions.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2024 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.loaders
+
+import java.time.Duration
+
+/**
+ * The constant to be used for the maxResults parameter, if we wish to get all (unlimited) results.
+ */
+const val ALL_RESULTS: Int = -1
+
+/**
+ * Common query options. These are:
+ * - maximum number to return; pass ALL_RESULTS to impose no limits.
+ * - maximum lastModified delta in milliseconds: the delta from now used to reject files that were
+ * not modified in the specified milliseconds; pass null for no limits.
+ * - maximum time the query should return, including empty, results; pass null for no limits.
+ * - whether or not to show hidden files.
+ * - A list of MIME types used to filter returned files.
+ */
+data class QueryOptions(
+ val maxResults: Int,
+ val maxLastModifiedDelta: Duration?,
+ val maxQueryTime: Duration?,
+ val showHidden: Boolean,
+ val acceptableMimeTypes: Array<String>,
+) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as QueryOptions
+
+ return maxResults == other.maxResults &&
+ maxLastModifiedDelta == other.maxLastModifiedDelta &&
+ maxQueryTime == other.maxQueryTime &&
+ showHidden == other.showHidden &&
+ acceptableMimeTypes.contentEquals(other.acceptableMimeTypes)
+ }
+
+ /**
+ * Helper method that computes the earliest valid last modified timestamp. Converts last
+ * modified duration to milliseconds past now. If the maxLastModifiedDelta is negative
+ * this method returns 0L.
+ */
+ fun getRejectBeforeTimestamp() =
+ if (maxLastModifiedDelta == null) {
+ 0L
+ } else {
+ System.currentTimeMillis() - maxLastModifiedDelta.toMillis()
+ }
+
+ /**
+ * Helper function that indicates if query time is unlimited. Due to internal reliance on
+ * Java's Duration class it assumes anything larger than 60 seconds has unlimited waiting
+ * time.
+ */
+ fun isQueryTimeUnlimited() = maxQueryTime == null
+
+ override fun hashCode(): Int {
+ var result = maxResults
+ result = 31 * result + maxLastModifiedDelta.hashCode()
+ result = 31 * result + maxQueryTime.hashCode()
+ result = 31 * result + showHidden.hashCode()
+ result = 31 * result + acceptableMimeTypes.contentHashCode()
+ return result
+ }
+}
diff --git a/src/com/android/documentsui/loaders/SearchLoader.kt b/src/com/android/documentsui/loaders/SearchLoader.kt
new file mode 100644
index 000000000..b394c009e
--- /dev/null
+++ b/src/com/android/documentsui/loaders/SearchLoader.kt
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2024 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.loaders
+
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.Document
+import android.util.Log
+import com.android.documentsui.DirectoryResult
+import com.android.documentsui.LockingContentObserver
+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.UserId
+import com.android.documentsui.sorting.SortModel
+import com.google.common.util.concurrent.AbstractFuture
+import java.io.Closeable
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.TimeUnit
+import kotlin.time.measureTime
+
+/**
+ * A specialization of the BaseFileLoader that searches the set of specified roots. To search
+ * the roots you must provider:
+ *
+ * - The current application context
+ * - A content lock for which a locking content observer is built
+ * - A list of user IDs, on whose behalf we query content provider clients.
+ * - A list of RootInfo objects representing searched roots
+ * - A query used to search for matching files.
+ * - Query options such as maximum number of results, last modified time delta, etc.
+ * - a lookup from file extension to file type
+ * - The model capable of sorting results
+ * - An acceptable mime types
+ */
+class SearchLoader(
+ context: Context,
+ userIdList: List<UserId>,
+ mimeTypeLookup: Lookup<String, String>,
+ private val mObserver: LockingContentObserver,
+ private val mRootList: Collection<RootInfo>,
+ private val mQuery: String?,
+ private val mOptions: QueryOptions,
+ private val mSortModel: SortModel,
+ private val mExecutorService: ExecutorService,
+) : BaseFileLoader(context, userIdList, mimeTypeLookup) {
+
+ /**
+ * Helper class that runs query on a single user for the given parameter. This class implements
+ * an abstract future so that if the task is completed, we can retrieve the cursor via the get
+ * method.
+ */
+ inner class SearchTask(
+ private val mRootId: String,
+ private val mSearchUri: Uri,
+ private val mQueryArgs: Bundle,
+ private val mLatch: CountDownLatch,
+ ) : Closeable, Runnable, AbstractFuture<Cursor>() {
+ private var mCursor: Cursor? = null
+ val cursor: Cursor? get() = mCursor
+ val taskId: String get() = mSearchUri.toString()
+
+ override fun close() {
+ mCursor = null
+ }
+
+ override fun run() {
+ val queryDuration = measureTime {
+ try {
+ mCursor = queryLocation(mRootId, mSearchUri, mQueryArgs, mOptions.maxResults)
+ set(mCursor)
+ } finally {
+ mLatch.countDown()
+ }
+ }
+ Log.d(TAG, "Query on $mSearchUri took $queryDuration")
+ }
+ }
+
+ @Volatile
+ private lateinit var mSearchTaskList: List<SearchTask>
+
+ // Creates a directory result object corresponding to the current parameters of the loader.
+ override fun loadInBackground(): DirectoryResult? {
+ val result = DirectoryResult()
+ // TODO(b:378590632): If root list has one root use it to construct result.doc
+ result.doc = DocumentInfo()
+ result.cursor = emptyCursor()
+
+ val searchedRoots = mRootList
+ val countDownLatch = CountDownLatch(searchedRoots.size)
+ val rejectBeforeTimestamp = mOptions.getRejectBeforeTimestamp()
+
+ // Step 1: Build a list of search tasks.
+ val searchTaskList =
+ createSearchTaskList(rejectBeforeTimestamp, countDownLatch, mRootList)
+ Log.d(TAG, "${searchTaskList.size} tasks have been created")
+
+ // Check if we are cancelled; if not copy the task list.
+ if (isLoadInBackgroundCanceled) {
+ return result
+ }
+ mSearchTaskList = searchTaskList
+
+ // Step 2: Enqueue tasks and wait for them to complete or time out.
+ for (task in mSearchTaskList) {
+ mExecutorService.execute(task)
+ }
+ Log.d(TAG, "${mSearchTaskList.size} tasks have been enqueued")
+
+ // Step 3: Wait for the results.
+ try {
+ if (mOptions.isQueryTimeUnlimited()) {
+ Log.d(TAG, "Waiting for results with no time limit")
+ countDownLatch.await()
+ } else {
+ Log.d(TAG, "Waiting ${mOptions.maxQueryTime!!.toMillis()}ms for results")
+ countDownLatch.await(
+ mOptions.maxQueryTime.toMillis(),
+ TimeUnit.MILLISECONDS
+ )
+ }
+ Log.d(TAG, "Waiting for results is done")
+ } catch (e: InterruptedException) {
+ Log.d(TAG, "Failed to complete all searches within ${mOptions.maxQueryTime}")
+ // TODO(b:388336095): Record a metrics indicating incomplete search.
+ throw RuntimeException(e)
+ }
+
+ // Step 4: Collect cursors from done tasks.
+ val cursorList = mutableListOf<Cursor>()
+ for (task in mSearchTaskList) {
+ Log.d(TAG, "Processing task ${task.taskId}")
+ if (isLoadInBackgroundCanceled) {
+ break
+ }
+ // TODO(b:388336095): Record a metric for each done and not done task.
+ val cursor = task.cursor
+ if (task.isDone && cursor != null) {
+ // TODO(b:388336095): Record a metric for null and not null cursor.
+ Log.d(TAG, "Task ${task.taskId} has ${cursor.count} results")
+ cursorList.add(cursor)
+ }
+ }
+ Log.d(TAG, "Search complete with ${cursorList.size} cursors collected")
+
+ // Step 5: Assign the cursor, after adding filtering and sorting, to the results.
+ val filteringCursor = FilteringCursorWrapper(toSingleCursor(cursorList))
+ filteringCursor.filterHiddenFiles(mOptions.showHidden)
+ if (rejectBeforeTimestamp > 0L) {
+ filteringCursor.filterLastModified(rejectBeforeTimestamp)
+ }
+ filteringCursor.filterMimes(mOptions.acceptableMimeTypes, arrayOf(Document.MIME_TYPE_DIR))
+ val sortingCursor = mSortModel.sortCursor(filteringCursor, mMimeTypeLookup)
+ sortingCursor.registerContentObserver(mObserver)
+ result.cursor = sortingCursor
+
+ // TODO(b:388336095): Record the total time it took to complete search.
+ return result
+ }
+
+ private fun createContentProviderQuery(root: RootInfo) =
+ if (mQuery == null || mQuery.isBlank()) {
+ DocumentsContract.buildRecentDocumentsUri(
+ root.authority,
+ root.rootId
+ )
+ } else {
+ // NOTE: We pass empty query, as the name matching query is placed in queryArgs.
+ DocumentsContract.buildSearchDocumentsUri(
+ root.authority,
+ root.rootId,
+ ""
+ )
+ }
+
+ private fun createQueryArgs(rejectBeforeTimestamp: Long): Bundle {
+ val queryArgs = Bundle()
+ mSortModel.addQuerySortArgs(queryArgs)
+ if (rejectBeforeTimestamp > 0L) {
+ queryArgs.putLong(
+ DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER,
+ rejectBeforeTimestamp
+ )
+ }
+ if (mQuery != null && !mQuery.isBlank()) {
+ queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, mQuery)
+ }
+ return queryArgs
+ }
+
+ /**
+ * Helper function that creates a list of search tasks for the given countdown latch.
+ */
+ private fun createSearchTaskList(
+ rejectBeforeTimestamp: Long,
+ countDownLatch: CountDownLatch,
+ rootList: Collection<RootInfo>
+ ): List<SearchTask> {
+ val searchTaskList = mutableListOf<SearchTask>()
+ for (root in rootList) {
+ if (isLoadInBackgroundCanceled) {
+ break
+ }
+ val rootSearchUri = createContentProviderQuery(root)
+ // TODO(b:385789236): Correctly pass sort order information.
+ val queryArgs = createQueryArgs(rejectBeforeTimestamp)
+ mSortModel.addQuerySortArgs(queryArgs)
+ Log.d(TAG, "Query $rootSearchUri and queryArgs $queryArgs")
+ val task = SearchTask(
+ root.rootId,
+ rootSearchUri,
+ queryArgs,
+ countDownLatch
+ )
+ searchTaskList.add(task)
+ }
+ return searchTaskList
+ }
+
+ override fun onReset() {
+ for (task in mSearchTaskList) {
+ task.close()
+ }
+ Log.d(TAG, "Resetting search loader; search task list emptied.")
+ super.onReset()
+ }
+}
diff --git a/tests/unit/com/android/documentsui/loaders/BaseLoaderTest.kt b/tests/unit/com/android/documentsui/loaders/BaseLoaderTest.kt
new file mode 100644
index 000000000..71512e9a1
--- /dev/null
+++ b/tests/unit/com/android/documentsui/loaders/BaseLoaderTest.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2024 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.loaders
+
+import android.os.Parcel
+import com.android.documentsui.DirectoryResult
+import com.android.documentsui.TestActivity
+import com.android.documentsui.TestConfigStore
+import com.android.documentsui.base.DocumentInfo
+import com.android.documentsui.sorting.SortModel
+import com.android.documentsui.testing.ActivityManagers
+import com.android.documentsui.testing.TestEnv
+import com.android.documentsui.testing.UserManagers
+import java.util.Locale
+import org.junit.Before
+
+/**
+ * Returns the number of matched files, or -1.
+ */
+fun getFileCount(result: DirectoryResult?) = result?.cursor?.count ?: -1
+
+/**
+ * Common base class for search and folder loaders.
+ */
+open class BaseLoaderTest {
+ lateinit var mEnv: TestEnv
+ lateinit var mActivity: TestActivity
+ lateinit var mTestConfigStore: TestConfigStore
+
+ @Before
+ open fun setUp() {
+ mEnv = TestEnv.create()
+ mTestConfigStore = TestConfigStore()
+ mEnv.state.configStore = mTestConfigStore
+ mEnv.state.showHiddenFiles = false
+ val parcel = Parcel.obtain()
+ mEnv.state.sortModel = SortModel.CREATOR.createFromParcel(parcel)
+
+ mActivity = TestActivity.create(mEnv)
+ mActivity.activityManager = ActivityManagers.create(false)
+ mActivity.userManager = UserManagers.create()
+ }
+
+ fun createDocuments(count: Int): Array<DocumentInfo> {
+ val extensionList = arrayOf("txt", "png", "mp4", "mpg")
+ return Array<DocumentInfo>(count) { i ->
+ val id = String.format(Locale.US, "%05d", i)
+ mEnv.model.createFile("sample-$id.${extensionList[i % extensionList.size]}")
+ }
+ }
+}
diff --git a/tests/unit/com/android/documentsui/loaders/FolderLoaderTest.kt b/tests/unit/com/android/documentsui/loaders/FolderLoaderTest.kt
new file mode 100644
index 000000000..92aaaa041
--- /dev/null
+++ b/tests/unit/com/android/documentsui/loaders/FolderLoaderTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 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.loaders
+
+import androidx.test.filters.SmallTest
+import com.android.documentsui.ContentLock
+import com.android.documentsui.base.DocumentInfo
+import com.android.documentsui.testing.TestFileTypeLookup
+import com.android.documentsui.testing.TestProvidersAccess
+import junit.framework.Assert.assertEquals
+import org.junit.Test
+
+@SmallTest
+class FolderLoaderTest : BaseLoaderTest() {
+ @Test
+ fun testLoadInBackground() {
+ val mockProvider = mEnv.mockProviders[TestProvidersAccess.DOWNLOADS.authority]
+ val docs = createDocuments(5)
+ mockProvider!!.setNextChildDocumentsReturns(*docs)
+ val userIds = listOf(TestProvidersAccess.DOWNLOADS.userId)
+ val queryOptions = QueryOptions(10, null, null, true, arrayOf<String>("*/*"))
+ val contentLock = ContentLock()
+ // TODO(majewski): Is there a better way to create Downloads root folder DocumentInfo?
+ val rootFolderInfo = DocumentInfo()
+ rootFolderInfo.authority = TestProvidersAccess.DOWNLOADS.authority
+ rootFolderInfo.userId = userIds[0]
+
+ val loader =
+ FolderLoader(
+ mActivity,
+ userIds,
+ TestFileTypeLookup(),
+ contentLock,
+ TestProvidersAccess.DOWNLOADS,
+ rootFolderInfo,
+ queryOptions,
+ mEnv.state.sortModel
+ )
+ val directoryResult = loader.loadInBackground()
+ assertEquals(docs.size, getFileCount(directoryResult))
+ }
+}
diff --git a/tests/unit/com/android/documentsui/loaders/SearchLoaderTest.kt b/tests/unit/com/android/documentsui/loaders/SearchLoaderTest.kt
new file mode 100644
index 000000000..6d78ffdd9
--- /dev/null
+++ b/tests/unit/com/android/documentsui/loaders/SearchLoaderTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2024 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.loaders
+
+import com.android.documentsui.ContentLock
+import com.android.documentsui.LockingContentObserver
+import com.android.documentsui.base.DocumentInfo
+import com.android.documentsui.testing.TestFileTypeLookup
+import com.android.documentsui.testing.TestProvidersAccess
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import junit.framework.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class SearchLoaderTest : BaseLoaderTest() {
+ lateinit var mExecutor: ExecutorService
+
+ @Before
+ override fun setUp() {
+ super.setUp()
+ mExecutor = Executors.newSingleThreadExecutor()
+ }
+
+ @Test
+ fun testLoadInBackground() {
+ val mockProvider = mEnv.mockProviders[TestProvidersAccess.DOWNLOADS.authority]
+ val docs = createDocuments(8)
+ mockProvider!!.setNextChildDocumentsReturns(*docs)
+ val userIds = listOf(TestProvidersAccess.DOWNLOADS.userId)
+ val queryOptions = QueryOptions(10, null, null, true, arrayOf("*/*"))
+ val contentLock = ContentLock()
+ val rootIds = listOf(TestProvidersAccess.DOWNLOADS)
+ val observer = LockingContentObserver(contentLock) {
+ }
+
+ // TODO(majewski): Is there a better way to create Downloads root folder DocumentInfo?
+ val rootFolderInfo = DocumentInfo()
+ rootFolderInfo.authority = TestProvidersAccess.DOWNLOADS.authority
+ rootFolderInfo.userId = userIds[0]
+
+ val loader =
+ SearchLoader(
+ mActivity,
+ userIds,
+ TestFileTypeLookup(),
+ observer,
+ rootIds,
+ "txt",
+ queryOptions,
+ mEnv.state.sortModel,
+ mExecutor,
+ )
+ val directoryResult = loader.loadInBackground()
+ // Expect only 2 text files to match txt.
+ assertEquals(2, getFileCount(directoryResult))
+ }
+}