1 /* 2 * Copyright (C) 2018 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.SharedMinimal.DEBUG; 20 import static com.android.documentsui.base.SharedMinimal.TAG; 21 22 import android.app.ActivityManager; 23 import android.content.ContentProviderClient; 24 import android.content.Context; 25 import android.database.Cursor; 26 import android.database.CursorWrapper; 27 import android.database.MatrixCursor; 28 import android.database.MergeCursor; 29 import android.net.Uri; 30 import android.os.Bundle; 31 import android.os.FileUtils; 32 import android.provider.DocumentsContract; 33 import android.provider.DocumentsContract.Document; 34 import android.util.Log; 35 36 import androidx.annotation.GuardedBy; 37 import androidx.annotation.NonNull; 38 import androidx.loader.content.AsyncTaskLoader; 39 40 import com.android.documentsui.base.DocumentInfo; 41 import com.android.documentsui.base.FilteringCursorWrapper; 42 import com.android.documentsui.base.Lookup; 43 import com.android.documentsui.base.RootInfo; 44 import com.android.documentsui.base.State; 45 import com.android.documentsui.roots.ProvidersAccess; 46 import com.android.documentsui.roots.RootCursorWrapper; 47 48 import com.google.common.util.concurrent.AbstractFuture; 49 50 import java.io.Closeable; 51 import java.io.IOException; 52 import java.util.ArrayList; 53 import java.util.Collection; 54 import java.util.HashMap; 55 import java.util.List; 56 import java.util.Map; 57 import java.util.concurrent.CountDownLatch; 58 import java.util.concurrent.ExecutionException; 59 import java.util.concurrent.Executor; 60 import java.util.concurrent.Semaphore; 61 import java.util.concurrent.TimeUnit; 62 63 /* 64 * The abstract class to query multiple roots from {@link android.provider.DocumentsProvider} 65 * and return the combined result. 66 */ 67 public abstract class MultiRootDocumentsLoader extends AsyncTaskLoader<DirectoryResult> { 68 // TODO: clean up cursor ownership so background thread doesn't traverse 69 // previously returned cursors for filtering/sorting; this currently races 70 // with the UI thread. 71 72 private static final int MAX_OUTSTANDING_TASK = 4; 73 private static final int MAX_OUTSTANDING_TASK_SVELTE = 2; 74 75 /** 76 * Time to wait for first pass to complete before returning partial results. 77 */ 78 private static final int MAX_FIRST_PASS_WAIT_MILLIS = 500; 79 80 protected final State mState; 81 82 private final Semaphore mQueryPermits; 83 private final ProvidersAccess mProviders; 84 private final Lookup<String, Executor> mExecutors; 85 private final Lookup<String, String> mFileTypeMap; 86 87 @GuardedBy("mTasks") 88 /** A authority -> QueryTask map */ 89 private final Map<String, QueryTask> mTasks = new HashMap<>(); 90 91 private CountDownLatch mFirstPassLatch; 92 private volatile boolean mFirstPassDone; 93 94 private DirectoryResult mResult; 95 96 /* 97 * Create the loader to query roots from {@link android.provider.DocumentsProvider}. 98 * 99 * @param context the context 100 * @param providers the providers 101 * @param state current state 102 * @param executors the executors of authorities 103 * @param fileTypeMap the map of mime types and file types. 104 */ MultiRootDocumentsLoader(Context context, ProvidersAccess providers, State state, Lookup<String, Executor> executors, Lookup<String, String> fileTypeMap)105 public MultiRootDocumentsLoader(Context context, ProvidersAccess providers, State state, 106 Lookup<String, Executor> executors, Lookup<String, String> fileTypeMap) { 107 108 super(context); 109 mProviders = providers; 110 mState = state; 111 mExecutors = executors; 112 mFileTypeMap = fileTypeMap; 113 114 // Keep clients around on high-RAM devices, since we'd be spinning them 115 // up moments later to fetch thumbnails anyway. 116 final ActivityManager am = (ActivityManager) getContext().getSystemService( 117 Context.ACTIVITY_SERVICE); 118 mQueryPermits = new Semaphore( 119 am.isLowRamDevice() ? MAX_OUTSTANDING_TASK_SVELTE : MAX_OUTSTANDING_TASK); 120 } 121 122 @Override loadInBackground()123 public DirectoryResult loadInBackground() { 124 synchronized (mTasks) { 125 return loadInBackgroundLocked(); 126 } 127 } 128 loadInBackgroundLocked()129 private DirectoryResult loadInBackgroundLocked() { 130 if (mFirstPassLatch == null) { 131 // First time through we kick off all the recent tasks, and wait 132 // around to see if everyone finishes quickly. 133 Map<String, List<RootInfo>> rootsIndex = indexRoots(); 134 135 for (Map.Entry<String, List<RootInfo>> rootEntry : rootsIndex.entrySet()) { 136 mTasks.put(rootEntry.getKey(), 137 getQueryTask(rootEntry.getKey(), rootEntry.getValue())); 138 } 139 140 mFirstPassLatch = new CountDownLatch(mTasks.size()); 141 for (QueryTask task : mTasks.values()) { 142 mExecutors.lookup(task.authority).execute(task); 143 } 144 145 try { 146 mFirstPassLatch.await(MAX_FIRST_PASS_WAIT_MILLIS, TimeUnit.MILLISECONDS); 147 mFirstPassDone = true; 148 } catch (InterruptedException e) { 149 throw new RuntimeException(e); 150 } 151 } 152 153 final long rejectBefore = getRejectBeforeTime(); 154 155 // Collect all finished tasks 156 boolean allDone = true; 157 int totalQuerySize = 0; 158 List<Cursor> cursors = new ArrayList<>(mTasks.size()); 159 for (QueryTask task : mTasks.values()) { 160 if (task.isDone()) { 161 try { 162 final Cursor[] taskCursors = task.get(); 163 if (taskCursors == null || taskCursors.length == 0) { 164 continue; 165 } 166 167 totalQuerySize += taskCursors.length; 168 for (Cursor cursor : taskCursors) { 169 if (cursor == null) { 170 // It's possible given an authority, some roots fail to return a cursor 171 // after a query. 172 continue; 173 } 174 final FilteringCursorWrapper filtered = new FilteringCursorWrapper( 175 cursor, mState.acceptMimes, getRejectMimes(), rejectBefore) { 176 @Override 177 public void close() { 178 // Ignored, since we manage cursor lifecycle internally 179 } 180 }; 181 cursors.add(filtered); 182 } 183 184 } catch (InterruptedException e) { 185 throw new RuntimeException(e); 186 } catch (ExecutionException e) { 187 // We already logged on other side 188 } catch (Exception e) { 189 // Catch exceptions thrown when we read the cursor. 190 Log.e(TAG, "Failed to query documents for authority: " + task.authority 191 + ". Skip this authority.", e); 192 } 193 } else { 194 allDone = false; 195 } 196 } 197 198 if (DEBUG) { 199 Log.d(TAG, 200 "Found " + cursors.size() + " of " + totalQuerySize + " queries done"); 201 } 202 203 final DirectoryResult result = new DirectoryResult(); 204 result.doc = new DocumentInfo(); 205 206 final Cursor merged; 207 if (cursors.size() > 0) { 208 merged = new MergeCursor(cursors.toArray(new Cursor[cursors.size()])); 209 } else { 210 // Return something when nobody is ready 211 merged = new MatrixCursor(new String[0]); 212 } 213 214 final Cursor sorted; 215 if (isDocumentsMovable()) { 216 sorted = mState.sortModel.sortCursor(merged, mFileTypeMap); 217 } else { 218 final Cursor notMovableMasked = new NotMovableMaskCursor(merged); 219 sorted = mState.sortModel.sortCursor(notMovableMasked, mFileTypeMap); 220 } 221 222 // Tell the UI if this is an in-progress result. When loading is complete, another update is 223 // sent with EXTRA_LOADING set to false. 224 Bundle extras = new Bundle(); 225 extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone); 226 sorted.setExtras(extras); 227 228 result.cursor = sorted; 229 230 return result; 231 } 232 233 /** 234 * Returns a map of Authority -> rootInfos. 235 */ indexRoots()236 private Map<String, List<RootInfo>> indexRoots() { 237 final Collection<RootInfo> roots = mProviders.getMatchingRootsBlocking(mState); 238 HashMap<String, List<RootInfo>> rootsIndex = new HashMap<>(); 239 for (RootInfo root : roots) { 240 // ignore the root with authority is null. e.g. Recent 241 if (root.authority == null || shouldIgnoreRoot(root)) { 242 continue; 243 } 244 245 if (!rootsIndex.containsKey(root.authority)) { 246 rootsIndex.put(root.authority, new ArrayList<>()); 247 } 248 rootsIndex.get(root.authority).add(root); 249 } 250 251 return rootsIndex; 252 } 253 getRejectBeforeTime()254 protected long getRejectBeforeTime() { 255 return -1; 256 } 257 getRejectMimes()258 protected String[] getRejectMimes() { 259 return null; 260 } 261 shouldIgnoreRoot(RootInfo root)262 protected boolean shouldIgnoreRoot(RootInfo root) { 263 return false; 264 } 265 isDocumentsMovable()266 protected boolean isDocumentsMovable() { 267 return false; 268 } 269 getQueryTask(String authority, List<RootInfo> rootInfos)270 protected abstract QueryTask getQueryTask(String authority, List<RootInfo> rootInfos); 271 272 @Override deliverResult(DirectoryResult result)273 public void deliverResult(DirectoryResult result) { 274 if (isReset()) { 275 FileUtils.closeQuietly(result); 276 return; 277 } 278 DirectoryResult oldResult = mResult; 279 mResult = result; 280 281 if (isStarted()) { 282 super.deliverResult(result); 283 } 284 285 if (oldResult != null && oldResult != result) { 286 FileUtils.closeQuietly(oldResult); 287 } 288 } 289 290 @Override onStartLoading()291 protected void onStartLoading() { 292 if (mResult != null) { 293 deliverResult(mResult); 294 } 295 if (takeContentChanged() || mResult == null) { 296 forceLoad(); 297 } 298 } 299 300 @Override onStopLoading()301 protected void onStopLoading() { 302 cancelLoad(); 303 } 304 305 @Override onCanceled(DirectoryResult result)306 public void onCanceled(DirectoryResult result) { 307 FileUtils.closeQuietly(result); 308 } 309 310 @Override onReset()311 protected void onReset() { 312 super.onReset(); 313 314 // Ensure the loader is stopped 315 onStopLoading(); 316 317 synchronized (mTasks) { 318 for (QueryTask task : mTasks.values()) { 319 FileUtils.closeQuietly(task); 320 } 321 } 322 323 FileUtils.closeQuietly(mResult); 324 mResult = null; 325 } 326 327 // TODO: create better transfer of ownership around cursor to ensure its 328 // closed in all edge cases. 329 330 private static class NotMovableMaskCursor extends CursorWrapper { 331 private static final int NOT_MOVABLE_MASK = 332 ~(Document.FLAG_SUPPORTS_DELETE 333 | Document.FLAG_SUPPORTS_REMOVE 334 | Document.FLAG_SUPPORTS_MOVE); 335 NotMovableMaskCursor(Cursor cursor)336 private NotMovableMaskCursor(Cursor cursor) { 337 super(cursor); 338 } 339 340 @Override getInt(int index)341 public int getInt(int index) { 342 final int flagIndex = getWrappedCursor().getColumnIndex(Document.COLUMN_FLAGS); 343 final int value = super.getInt(index); 344 return (index == flagIndex) ? (value & NOT_MOVABLE_MASK) : value; 345 } 346 } 347 348 protected abstract class QueryTask extends AbstractFuture<Cursor[]> implements Runnable, 349 Closeable { 350 public final String authority; 351 public final List<RootInfo> rootInfos; 352 353 private Cursor[] mCursors; 354 private boolean mIsClosed = false; 355 QueryTask(String authority, List<RootInfo> rootInfos)356 public QueryTask(String authority, List<RootInfo> rootInfos) { 357 this.authority = authority; 358 this.rootInfos = rootInfos; 359 } 360 361 @Override run()362 public void run() { 363 if (isCancelled()) { 364 return; 365 } 366 367 try { 368 mQueryPermits.acquire(); 369 } catch (InterruptedException e) { 370 return; 371 } 372 373 try { 374 runInternal(); 375 } finally { 376 mQueryPermits.release(); 377 } 378 } 379 getQueryUri(RootInfo rootInfo)380 protected abstract Uri getQueryUri(RootInfo rootInfo); 381 generateResultCursor(RootInfo rootInfo, Cursor oriCursor)382 protected abstract RootCursorWrapper generateResultCursor(RootInfo rootInfo, 383 Cursor oriCursor); 384 addQueryArgs(@onNull Bundle queryArgs)385 protected void addQueryArgs(@NonNull Bundle queryArgs) { 386 } 387 runInternal()388 private synchronized void runInternal() { 389 if (mIsClosed) { 390 return; 391 } 392 393 ContentProviderClient client = null; 394 try { 395 client = DocumentsApplication.acquireUnstableProviderOrThrow( 396 getContext().getContentResolver(), authority); 397 398 final int rootInfoCount = rootInfos.size(); 399 final Cursor[] res = new Cursor[rootInfoCount]; 400 mCursors = new Cursor[rootInfoCount]; 401 402 for (int i = 0; i < rootInfoCount; i++) { 403 final Uri uri = getQueryUri(rootInfos.get(i)); 404 try { 405 final Bundle queryArgs = new Bundle(); 406 mState.sortModel.addQuerySortArgs(queryArgs); 407 addQueryArgs(queryArgs); 408 res[i] = client.query(uri, null, queryArgs, null); 409 mCursors[i] = generateResultCursor(rootInfos.get(i), res[i]); 410 } catch (Exception e) { 411 Log.w(TAG, "Failed to load " + authority + ", " + rootInfos.get(i).rootId, 412 e); 413 } 414 } 415 416 } catch (Exception e) { 417 Log.w(TAG, "Failed to acquire content resolver for authority: " + authority); 418 } finally { 419 FileUtils.closeQuietly(client); 420 } 421 422 set(mCursors); 423 424 mFirstPassLatch.countDown(); 425 if (mFirstPassDone) { 426 onContentChanged(); 427 } 428 } 429 430 @Override close()431 public synchronized void close() throws IOException { 432 if (mCursors == null) { 433 return; 434 } 435 436 for (Cursor cursor : mCursors) { 437 FileUtils.closeQuietly(cursor); 438 } 439 440 mIsClosed = true; 441 } 442 } 443 } 444