summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Bo Majewski <majewski@google.com> 2024-11-27 23:57:23 +0000
committer Bo Majewski <majewski@google.com> 2025-01-23 03:58:45 +0000
commit7e6cb71ea41b14206bacf5b5fe57d99530d42b65 (patch)
treea77e8063d03ab310d756a3610079a4ab085ebd17
parentf9367a89348f219cc8d20348ff7ba3caaa201e25 (diff)
[DocsUI, Search]: Introduces new loaders for search v2.
Introduces a hierarchy of new loaders. The BaseFileLoader is the base class from which FolderLoader and SearchLoader are derived. It holds onto the common objects used by both loaders. It defines the queryLocation method that tries, with all known users, to query the given location. It returns the first non-null cursor it gets from the ContentProviderClient. These loaders are created in the same location as the old set of loaders, based on the useSearchV2 flag. The CL adds a very preliminary unit tests for the new loaders. Test: m DocumentsUIGoogle Bug: 378590632 Change-Id: Id8da196ef79f9a84dff12f5df5c086c318dd325c Flag: com.android.documentsui.flags.use_search_v2 Adding test data Change-Id: I641879d3dea8b0dc3fe84c50a5fbda0ce9b7907a
-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 b11567344..5a8702366 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;
@@ -889,16 +898,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);
@@ -975,6 +996,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))
+ }
+}