1 /*
2  * Copyright (C) 2013 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.Shared.DEBUG;
20 import static com.android.documentsui.base.Shared.TAG;
21 
22 import android.app.ActivityManager;
23 import android.content.AsyncTaskLoader;
24 import android.content.ContentProviderClient;
25 import android.content.Context;
26 import android.database.Cursor;
27 import android.database.MatrixCursor;
28 import android.database.MergeCursor;
29 import android.net.Uri;
30 import android.os.Bundle;
31 import android.provider.DocumentsContract;
32 import android.provider.DocumentsContract.Document;
33 import android.text.format.DateUtils;
34 import android.util.Log;
35 
36 import com.android.documentsui.base.Features;
37 import com.android.documentsui.base.FilteringCursorWrapper;
38 import com.android.documentsui.base.RootInfo;
39 import com.android.documentsui.base.State;
40 import com.android.documentsui.roots.ProvidersAccess;
41 import com.android.documentsui.roots.RootCursorWrapper;
42 import com.android.internal.annotations.GuardedBy;
43 
44 import com.google.common.util.concurrent.AbstractFuture;
45 
46 import libcore.io.IoUtils;
47 
48 import java.io.Closeable;
49 import java.io.IOException;
50 import java.util.ArrayList;
51 import java.util.Collection;
52 import java.util.HashMap;
53 import java.util.List;
54 import java.util.Map;
55 import java.util.concurrent.CountDownLatch;
56 import java.util.concurrent.ExecutionException;
57 import java.util.concurrent.Semaphore;
58 import java.util.concurrent.TimeUnit;
59 
60 public class RecentsLoader extends AsyncTaskLoader<DirectoryResult> {
61     // TODO: clean up cursor ownership so background thread doesn't traverse
62     // previously returned cursors for filtering/sorting; this currently races
63     // with the UI thread.
64 
65     private static final int MAX_OUTSTANDING_RECENTS = 4;
66     private static final int MAX_OUTSTANDING_RECENTS_SVELTE = 2;
67 
68     /**
69      * Time to wait for first pass to complete before returning partial results.
70      */
71     private static final int MAX_FIRST_PASS_WAIT_MILLIS = 500;
72 
73     /** Maximum documents from a single root. */
74     private static final int MAX_DOCS_FROM_ROOT = 64;
75 
76     /** Ignore documents older than this age. */
77     private static final long REJECT_OLDER_THAN = 45 * DateUtils.DAY_IN_MILLIS;
78 
79     /** MIME types that should always be excluded from recents. */
80     private static final String[] RECENT_REJECT_MIMES = new String[] { Document.MIME_TYPE_DIR };
81 
82     private final Semaphore mQueryPermits;
83 
84     private final ProvidersAccess mProviders;
85     private final State mState;
86     private final Features mFeatures;
87 
88     @GuardedBy("mTasks")
89     /** A authority -> RecentsTask map */
90     private final Map<String, RecentsTask> mTasks = new HashMap<>();
91 
92     private CountDownLatch mFirstPassLatch;
93     private volatile boolean mFirstPassDone;
94 
95     private DirectoryResult mResult;
96 
RecentsLoader(Context context, ProvidersAccess providers, State state, Features features)97     public RecentsLoader(Context context, ProvidersAccess providers, State state, Features features) {
98         super(context);
99         mProviders = providers;
100         mState = state;
101         mFeatures = features;
102 
103         // Keep clients around on high-RAM devices, since we'd be spinning them
104         // up moments later to fetch thumbnails anyway.
105         final ActivityManager am = (ActivityManager) getContext().getSystemService(
106                 Context.ACTIVITY_SERVICE);
107         mQueryPermits = new Semaphore(
108                 am.isLowRamDevice() ? MAX_OUTSTANDING_RECENTS_SVELTE : MAX_OUTSTANDING_RECENTS);
109     }
110 
111     @Override
loadInBackground()112     public DirectoryResult loadInBackground() {
113         synchronized (mTasks) {
114             return loadInBackgroundLocked();
115         }
116     }
117 
loadInBackgroundLocked()118     private DirectoryResult loadInBackgroundLocked() {
119         if (mFirstPassLatch == null) {
120             // First time through we kick off all the recent tasks, and wait
121             // around to see if everyone finishes quickly.
122             Map<String, List<String>> rootsIndex = indexRecentsRoots();
123 
124             for (String authority : rootsIndex.keySet()) {
125                 mTasks.put(authority, new RecentsTask(authority, rootsIndex.get(authority)));
126             }
127 
128             mFirstPassLatch = new CountDownLatch(mTasks.size());
129             for (RecentsTask task : mTasks.values()) {
130                 ProviderExecutor.forAuthority(task.authority).execute(task);
131             }
132 
133             try {
134                 mFirstPassLatch.await(MAX_FIRST_PASS_WAIT_MILLIS, TimeUnit.MILLISECONDS);
135                 mFirstPassDone = true;
136             } catch (InterruptedException e) {
137                 throw new RuntimeException(e);
138             }
139         }
140 
141         final long rejectBefore = System.currentTimeMillis() - REJECT_OLDER_THAN;
142 
143         // Collect all finished tasks
144         boolean allDone = true;
145         int totalQuerySize = 0;
146         List<Cursor> cursors = new ArrayList<>(mTasks.size());
147         for (RecentsTask task : mTasks.values()) {
148             if (task.isDone()) {
149                 try {
150                     final Cursor[] taskCursors = task.get();
151                     if (taskCursors == null || taskCursors.length == 0) continue;
152 
153                     totalQuerySize += taskCursors.length;
154                     for (Cursor cursor : taskCursors) {
155                         if (cursor == null) {
156                             // It's possible given an authority, some roots fail to return a cursor
157                             // after a query.
158                             continue;
159                         }
160                         final FilteringCursorWrapper filtered = new FilteringCursorWrapper(
161                                 cursor, mState.acceptMimes, RECENT_REJECT_MIMES, rejectBefore) {
162                             @Override
163                             public void close() {
164                                 // Ignored, since we manage cursor lifecycle internally
165                             }
166                         };
167                         cursors.add(filtered);
168                     }
169 
170                 } catch (InterruptedException e) {
171                     throw new RuntimeException(e);
172                 } catch (ExecutionException e) {
173                     // We already logged on other side
174                 } catch (Exception e) {
175                     // Catch exceptions thrown when we read the cursor.
176                     Log.e(TAG, "Failed to query Recents for authority: " + task.authority
177                             + ". Skip this authority in Recents.", e);
178                 }
179             } else {
180                 allDone = false;
181             }
182         }
183 
184         if (DEBUG) {
185             Log.d(TAG,
186                     "Found " + cursors.size() + " of " + totalQuerySize + " recent queries done");
187         }
188 
189         final DirectoryResult result = new DirectoryResult();
190 
191         final Cursor merged;
192         if (cursors.size() > 0) {
193             merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()]));
194         } else {
195             // Return something when nobody is ready
196             merged = new MatrixCursor(new String[0]);
197         }
198 
199         final Cursor sorted = mState.sortModel.sortCursor(merged);
200 
201         // Tell the UI if this is an in-progress result. When loading is complete, another update is
202         // sent with EXTRA_LOADING set to false.
203         Bundle extras = new Bundle();
204         extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone);
205         sorted.setExtras(extras);
206 
207         result.cursor = sorted;
208 
209         return result;
210     }
211 
212     /**
213      * Returns a map of Authority -> rootIds
214      */
indexRecentsRoots()215     private Map<String, List<String>> indexRecentsRoots() {
216         final Collection<RootInfo> roots = mProviders.getMatchingRootsBlocking(mState);
217         HashMap<String, List<String>> rootsIndex = new HashMap<>();
218         for (RootInfo root : roots) {
219             if (!root.supportsRecents()) {
220                 continue;
221             }
222 
223             if (!rootsIndex.containsKey(root.authority)) {
224                 rootsIndex.put(root.authority, new ArrayList<>());
225             }
226             rootsIndex.get(root.authority).add(root.rootId);
227         }
228 
229         return rootsIndex;
230     }
231 
232     @Override
cancelLoadInBackground()233     public void cancelLoadInBackground() {
234         super.cancelLoadInBackground();
235     }
236 
237     @Override
deliverResult(DirectoryResult result)238     public void deliverResult(DirectoryResult result) {
239         if (isReset()) {
240             IoUtils.closeQuietly(result);
241             return;
242         }
243         DirectoryResult oldResult = mResult;
244         mResult = result;
245 
246         if (isStarted()) {
247             super.deliverResult(result);
248         }
249 
250         if (oldResult != null && oldResult != result) {
251             IoUtils.closeQuietly(oldResult);
252         }
253     }
254 
255     @Override
onStartLoading()256     protected void onStartLoading() {
257         if (mResult != null) {
258             deliverResult(mResult);
259         }
260         if (takeContentChanged() || mResult == null) {
261             forceLoad();
262         }
263     }
264 
265     @Override
onStopLoading()266     protected void onStopLoading() {
267         cancelLoad();
268     }
269 
270     @Override
onCanceled(DirectoryResult result)271     public void onCanceled(DirectoryResult result) {
272         IoUtils.closeQuietly(result);
273     }
274 
275     @Override
onReset()276     protected void onReset() {
277         super.onReset();
278 
279         // Ensure the loader is stopped
280         onStopLoading();
281 
282         synchronized (mTasks) {
283             for (RecentsTask task : mTasks.values()) {
284                 IoUtils.closeQuietly(task);
285             }
286         }
287 
288         IoUtils.closeQuietly(mResult);
289         mResult = null;
290     }
291 
292     // TODO: create better transfer of ownership around cursor to ensure its
293     // closed in all edge cases.
294 
295     public class RecentsTask extends AbstractFuture<Cursor[]> implements Runnable, Closeable {
296         public final String authority;
297         public final List<String> rootIds;
298 
299         private Cursor[] mCursors;
300         private boolean mIsClosed = false;
301 
RecentsTask(String authority, List<String> rootIds)302         public RecentsTask(String authority, List<String> rootIds) {
303             this.authority = authority;
304             this.rootIds = rootIds;
305         }
306 
307         @Override
run()308         public void run() {
309             if (isCancelled()) return;
310 
311             try {
312                 mQueryPermits.acquire();
313             } catch (InterruptedException e) {
314                 return;
315             }
316 
317             try {
318                 runInternal();
319             } finally {
320                 mQueryPermits.release();
321             }
322         }
323 
runInternal()324         public synchronized void runInternal() {
325             if (mIsClosed) {
326                 return;
327             }
328 
329             ContentProviderClient client = null;
330             try {
331                 client = DocumentsApplication.acquireUnstableProviderOrThrow(
332                         getContext().getContentResolver(), authority);
333 
334                 final Cursor[] res = new Cursor[rootIds.size()];
335                 mCursors = new Cursor[rootIds.size()];
336                 for (int i = 0; i < rootIds.size(); i++) {
337                     final Uri uri =
338                             DocumentsContract.buildRecentDocumentsUri(authority, rootIds.get(i));
339                     try {
340                         if (mFeatures.isContentPagingEnabled()) {
341                             final Bundle queryArgs = new Bundle();
342                             mState.sortModel.addQuerySortArgs(queryArgs);
343                             res[i] = client.query(uri, null, queryArgs, null);
344                         } else {
345                             res[i] = client.query(
346                                     uri, null, null, null, mState.sortModel.getDocumentSortQuery());
347                         }
348                         mCursors[i] = new RootCursorWrapper(authority, rootIds.get(i), res[i],
349                                 MAX_DOCS_FROM_ROOT);
350                     } catch (Exception e) {
351                         Log.w(TAG, "Failed to load " + authority + ", " + rootIds.get(i), e);
352                     }
353                 }
354 
355             } catch (Exception e) {
356                 Log.w(TAG, "Failed to acquire content resolver for authority: " + authority);
357             } finally {
358                 ContentProviderClient.releaseQuietly(client);
359             }
360 
361             set(mCursors);
362 
363             mFirstPassLatch.countDown();
364             if (mFirstPassDone) {
365                 onContentChanged();
366             }
367         }
368 
369         @Override
close()370         public synchronized void close() throws IOException {
371             if (mCursors == null) {
372                 return;
373             }
374 
375             for (Cursor cursor : mCursors) {
376                 IoUtils.closeQuietly(cursor);
377             }
378 
379             mIsClosed = true;
380         }
381     }
382 }
383