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