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.DocumentsActivity.TAG;
20 import static com.android.documentsui.BaseActivity.State.SORT_ORDER_LAST_MODIFIED;
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.provider.DocumentsContract.Root;
34 import android.text.format.DateUtils;
35 import android.util.Log;
36 
37 import com.android.documentsui.BaseActivity.State;
38 import com.android.documentsui.model.RootInfo;
39 import com.google.android.collect.Maps;
40 import com.google.common.collect.Lists;
41 import com.google.common.util.concurrent.AbstractFuture;
42 
43 import libcore.io.IoUtils;
44 
45 import java.io.Closeable;
46 import java.io.IOException;
47 import java.util.Collection;
48 import java.util.HashMap;
49 import java.util.List;
50 import java.util.concurrent.CountDownLatch;
51 import java.util.concurrent.ExecutionException;
52 import java.util.concurrent.Semaphore;
53 import java.util.concurrent.TimeUnit;
54 
55 public class RecentLoader extends AsyncTaskLoader<DirectoryResult> {
56     private static final boolean LOGD = true;
57 
58     // TODO: clean up cursor ownership so background thread doesn't traverse
59     // previously returned cursors for filtering/sorting; this currently races
60     // with the UI thread.
61 
62     private static final int MAX_OUTSTANDING_RECENTS = 4;
63     private static final int MAX_OUTSTANDING_RECENTS_SVELTE = 2;
64 
65     /**
66      * Time to wait for first pass to complete before returning partial results.
67      */
68     private static final int MAX_FIRST_PASS_WAIT_MILLIS = 500;
69 
70     /** Maximum documents from a single root. */
71     private static final int MAX_DOCS_FROM_ROOT = 64;
72 
73     /** Ignore documents older than this age. */
74     private static final long REJECT_OLDER_THAN = 45 * DateUtils.DAY_IN_MILLIS;
75 
76     /** MIME types that should always be excluded from recents. */
77     private static final String[] RECENT_REJECT_MIMES = new String[] { Document.MIME_TYPE_DIR };
78 
79     private final Semaphore mQueryPermits;
80 
81     private final RootsCache mRoots;
82     private final State mState;
83 
84     private final HashMap<RootInfo, RecentTask> mTasks = Maps.newHashMap();
85 
86     private final int mSortOrder = State.SORT_ORDER_LAST_MODIFIED;
87 
88     private CountDownLatch mFirstPassLatch;
89     private volatile boolean mFirstPassDone;
90 
91     private DirectoryResult mResult;
92 
93     // TODO: create better transfer of ownership around cursor to ensure its
94     // closed in all edge cases.
95 
96     public class RecentTask extends AbstractFuture<Cursor> implements Runnable, Closeable {
97         public final String authority;
98         public final String rootId;
99 
100         private Cursor mWithRoot;
101 
RecentTask(String authority, String rootId)102         public RecentTask(String authority, String rootId) {
103             this.authority = authority;
104             this.rootId = rootId;
105         }
106 
107         @Override
run()108         public void run() {
109             if (isCancelled()) return;
110 
111             try {
112                 mQueryPermits.acquire();
113             } catch (InterruptedException e) {
114                 return;
115             }
116 
117             try {
118                 runInternal();
119             } finally {
120                 mQueryPermits.release();
121             }
122         }
123 
runInternal()124         public void runInternal() {
125             ContentProviderClient client = null;
126             try {
127                 client = DocumentsApplication.acquireUnstableProviderOrThrow(
128                         getContext().getContentResolver(), authority);
129 
130                 final Uri uri = DocumentsContract.buildRecentDocumentsUri(authority, rootId);
131                 final Cursor cursor = client.query(
132                         uri, null, null, null, DirectoryLoader.getQuerySortOrder(mSortOrder));
133                 mWithRoot = new RootCursorWrapper(authority, rootId, cursor, MAX_DOCS_FROM_ROOT);
134 
135             } catch (Exception e) {
136                 Log.w(TAG, "Failed to load " + authority + ", " + rootId, e);
137             } finally {
138                 ContentProviderClient.releaseQuietly(client);
139             }
140 
141             set(mWithRoot);
142 
143             mFirstPassLatch.countDown();
144             if (mFirstPassDone) {
145                 onContentChanged();
146             }
147         }
148 
149         @Override
close()150         public void close() throws IOException {
151             IoUtils.closeQuietly(mWithRoot);
152         }
153     }
154 
RecentLoader(Context context, RootsCache roots, State state)155     public RecentLoader(Context context, RootsCache roots, State state) {
156         super(context);
157         mRoots = roots;
158         mState = state;
159 
160         // Keep clients around on high-RAM devices, since we'd be spinning them
161         // up moments later to fetch thumbnails anyway.
162         final ActivityManager am = (ActivityManager) getContext().getSystemService(
163                 Context.ACTIVITY_SERVICE);
164         mQueryPermits = new Semaphore(
165                 am.isLowRamDevice() ? MAX_OUTSTANDING_RECENTS_SVELTE : MAX_OUTSTANDING_RECENTS);
166     }
167 
168     @Override
loadInBackground()169     public DirectoryResult loadInBackground() {
170         if (mFirstPassLatch == null) {
171             // First time through we kick off all the recent tasks, and wait
172             // around to see if everyone finishes quickly.
173 
174             final Collection<RootInfo> roots = mRoots.getMatchingRootsBlocking(mState);
175             for (RootInfo root : roots) {
176                 if ((root.flags & Root.FLAG_SUPPORTS_RECENTS) != 0) {
177                     final RecentTask task = new RecentTask(root.authority, root.rootId);
178                     mTasks.put(root, task);
179                 }
180             }
181 
182             mFirstPassLatch = new CountDownLatch(mTasks.size());
183             for (RecentTask task : mTasks.values()) {
184                 ProviderExecutor.forAuthority(task.authority).execute(task);
185             }
186 
187             try {
188                 mFirstPassLatch.await(MAX_FIRST_PASS_WAIT_MILLIS, TimeUnit.MILLISECONDS);
189                 mFirstPassDone = true;
190             } catch (InterruptedException e) {
191                 throw new RuntimeException(e);
192             }
193         }
194 
195         final long rejectBefore = System.currentTimeMillis() - REJECT_OLDER_THAN;
196 
197         // Collect all finished tasks
198         boolean allDone = true;
199         List<Cursor> cursors = Lists.newArrayList();
200         for (RecentTask task : mTasks.values()) {
201             if (task.isDone()) {
202                 try {
203                     final Cursor cursor = task.get();
204                     if (cursor == null) continue;
205 
206                     final FilteringCursorWrapper filtered = new FilteringCursorWrapper(
207                             cursor, mState.acceptMimes, RECENT_REJECT_MIMES, rejectBefore) {
208                         @Override
209                         public void close() {
210                             // Ignored, since we manage cursor lifecycle internally
211                         }
212                     };
213                     cursors.add(filtered);
214                 } catch (InterruptedException e) {
215                     throw new RuntimeException(e);
216                 } catch (ExecutionException e) {
217                     // We already logged on other side
218                 }
219             } else {
220                 allDone = false;
221             }
222         }
223 
224         if (LOGD) {
225             Log.d(TAG, "Found " + cursors.size() + " of " + mTasks.size() + " recent queries done");
226         }
227 
228         final DirectoryResult result = new DirectoryResult();
229         result.sortOrder = SORT_ORDER_LAST_MODIFIED;
230 
231         // Hint to UI if we're still loading
232         final Bundle extras = new Bundle();
233         if (!allDone) {
234             extras.putBoolean(DocumentsContract.EXTRA_LOADING, true);
235         }
236 
237         final Cursor merged;
238         if (cursors.size() > 0) {
239             merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()]));
240         } else {
241             // Return something when nobody is ready
242             merged = new MatrixCursor(new String[0]);
243         }
244 
245         final SortingCursorWrapper sorted = new SortingCursorWrapper(merged, result.sortOrder) {
246             @Override
247             public Bundle getExtras() {
248                 return extras;
249             }
250         };
251 
252         result.cursor = sorted;
253 
254         return result;
255     }
256 
257     @Override
cancelLoadInBackground()258     public void cancelLoadInBackground() {
259         super.cancelLoadInBackground();
260     }
261 
262     @Override
deliverResult(DirectoryResult result)263     public void deliverResult(DirectoryResult result) {
264         if (isReset()) {
265             IoUtils.closeQuietly(result);
266             return;
267         }
268         DirectoryResult oldResult = mResult;
269         mResult = result;
270 
271         if (isStarted()) {
272             super.deliverResult(result);
273         }
274 
275         if (oldResult != null && oldResult != result) {
276             IoUtils.closeQuietly(oldResult);
277         }
278     }
279 
280     @Override
onStartLoading()281     protected void onStartLoading() {
282         if (mResult != null) {
283             deliverResult(mResult);
284         }
285         if (takeContentChanged() || mResult == null) {
286             forceLoad();
287         }
288     }
289 
290     @Override
onStopLoading()291     protected void onStopLoading() {
292         cancelLoad();
293     }
294 
295     @Override
onCanceled(DirectoryResult result)296     public void onCanceled(DirectoryResult result) {
297         IoUtils.closeQuietly(result);
298     }
299 
300     @Override
onReset()301     protected void onReset() {
302         super.onReset();
303 
304         // Ensure the loader is stopped
305         onStopLoading();
306 
307         for (RecentTask task : mTasks.values()) {
308             IoUtils.closeQuietly(task);
309         }
310 
311         IoUtils.closeQuietly(mResult);
312         mResult = null;
313     }
314 }
315