Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 2f300d81 authored by Bo Majewski's avatar Bo Majewski Committed by Android (Google) Code Review
Browse files

Merge "[DocsUI, Search]: Introduces new loaders for search v2." into main

parents 41256da5 7e6cb71e
Loading
Loading
Loading
Loading
+86 −2
Original line number Diff line number Diff line
@@ -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) {
+1 −1
Original line number Diff line number Diff line
@@ -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;

    /**
+3 −3
Original line number Diff line number Diff line
@@ -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;

+208 −0
Original line number Diff line number Diff line
/*
 * 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
    }
}
+79 −0
Original line number Diff line number Diff line
/*
 * 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
    }
}
Loading