/* * Copyright (C) 2018 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui; import static com.android.documentsui.base.SharedMinimal.DEBUG; import android.app.ActivityManager; import android.content.ContentProviderClient; import android.content.Context; import android.database.Cursor; import android.database.CursorWrapper; import android.database.MatrixCursor; import android.database.MergeCursor; import android.net.Uri; import android.os.Bundle; import android.os.FileUtils; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; import android.util.Log; import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.loader.content.AsyncTaskLoader; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.FilteringCursorWrapper; import com.android.documentsui.base.Lookup; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.State; import com.android.documentsui.roots.ProvidersAccess; import com.android.documentsui.roots.RootCursorWrapper; import com.google.common.util.concurrent.AbstractFuture; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; /* * The abstract class to query multiple roots from {@link android.provider.DocumentsProvider} * and return the combined result. */ public abstract class MultiRootDocumentsLoader extends AsyncTaskLoader { private static final String TAG = "MultiRootDocsLoader"; // TODO: clean up cursor ownership so background thread doesn't traverse // previously returned cursors for filtering/sorting; this currently races // with the UI thread. private static final int MAX_OUTSTANDING_TASK = 4; private static final int MAX_OUTSTANDING_TASK_SVELTE = 2; /** * Time to wait for first pass to complete before returning partial results. */ private static final int MAX_FIRST_PASS_WAIT_MILLIS = 500; protected final State mState; private final Semaphore mQueryPermits; private final ProvidersAccess mProviders; private final Lookup mExecutors; private final Lookup mFileTypeMap; private LockingContentObserver mObserver; @GuardedBy("mTasks") /** A authority -> QueryTask map */ private final Map mTasks = new HashMap<>(); private CountDownLatch mFirstPassLatch; private volatile boolean mFirstPassDone; private DirectoryResult mResult; /* * Create the loader to query roots from {@link android.provider.DocumentsProvider}. * * @param context the context * @param providers the providers * @param state current state * @param executors the executors of authorities * @param fileTypeMap the map of mime types and file types. */ public MultiRootDocumentsLoader(Context context, ProvidersAccess providers, State state, Lookup executors, Lookup fileTypeMap) { super(context); mProviders = providers; mState = state; mExecutors = executors; mFileTypeMap = fileTypeMap; // Keep clients around on high-RAM devices, since we'd be spinning them // up moments later to fetch thumbnails anyway. final ActivityManager am = (ActivityManager) getContext().getSystemService( Context.ACTIVITY_SERVICE); mQueryPermits = new Semaphore( am.isLowRamDevice() ? MAX_OUTSTANDING_TASK_SVELTE : MAX_OUTSTANDING_TASK); } @Override public DirectoryResult loadInBackground() { try { synchronized (mTasks) { return loadInBackgroundLocked(); } } catch (InterruptedException e) { Log.w(TAG, "loadInBackground is interrupted: ", e); return null; } } public void setObserver(LockingContentObserver observer) { mObserver = observer; } private DirectoryResult loadInBackgroundLocked() throws InterruptedException { if (mFirstPassLatch == null) { // First time through we kick off all the recent tasks, and wait // around to see if everyone finishes quickly. Map> rootsIndex = indexRoots(); for (Map.Entry> rootEntry : rootsIndex.entrySet()) { mTasks.put(rootEntry.getKey(), getQueryTask(rootEntry.getKey(), rootEntry.getValue())); } if (isLoadInBackgroundCanceled()) { // Loader is cancelled (e.g. about to be reset), preempt loading. throw new InterruptedException("Loading is cancelled!"); } mFirstPassLatch = new CountDownLatch(mTasks.size()); for (QueryTask task : mTasks.values()) { mExecutors.lookup(task.authority).execute(task); } try { mFirstPassLatch.await(MAX_FIRST_PASS_WAIT_MILLIS, TimeUnit.MILLISECONDS); mFirstPassDone = true; } catch (InterruptedException e) { throw new RuntimeException(e); } } final long rejectBefore = getRejectBeforeTime(); // Collect all finished tasks boolean allDone = true; int totalQuerySize = 0; List cursors = new ArrayList<>(mTasks.size()); for (QueryTask task : mTasks.values()) { if (isLoadInBackgroundCanceled()) { // Loader is cancelled (e.g. about to be reset), preempt loading. throw new InterruptedException("Loading is cancelled!"); } if (task.isDone()) { try { final Cursor[] taskCursors = task.get(); if (taskCursors == null || taskCursors.length == 0) { continue; } totalQuerySize += taskCursors.length; for (Cursor cursor : taskCursors) { if (cursor == null) { // It's possible given an authority, some roots fail to return a cursor // after a query. continue; } final FilteringCursorWrapper filteredCursor = new FilteringCursorWrapper(cursor) { @Override public void close() { // Ignored, since we manage cursor lifecycle internally } }; filteredCursor.filterHiddenFiles(mState.showHiddenFiles); filteredCursor.filterMimes(mState.acceptMimes, getRejectMimes()); filteredCursor.filterLastModified(rejectBefore); cursors.add(filteredCursor); } } catch (InterruptedException e) { throw new RuntimeException(e); } catch (ExecutionException e) { // We already logged on other side } catch (Exception e) { // Catch exceptions thrown when we read the cursor. Log.e(TAG, "Failed to query documents for authority: " + task.authority + ". Skip this authority.", e); } } else { allDone = false; } } if (DEBUG) { Log.d(TAG, "Found " + cursors.size() + " of " + totalQuerySize + " queries done"); } final DirectoryResult result = new DirectoryResult(); result.doc = new DocumentInfo(); final Cursor merged; if (cursors.size() > 0) { merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()])); } else { // Return something when nobody is ready merged = new MatrixCursor(new String[0]); } final Cursor sorted; if (isDocumentsMovable()) { sorted = mState.sortModel.sortCursor(merged, mFileTypeMap); } else { final Cursor notMovableMasked = new NotMovableMaskCursor(merged); sorted = mState.sortModel.sortCursor(notMovableMasked, mFileTypeMap); } // Tell the UI if this is an in-progress result. When loading is complete, another update is // sent with EXTRA_LOADING set to false. Bundle extras = new Bundle(); extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone); sorted.setExtras(extras); result.setCursor(sorted); return result; } /** * Returns a map of Authority -> rootInfos. */ private Map> indexRoots() { final Collection roots = mProviders.getMatchingRootsBlocking(mState); HashMap> rootsIndex = new HashMap<>(); for (RootInfo root : roots) { // ignore the root with authority is null. e.g. Recent if (root.authority == null || shouldIgnoreRoot(root) || !mState.canInteractWith(root.userId)) { continue; } if (!rootsIndex.containsKey(root.authority)) { rootsIndex.put(root.authority, new ArrayList<>()); } rootsIndex.get(root.authority).add(root); } return rootsIndex; } protected long getRejectBeforeTime() { return -1; } protected String[] getRejectMimes() { return null; } protected boolean shouldIgnoreRoot(RootInfo root) { return false; } protected boolean isDocumentsMovable() { return false; } protected abstract QueryTask getQueryTask(String authority, List rootInfos); @Override public void deliverResult(DirectoryResult result) { if (isReset()) { FileUtils.closeQuietly(result); return; } DirectoryResult oldResult = mResult; mResult = result; if (isStarted() && !isAbandoned() && !isLoadInBackgroundCanceled()) { super.deliverResult(result); } if (oldResult != null && oldResult != result) { FileUtils.closeQuietly(oldResult); } } @Override protected void onStartLoading() { boolean isCursorStale = checkIfCursorStale(mResult); if (mResult != null && !isCursorStale) { deliverResult(mResult); } if (takeContentChanged() || mResult == null || isCursorStale) { forceLoad(); } } @Override protected void onStopLoading() { cancelLoad(); } @Override public void onCanceled(DirectoryResult result) { FileUtils.closeQuietly(result); } @Override protected void onReset() { super.onReset(); synchronized (mTasks) { for (QueryTask task : mTasks.values()) { mExecutors.lookup(task.authority).execute(() -> FileUtils.closeQuietly(task)); } } FileUtils.closeQuietly(mResult); mResult = null; } // TODO: create better transfer of ownership around cursor to ensure its // closed in all edge cases. private static class NotMovableMaskCursor extends CursorWrapper { private static final int NOT_MOVABLE_MASK = ~(Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_REMOVE | Document.FLAG_SUPPORTS_MOVE); private NotMovableMaskCursor(Cursor cursor) { super(cursor); } @Override public int getInt(int index) { final int flagIndex = getWrappedCursor().getColumnIndex(Document.COLUMN_FLAGS); final int value = super.getInt(index); return (index == flagIndex) ? (value & NOT_MOVABLE_MASK) : value; } } protected abstract class QueryTask extends AbstractFuture implements Runnable, Closeable { public final String authority; public final List rootInfos; private Cursor[] mCursors; private boolean mIsClosed = false; public QueryTask(String authority, List rootInfos) { this.authority = authority; this.rootInfos = rootInfos; } @Override public void run() { if (isCancelled()) { return; } try { mQueryPermits.acquire(); } catch (InterruptedException e) { return; } try { runInternal(); } finally { mQueryPermits.release(); } } protected abstract Uri getQueryUri(RootInfo rootInfo); protected abstract RootCursorWrapper generateResultCursor(RootInfo rootInfo, Cursor oriCursor); protected void addQueryArgs(@NonNull Bundle queryArgs) { } private synchronized void runInternal() { if (mIsClosed) { return; } final int rootInfoCount = rootInfos.size(); final Cursor[] res = new Cursor[rootInfoCount]; mCursors = new Cursor[rootInfoCount]; for (int i = 0; i < rootInfoCount; i++) { final RootInfo rootInfo = rootInfos.get(i); try (ContentProviderClient client = DocumentsApplication.acquireUnstableProviderOrThrow( rootInfo.userId.getContentResolver(getContext()), authority)) { final Uri uri = getQueryUri(rootInfo); try { final Bundle queryArgs = new Bundle(); mState.sortModel.addQuerySortArgs(queryArgs); addQueryArgs(queryArgs); res[i] = client.query(uri, null, queryArgs, null); if (mObserver != null) { res[i].registerContentObserver(mObserver); } mCursors[i] = generateResultCursor(rootInfo, res[i]); } catch (Exception e) { Log.w(TAG, "Failed to load " + authority + ", " + rootInfo.rootId, e); } } catch (Exception e) { Log.w(TAG, "Failed to acquire content resolver for authority: " + authority); } } set(mCursors); mFirstPassLatch.countDown(); if (mFirstPassDone) { onContentChanged(); } } @Override public synchronized void close() throws IOException { if (mCursors == null) { return; } for (Cursor cursor : mCursors) { if (mObserver != null && cursor != null) { cursor.unregisterContentObserver(mObserver); } FileUtils.closeQuietly(cursor); } mIsClosed = true; } } private boolean checkIfCursorStale(DirectoryResult result) { if (result == null || result.getCursor() == null || result.getCursor().isClosed()) { return true; } Cursor cursor = result.getCursor(); try { cursor.moveToPosition(-1); for (int pos = 0; pos < cursor.getCount(); ++pos) { if (!cursor.moveToNext()) { return true; } } } catch (Exception e) { return true; } return false; } }