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