1 /*
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 
17 package com.android.documentsui;
18 
19 import static com.android.documentsui.base.SharedMinimal.DEBUG;
20 
21 import android.app.ActivityManager;
22 import android.content.ContentProviderClient;
23 import android.content.Context;
24 import android.database.Cursor;
25 import android.database.CursorWrapper;
26 import android.database.MatrixCursor;
27 import android.database.MergeCursor;
28 import android.net.Uri;
29 import android.os.Bundle;
30 import android.os.FileUtils;
31 import android.provider.DocumentsContract;
32 import android.provider.DocumentsContract.Document;
33 import android.util.Log;
34 
35 import androidx.annotation.GuardedBy;
36 import androidx.annotation.NonNull;
37 import androidx.loader.content.AsyncTaskLoader;
38 
39 import com.android.documentsui.base.DocumentInfo;
40 import com.android.documentsui.base.FilteringCursorWrapper;
41 import com.android.documentsui.base.Lookup;
42 import com.android.documentsui.base.RootInfo;
43 import com.android.documentsui.base.State;
44 import com.android.documentsui.roots.ProvidersAccess;
45 import com.android.documentsui.roots.RootCursorWrapper;
46 
47 import com.google.common.util.concurrent.AbstractFuture;
48 
49 import java.io.Closeable;
50 import java.io.IOException;
51 import java.util.ArrayList;
52 import java.util.Collection;
53 import java.util.HashMap;
54 import java.util.List;
55 import java.util.Map;
56 import java.util.concurrent.CountDownLatch;
57 import java.util.concurrent.ExecutionException;
58 import java.util.concurrent.Executor;
59 import java.util.concurrent.Semaphore;
60 import 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  */
66 public abstract class MultiRootDocumentsLoader extends AsyncTaskLoader<DirectoryResult> {
67 
68     private static final String TAG = "MultiRootDocsLoader";
69 
70     // 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;
88     private LockingContentObserver mObserver;
89 
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.
107      * @param lock the selection lock
108      * @param contentChangedCallback callback when content changed
109      */
MultiRootDocumentsLoader(Context context, ProvidersAccess providers, State state, Lookup<String, Executor> executors, Lookup<String, String> fileTypeMap)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
loadInBackground()128     public DirectoryResult loadInBackground() {
129         synchronized (mTasks) {
130             return loadInBackgroundLocked();
131         }
132     }
133 
setObserver(LockingContentObserver observer)134     public void setObserver(LockingContentObserver observer) {
135         mObserver = observer;
136     }
137 
loadInBackgroundLocked()138     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 
184                         // Filter hidden files.
185                         cursor = new FilteringCursorWrapper(cursor, mState.showHiddenFiles);
186 
187                         final FilteringCursorWrapper filtered = new FilteringCursorWrapper(
188                                 cursor, mState.acceptMimes, getRejectMimes(), rejectBefore) {
189                             @Override
190                             public void close() {
191                                 // Ignored, since we manage cursor lifecycle internally
192                             }
193                         };
194                         cursors.add(filtered);
195                     }
196 
197                 } catch (InterruptedException e) {
198                     throw new RuntimeException(e);
199                 } catch (ExecutionException e) {
200                     // We already logged on other side
201                 } catch (Exception e) {
202                     // Catch exceptions thrown when we read the cursor.
203                     Log.e(TAG, "Failed to query documents for authority: " + task.authority
204                             + ". Skip this authority.", e);
205                 }
206             } else {
207                 allDone = false;
208             }
209         }
210 
211         if (DEBUG) {
212             Log.d(TAG,
213                     "Found " + cursors.size() + " of " + totalQuerySize + " queries done");
214         }
215 
216         final DirectoryResult result = new DirectoryResult();
217         result.doc = new DocumentInfo();
218 
219         final Cursor merged;
220         if (cursors.size() > 0) {
221             merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()]));
222         } else {
223             // Return something when nobody is ready
224             merged = new MatrixCursor(new String[0]);
225         }
226 
227         final Cursor sorted;
228         if (isDocumentsMovable()) {
229             sorted = mState.sortModel.sortCursor(merged, mFileTypeMap);
230         } else {
231             final Cursor notMovableMasked = new NotMovableMaskCursor(merged);
232             sorted = mState.sortModel.sortCursor(notMovableMasked, mFileTypeMap);
233         }
234 
235         // Tell the UI if this is an in-progress result. When loading is complete, another update is
236         // sent with EXTRA_LOADING set to false.
237         Bundle extras = new Bundle();
238         extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone);
239         sorted.setExtras(extras);
240 
241         result.cursor = sorted;
242 
243         return result;
244     }
245 
246     /**
247      * Returns a map of Authority -> rootInfos.
248      */
indexRoots()249     private Map<String, List<RootInfo>> indexRoots() {
250         final Collection<RootInfo> roots = mProviders.getMatchingRootsBlocking(mState);
251         HashMap<String, List<RootInfo>> rootsIndex = new HashMap<>();
252         for (RootInfo root : roots) {
253             // ignore the root with authority is null. e.g. Recent
254             if (root.authority == null || shouldIgnoreRoot(root)
255                     || !mState.canInteractWith(root.userId)) {
256                 continue;
257             }
258 
259             if (!rootsIndex.containsKey(root.authority)) {
260                 rootsIndex.put(root.authority, new ArrayList<>());
261             }
262             rootsIndex.get(root.authority).add(root);
263         }
264 
265         return rootsIndex;
266     }
267 
getRejectBeforeTime()268     protected long getRejectBeforeTime() {
269         return -1;
270     }
271 
getRejectMimes()272     protected String[] getRejectMimes() {
273         return null;
274     }
275 
shouldIgnoreRoot(RootInfo root)276     protected boolean shouldIgnoreRoot(RootInfo root) {
277         return false;
278     }
279 
isDocumentsMovable()280     protected boolean isDocumentsMovable() {
281         return false;
282     }
283 
getQueryTask(String authority, List<RootInfo> rootInfos)284     protected abstract QueryTask getQueryTask(String authority, List<RootInfo> rootInfos);
285 
286     @Override
deliverResult(DirectoryResult result)287     public void deliverResult(DirectoryResult result) {
288         if (isReset()) {
289             FileUtils.closeQuietly(result);
290             return;
291         }
292         DirectoryResult oldResult = mResult;
293         mResult = result;
294 
295         if (isStarted()) {
296             super.deliverResult(result);
297         }
298 
299         if (oldResult != null && oldResult != result) {
300             FileUtils.closeQuietly(oldResult);
301         }
302     }
303 
304     @Override
onStartLoading()305     protected void onStartLoading() {
306         if (mResult != null) {
307             deliverResult(mResult);
308         }
309         if (takeContentChanged() || mResult == null) {
310             forceLoad();
311         }
312     }
313 
314     @Override
onStopLoading()315     protected void onStopLoading() {
316         cancelLoad();
317     }
318 
319     @Override
onCanceled(DirectoryResult result)320     public void onCanceled(DirectoryResult result) {
321         FileUtils.closeQuietly(result);
322     }
323 
324     @Override
onReset()325     protected void onReset() {
326         super.onReset();
327 
328         // Ensure the loader is stopped
329         onStopLoading();
330 
331         synchronized (mTasks) {
332             for (QueryTask task : mTasks.values()) {
333                 mExecutors.lookup(task.authority).execute(() -> FileUtils.closeQuietly(task));
334             }
335         }
336         FileUtils.closeQuietly(mResult);
337         mResult = null;
338     }
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 
NotMovableMaskCursor(Cursor cursor)349         private NotMovableMaskCursor(Cursor cursor) {
350             super(cursor);
351         }
352 
353         @Override
getInt(int index)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 
QueryTask(String authority, List<RootInfo> rootInfos)369         public QueryTask(String authority, List<RootInfo> rootInfos) {
370             this.authority = authority;
371             this.rootInfos = rootInfos;
372         }
373 
374         @Override
run()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 
getQueryUri(RootInfo rootInfo)393         protected abstract Uri getQueryUri(RootInfo rootInfo);
394 
generateResultCursor(RootInfo rootInfo, Cursor oriCursor)395         protected abstract RootCursorWrapper generateResultCursor(RootInfo rootInfo,
396                 Cursor oriCursor);
397 
addQueryArgs(@onNull Bundle queryArgs)398         protected void addQueryArgs(@NonNull Bundle queryArgs) {
399         }
400 
runInternal()401         private synchronized void runInternal() {
402             if (mIsClosed) {
403                 return;
404             }
405 
406             final int rootInfoCount = rootInfos.size();
407             final Cursor[] res = new Cursor[rootInfoCount];
408             mCursors = new Cursor[rootInfoCount];
409 
410             for (int i = 0; i < rootInfoCount; i++) {
411                 final RootInfo rootInfo = rootInfos.get(i);
412                 try (ContentProviderClient client =
413                              DocumentsApplication.acquireUnstableProviderOrThrow(
414                                      rootInfo.userId.getContentResolver(getContext()),
415                                      authority)) {
416                     final Uri uri = getQueryUri(rootInfo);
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);
422                         if (mObserver != null) {
423                             res[i].registerContentObserver(mObserver);
424                         }
425                         mCursors[i] = generateResultCursor(rootInfo, res[i]);
426                     } catch (Exception e) {
427                         Log.w(TAG, "Failed to load " + authority + ", " + rootInfo.rootId, e);
428                     }
429 
430                 } catch (Exception e) {
431                     Log.w(TAG, "Failed to acquire content resolver for authority: " + authority);
432                 }
433             }
434 
435             set(mCursors);
436 
437             mFirstPassLatch.countDown();
438             if (mFirstPassDone) {
439                 onContentChanged();
440             }
441         }
442 
443         @Override
close()444         public synchronized void close() throws IOException {
445             if (mCursors == null) {
446                 return;
447             }
448 
449             for (Cursor cursor : mCursors) {
450                 if (mObserver != null && cursor != null) {
451                     cursor.unregisterContentObserver(mObserver);
452                 }
453                 FileUtils.closeQuietly(cursor);
454             }
455 
456             mIsClosed = true;
457         }
458     }
459 }
460