diff options
author | 2025-01-23 01:37:50 -0800 | |
---|---|---|
committer | 2025-01-23 01:37:50 -0800 | |
commit | 2f300d81446a984538126dac78456ebc0a0978fd (patch) | |
tree | 33e5da271016765d44fdfccc9e00fc2617f8aa24 | |
parent | 41256da564bf24d564eb97d31008d0ec511e47d5 (diff) | |
parent | 7e6cb71ea41b14206bacf5b5fe57d99530d42b65 (diff) |
Merge "[DocsUI, Search]: Introduces new loaders for search v2." into main
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)) + } +} |