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