blob: 11208c16bbbef27e2d0f620d460292119e5f84c6 [file] [log] [blame]
/*
* 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<DirectoryResult> {
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<String, Executor> mExecutors;
private final Lookup<String, String> mFileTypeMap;
private LockingContentObserver mObserver;
@GuardedBy("mTasks")
/** A authority -> QueryTask map */
private final Map<String, QueryTask> 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.
* @param lock the selection lock
* @param contentChangedCallback callback when content changed
*/
public MultiRootDocumentsLoader(Context context, ProvidersAccess providers, State state,
Lookup<String, Executor> executors, Lookup<String, String> 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() {
synchronized (mTasks) {
return loadInBackgroundLocked();
}
}
public void setObserver(LockingContentObserver observer) {
mObserver = observer;
}
private DirectoryResult loadInBackgroundLocked() {
if (mFirstPassLatch == null) {
// First time through we kick off all the recent tasks, and wait
// around to see if everyone finishes quickly.
Map<String, List<RootInfo>> rootsIndex = indexRoots();
for (Map.Entry<String, List<RootInfo>> rootEntry : rootsIndex.entrySet()) {
mTasks.put(rootEntry.getKey(),
getQueryTask(rootEntry.getKey(), rootEntry.getValue()));
}
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<Cursor> cursors = new ArrayList<>(mTasks.size());
for (QueryTask task : mTasks.values()) {
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 filtered = new FilteringCursorWrapper(
cursor, mState.acceptMimes, getRejectMimes(), rejectBefore) {
@Override
public void close() {
// Ignored, since we manage cursor lifecycle internally
}
};
cursors.add(filtered);
}
} 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.cursor = sorted;
return result;
}
/**
* Returns a map of Authority -> rootInfos.
*/
private Map<String, List<RootInfo>> indexRoots() {
final Collection<RootInfo> roots = mProviders.getMatchingRootsBlocking(mState);
HashMap<String, List<RootInfo>> rootsIndex = new HashMap<>();
for (RootInfo root : roots) {
// ignore the root with authority is null. e.g. Recent
if (root.authority == null || shouldIgnoreRoot(root)) {
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<RootInfo> rootInfos);
@Override
public void deliverResult(DirectoryResult result) {
if (isReset()) {
FileUtils.closeQuietly(result);
return;
}
DirectoryResult oldResult = mResult;
mResult = result;
if (isStarted()) {
super.deliverResult(result);
}
if (oldResult != null && oldResult != result) {
FileUtils.closeQuietly(oldResult);
}
}
@Override
protected void onStartLoading() {
if (mResult != null) {
deliverResult(mResult);
}
if (takeContentChanged() || mResult == null) {
forceLoad();
}
}
@Override
protected void onStopLoading() {
cancelLoad();
}
@Override
public void onCanceled(DirectoryResult result) {
FileUtils.closeQuietly(result);
}
@Override
protected void onReset() {
super.onReset();
// Ensure the loader is stopped
onStopLoading();
synchronized (mTasks) {
for (QueryTask task : mTasks.values()) {
FileUtils.closeQuietly(task);
}
}
FileUtils.closeQuietly(mResult);
mResult = null;
if (mObserver != null) {
getContext().getContentResolver().unregisterContentObserver(mObserver);
}
}
// 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<Cursor[]> implements Runnable,
Closeable {
public final String authority;
public final List<RootInfo> rootInfos;
private Cursor[] mCursors;
private boolean mIsClosed = false;
public QueryTask(String authority, List<RootInfo> 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;
}
ContentProviderClient client = null;
try {
client = DocumentsApplication.acquireUnstableProviderOrThrow(
getContext().getContentResolver(), authority);
final int rootInfoCount = rootInfos.size();
final Cursor[] res = new Cursor[rootInfoCount];
mCursors = new Cursor[rootInfoCount];
for (int i = 0; i < rootInfoCount; i++) {
final Uri uri = getQueryUri(rootInfos.get(i));
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(rootInfos.get(i), res[i]);
} catch (Exception e) {
Log.w(TAG, "Failed to load " + authority + ", " + rootInfos.get(i).rootId,
e);
}
}
} catch (Exception e) {
Log.w(TAG, "Failed to acquire content resolver for authority: " + authority);
} finally {
FileUtils.closeQuietly(client);
}
set(mCursors);
mFirstPassLatch.countDown();
if (mFirstPassDone) {
onContentChanged();
}
}
@Override
public synchronized void close() throws IOException {
if (mCursors == null) {
return;
}
for (Cursor cursor : mCursors) {
FileUtils.closeQuietly(cursor);
}
mIsClosed = true;
}
}
}