blob: 11208c16bbbef27e2d0f620d460292119e5f84c6 [file] [log] [blame]
Ivan Chiang282baa42018-08-08 18:54:14 +08001/*
2 * Copyright (C) 2018 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.documentsui;
18
19import static com.android.documentsui.base.SharedMinimal.DEBUG;
Ivan Chiang282baa42018-08-08 18:54:14 +080020
21import android.app.ActivityManager;
22import android.content.ContentProviderClient;
23import android.content.Context;
24import android.database.Cursor;
25import android.database.CursorWrapper;
26import android.database.MatrixCursor;
27import android.database.MergeCursor;
28import android.net.Uri;
29import android.os.Bundle;
30import android.os.FileUtils;
31import android.provider.DocumentsContract;
32import android.provider.DocumentsContract.Document;
33import android.util.Log;
34
35import androidx.annotation.GuardedBy;
36import androidx.annotation.NonNull;
37import androidx.loader.content.AsyncTaskLoader;
38
39import com.android.documentsui.base.DocumentInfo;
40import com.android.documentsui.base.FilteringCursorWrapper;
41import com.android.documentsui.base.Lookup;
42import com.android.documentsui.base.RootInfo;
43import com.android.documentsui.base.State;
44import com.android.documentsui.roots.ProvidersAccess;
45import com.android.documentsui.roots.RootCursorWrapper;
46
47import com.google.common.util.concurrent.AbstractFuture;
48
49import java.io.Closeable;
50import java.io.IOException;
51import java.util.ArrayList;
52import java.util.Collection;
53import java.util.HashMap;
54import java.util.List;
55import java.util.Map;
56import java.util.concurrent.CountDownLatch;
57import java.util.concurrent.ExecutionException;
58import java.util.concurrent.Executor;
59import java.util.concurrent.Semaphore;
60import java.util.concurrent.TimeUnit;
61
62/*
63 * The abstract class to query multiple roots from {@link android.provider.DocumentsProvider}
64 * and return the combined result.
65 */
66public abstract class MultiRootDocumentsLoader extends AsyncTaskLoader<DirectoryResult> {
Tony Huang4d179b72019-08-15 14:25:10 +080067
68 private static final String TAG = "MultiRootDocsLoader";
69
Ivan Chiang282baa42018-08-08 18:54:14 +080070 // TODO: clean up cursor ownership so background thread doesn't traverse
71 // previously returned cursors for filtering/sorting; this currently races
72 // with the UI thread.
73
74 private static final int MAX_OUTSTANDING_TASK = 4;
75 private static final int MAX_OUTSTANDING_TASK_SVELTE = 2;
76
77 /**
78 * Time to wait for first pass to complete before returning partial results.
79 */
80 private static final int MAX_FIRST_PASS_WAIT_MILLIS = 500;
81
82 protected final State mState;
83
84 private final Semaphore mQueryPermits;
85 private final ProvidersAccess mProviders;
86 private final Lookup<String, Executor> mExecutors;
87 private final Lookup<String, String> mFileTypeMap;
Tony Huang4d179b72019-08-15 14:25:10 +080088 private LockingContentObserver mObserver;
Ivan Chiang282baa42018-08-08 18:54:14 +080089
90 @GuardedBy("mTasks")
91 /** A authority -> QueryTask map */
92 private final Map<String, QueryTask> mTasks = new HashMap<>();
93
94 private CountDownLatch mFirstPassLatch;
95 private volatile boolean mFirstPassDone;
96
97 private DirectoryResult mResult;
98
99 /*
100 * Create the loader to query roots from {@link android.provider.DocumentsProvider}.
101 *
102 * @param context the context
103 * @param providers the providers
104 * @param state current state
105 * @param executors the executors of authorities
106 * @param fileTypeMap the map of mime types and file types.
Tony Huang4d179b72019-08-15 14:25:10 +0800107 * @param lock the selection lock
108 * @param contentChangedCallback callback when content changed
Ivan Chiang282baa42018-08-08 18:54:14 +0800109 */
110 public MultiRootDocumentsLoader(Context context, ProvidersAccess providers, State state,
111 Lookup<String, Executor> executors, Lookup<String, String> fileTypeMap) {
112
113 super(context);
114 mProviders = providers;
115 mState = state;
116 mExecutors = executors;
117 mFileTypeMap = fileTypeMap;
118
119 // Keep clients around on high-RAM devices, since we'd be spinning them
120 // up moments later to fetch thumbnails anyway.
121 final ActivityManager am = (ActivityManager) getContext().getSystemService(
122 Context.ACTIVITY_SERVICE);
123 mQueryPermits = new Semaphore(
124 am.isLowRamDevice() ? MAX_OUTSTANDING_TASK_SVELTE : MAX_OUTSTANDING_TASK);
125 }
126
127 @Override
128 public DirectoryResult loadInBackground() {
129 synchronized (mTasks) {
130 return loadInBackgroundLocked();
131 }
132 }
133
Tony Huang4d179b72019-08-15 14:25:10 +0800134 public void setObserver(LockingContentObserver observer) {
135 mObserver = observer;
136 }
137
Ivan Chiang282baa42018-08-08 18:54:14 +0800138 private DirectoryResult loadInBackgroundLocked() {
139 if (mFirstPassLatch == null) {
140 // First time through we kick off all the recent tasks, and wait
141 // around to see if everyone finishes quickly.
142 Map<String, List<RootInfo>> rootsIndex = indexRoots();
143
144 for (Map.Entry<String, List<RootInfo>> rootEntry : rootsIndex.entrySet()) {
145 mTasks.put(rootEntry.getKey(),
146 getQueryTask(rootEntry.getKey(), rootEntry.getValue()));
147 }
148
149 mFirstPassLatch = new CountDownLatch(mTasks.size());
150 for (QueryTask task : mTasks.values()) {
151 mExecutors.lookup(task.authority).execute(task);
152 }
153
154 try {
155 mFirstPassLatch.await(MAX_FIRST_PASS_WAIT_MILLIS, TimeUnit.MILLISECONDS);
156 mFirstPassDone = true;
157 } catch (InterruptedException e) {
158 throw new RuntimeException(e);
159 }
160 }
161
162 final long rejectBefore = getRejectBeforeTime();
163
164 // Collect all finished tasks
165 boolean allDone = true;
166 int totalQuerySize = 0;
167 List<Cursor> cursors = new ArrayList<>(mTasks.size());
168 for (QueryTask task : mTasks.values()) {
169 if (task.isDone()) {
170 try {
171 final Cursor[] taskCursors = task.get();
172 if (taskCursors == null || taskCursors.length == 0) {
173 continue;
174 }
175
176 totalQuerySize += taskCursors.length;
177 for (Cursor cursor : taskCursors) {
178 if (cursor == null) {
179 // It's possible given an authority, some roots fail to return a cursor
180 // after a query.
181 continue;
182 }
183 final FilteringCursorWrapper filtered = new FilteringCursorWrapper(
184 cursor, mState.acceptMimes, getRejectMimes(), rejectBefore) {
185 @Override
186 public void close() {
187 // Ignored, since we manage cursor lifecycle internally
188 }
189 };
190 cursors.add(filtered);
191 }
192
193 } catch (InterruptedException e) {
194 throw new RuntimeException(e);
195 } catch (ExecutionException e) {
196 // We already logged on other side
197 } catch (Exception e) {
198 // Catch exceptions thrown when we read the cursor.
199 Log.e(TAG, "Failed to query documents for authority: " + task.authority
200 + ". Skip this authority.", e);
201 }
202 } else {
203 allDone = false;
204 }
205 }
206
207 if (DEBUG) {
208 Log.d(TAG,
209 "Found " + cursors.size() + " of " + totalQuerySize + " queries done");
210 }
211
212 final DirectoryResult result = new DirectoryResult();
213 result.doc = new DocumentInfo();
214
215 final Cursor merged;
216 if (cursors.size() > 0) {
217 merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()]));
218 } else {
219 // Return something when nobody is ready
220 merged = new MatrixCursor(new String[0]);
221 }
222
223 final Cursor sorted;
224 if (isDocumentsMovable()) {
225 sorted = mState.sortModel.sortCursor(merged, mFileTypeMap);
226 } else {
227 final Cursor notMovableMasked = new NotMovableMaskCursor(merged);
228 sorted = mState.sortModel.sortCursor(notMovableMasked, mFileTypeMap);
229 }
230
231 // Tell the UI if this is an in-progress result. When loading is complete, another update is
232 // sent with EXTRA_LOADING set to false.
233 Bundle extras = new Bundle();
234 extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone);
235 sorted.setExtras(extras);
236
237 result.cursor = sorted;
238
239 return result;
240 }
241
242 /**
243 * Returns a map of Authority -> rootInfos.
244 */
245 private Map<String, List<RootInfo>> indexRoots() {
246 final Collection<RootInfo> roots = mProviders.getMatchingRootsBlocking(mState);
247 HashMap<String, List<RootInfo>> rootsIndex = new HashMap<>();
248 for (RootInfo root : roots) {
249 // ignore the root with authority is null. e.g. Recent
250 if (root.authority == null || shouldIgnoreRoot(root)) {
251 continue;
252 }
253
254 if (!rootsIndex.containsKey(root.authority)) {
255 rootsIndex.put(root.authority, new ArrayList<>());
256 }
257 rootsIndex.get(root.authority).add(root);
258 }
259
260 return rootsIndex;
261 }
262
263 protected long getRejectBeforeTime() {
264 return -1;
265 }
266
267 protected String[] getRejectMimes() {
268 return null;
269 }
270
271 protected boolean shouldIgnoreRoot(RootInfo root) {
272 return false;
273 }
274
275 protected boolean isDocumentsMovable() {
Tony Huang6d6ac1d2019-04-11 12:08:36 +0800276 return false;
Ivan Chiang282baa42018-08-08 18:54:14 +0800277 }
278
279 protected abstract QueryTask getQueryTask(String authority, List<RootInfo> rootInfos);
280
281 @Override
282 public void deliverResult(DirectoryResult result) {
283 if (isReset()) {
284 FileUtils.closeQuietly(result);
285 return;
286 }
287 DirectoryResult oldResult = mResult;
288 mResult = result;
289
290 if (isStarted()) {
291 super.deliverResult(result);
292 }
293
294 if (oldResult != null && oldResult != result) {
295 FileUtils.closeQuietly(oldResult);
296 }
297 }
298
299 @Override
300 protected void onStartLoading() {
301 if (mResult != null) {
302 deliverResult(mResult);
303 }
304 if (takeContentChanged() || mResult == null) {
305 forceLoad();
306 }
307 }
308
309 @Override
310 protected void onStopLoading() {
311 cancelLoad();
312 }
313
314 @Override
315 public void onCanceled(DirectoryResult result) {
316 FileUtils.closeQuietly(result);
317 }
318
319 @Override
320 protected void onReset() {
321 super.onReset();
322
323 // Ensure the loader is stopped
324 onStopLoading();
325
326 synchronized (mTasks) {
327 for (QueryTask task : mTasks.values()) {
328 FileUtils.closeQuietly(task);
329 }
330 }
331
332 FileUtils.closeQuietly(mResult);
333 mResult = null;
Tony Huang4d179b72019-08-15 14:25:10 +0800334
335 if (mObserver != null) {
336 getContext().getContentResolver().unregisterContentObserver(mObserver);
337 }
Ivan Chiang282baa42018-08-08 18:54:14 +0800338 }
339
340 // TODO: create better transfer of ownership around cursor to ensure its
341 // closed in all edge cases.
342
343 private static class NotMovableMaskCursor extends CursorWrapper {
344 private static final int NOT_MOVABLE_MASK =
345 ~(Document.FLAG_SUPPORTS_DELETE
346 | Document.FLAG_SUPPORTS_REMOVE
347 | Document.FLAG_SUPPORTS_MOVE);
348
349 private NotMovableMaskCursor(Cursor cursor) {
350 super(cursor);
351 }
352
353 @Override
354 public int getInt(int index) {
355 final int flagIndex = getWrappedCursor().getColumnIndex(Document.COLUMN_FLAGS);
356 final int value = super.getInt(index);
357 return (index == flagIndex) ? (value & NOT_MOVABLE_MASK) : value;
358 }
359 }
360
361 protected abstract class QueryTask extends AbstractFuture<Cursor[]> implements Runnable,
362 Closeable {
363 public final String authority;
364 public final List<RootInfo> rootInfos;
365
366 private Cursor[] mCursors;
367 private boolean mIsClosed = false;
368
369 public QueryTask(String authority, List<RootInfo> rootInfos) {
370 this.authority = authority;
371 this.rootInfos = rootInfos;
372 }
373
374 @Override
375 public void run() {
376 if (isCancelled()) {
377 return;
378 }
379
380 try {
381 mQueryPermits.acquire();
382 } catch (InterruptedException e) {
383 return;
384 }
385
386 try {
387 runInternal();
388 } finally {
389 mQueryPermits.release();
390 }
391 }
392
393 protected abstract Uri getQueryUri(RootInfo rootInfo);
394
395 protected abstract RootCursorWrapper generateResultCursor(RootInfo rootInfo,
396 Cursor oriCursor);
397
398 protected void addQueryArgs(@NonNull Bundle queryArgs) {
399 }
400
401 private synchronized void runInternal() {
402 if (mIsClosed) {
403 return;
404 }
405
406 ContentProviderClient client = null;
407 try {
408 client = DocumentsApplication.acquireUnstableProviderOrThrow(
409 getContext().getContentResolver(), authority);
410
411 final int rootInfoCount = rootInfos.size();
412 final Cursor[] res = new Cursor[rootInfoCount];
413 mCursors = new Cursor[rootInfoCount];
414
415 for (int i = 0; i < rootInfoCount; i++) {
416 final Uri uri = getQueryUri(rootInfos.get(i));
417 try {
418 final Bundle queryArgs = new Bundle();
419 mState.sortModel.addQuerySortArgs(queryArgs);
420 addQueryArgs(queryArgs);
421 res[i] = client.query(uri, null, queryArgs, null);
Tony Huang4d179b72019-08-15 14:25:10 +0800422 if (mObserver != null) {
423 res[i].registerContentObserver(mObserver);
424 }
Ivan Chiang282baa42018-08-08 18:54:14 +0800425 mCursors[i] = generateResultCursor(rootInfos.get(i), res[i]);
426 } catch (Exception e) {
427 Log.w(TAG, "Failed to load " + authority + ", " + rootInfos.get(i).rootId,
428 e);
429 }
430 }
431
432 } catch (Exception e) {
433 Log.w(TAG, "Failed to acquire content resolver for authority: " + authority);
434 } finally {
Jeff Sharkeybb68a652019-02-19 11:17:30 -0700435 FileUtils.closeQuietly(client);
Ivan Chiang282baa42018-08-08 18:54:14 +0800436 }
437
438 set(mCursors);
439
440 mFirstPassLatch.countDown();
441 if (mFirstPassDone) {
442 onContentChanged();
443 }
444 }
445
446 @Override
447 public synchronized void close() throws IOException {
448 if (mCursors == null) {
449 return;
450 }
451
452 for (Cursor cursor : mCursors) {
453 FileUtils.closeQuietly(cursor);
454 }
455
456 mIsClosed = true;
457 }
458 }
459}