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.DocumentsActivity.TAG;
20import static com.android.documentsui.DocumentsActivity.State.SORT_ORDER_LAST_MODIFIED;
21
22import android.app.ActivityManager;
23import android.content.AsyncTaskLoader;
24import android.content.ContentProviderClient;
25import android.content.Context;
26import android.database.Cursor;
27import android.database.MatrixCursor;
28import android.database.MergeCursor;
29import android.net.Uri;
30import android.os.Bundle;
31import android.provider.DocumentsContract;
32import android.provider.DocumentsContract.Document;
33import android.provider.DocumentsContract.Root;
34import android.text.format.DateUtils;
35import android.util.Log;
36
37import com.android.documentsui.DocumentsActivity.State;
38import com.android.documentsui.model.RootInfo;
39import com.google.android.collect.Maps;
40import com.google.common.collect.Lists;
41import com.google.common.util.concurrent.AbstractFuture;
42
43import libcore.io.IoUtils;
44
45import java.io.Closeable;
46import java.io.IOException;
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 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
102        public RecentTask(String authority, String rootId) {
103            this.authority = authority;
104            this.rootId = rootId;
105        }
106
107        @Override
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
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
150        public void close() throws IOException {
151            IoUtils.closeQuietly(mWithRoot);
152        }
153    }
154
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
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
258    public void cancelLoadInBackground() {
259        super.cancelLoadInBackground();
260    }
261
262    @Override
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
281    protected void onStartLoading() {
282        if (mResult != null) {
283            deliverResult(mResult);
284        }
285        if (takeContentChanged() || mResult == null) {
286            forceLoad();
287        }
288    }
289
290    @Override
291    protected void onStopLoading() {
292        cancelLoad();
293    }
294
295    @Override
296    public void onCanceled(DirectoryResult result) {
297        IoUtils.closeQuietly(result);
298    }
299
300    @Override
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