1 /* 2 * Copyright (C) 2015 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 android.content.ContentResolver; 20 import android.content.Context; 21 import android.content.SharedPreferences; 22 import android.content.pm.ProviderInfo; 23 import android.content.res.AssetFileDescriptor; 24 import android.database.Cursor; 25 import android.database.MatrixCursor; 26 import android.database.MatrixCursor.RowBuilder; 27 import android.graphics.Point; 28 import android.net.Uri; 29 import android.os.Bundle; 30 import android.os.CancellationSignal; 31 import android.os.FileUtils; 32 import android.os.Handler; 33 import android.os.Looper; 34 import android.os.ParcelFileDescriptor; 35 import android.provider.DocumentsContract; 36 import android.provider.DocumentsContract.Document; 37 import android.provider.DocumentsContract.Root; 38 import android.provider.DocumentsProvider; 39 import android.text.TextUtils; 40 import android.util.Log; 41 42 import androidx.annotation.VisibleForTesting; 43 44 import java.io.File; 45 import java.io.FileNotFoundException; 46 import java.io.FileOutputStream; 47 import java.io.IOException; 48 import java.io.InputStream; 49 import java.io.OutputStream; 50 import java.util.ArrayList; 51 import java.util.Arrays; 52 import java.util.Collection; 53 import java.util.HashMap; 54 import java.util.HashSet; 55 import java.util.List; 56 import java.util.Map; 57 import java.util.Set; 58 import java.util.concurrent.CountDownLatch; 59 60 public class StubProvider extends DocumentsProvider { 61 62 public static final String DEFAULT_AUTHORITY = "com.android.documentsui.stubprovider"; 63 public static final String ROOT_0_ID = "TEST_ROOT_0"; 64 public static final String ROOT_1_ID = "TEST_ROOT_1"; 65 66 public static final String EXTRA_SIZE = "com.android.documentsui.stubprovider.SIZE"; 67 public static final String EXTRA_ROOT = "com.android.documentsui.stubprovider.ROOT"; 68 public static final String EXTRA_PATH = "com.android.documentsui.stubprovider.PATH"; 69 public static final String EXTRA_STREAM_TYPES 70 = "com.android.documentsui.stubprovider.STREAM_TYPES"; 71 public static final String EXTRA_CONTENT = "com.android.documentsui.stubprovider.CONTENT"; 72 public static final String EXTRA_ENABLE_ROOT_NOTIFICATION 73 = "com.android.documentsui.stubprovider.ROOT_NOTIFICATION"; 74 75 public static final String EXTRA_FLAGS = "com.android.documentsui.stubprovider.FLAGS"; 76 public static final String EXTRA_PARENT_ID = "com.android.documentsui.stubprovider.PARENT"; 77 78 private static final String TAG = "StubProvider"; 79 80 private static final String STORAGE_SIZE_KEY = "documentsui.stubprovider.size"; 81 private static int DEFAULT_ROOT_SIZE = 1024 * 1024 * 500; // 500 MB. 82 83 private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { 84 Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, 85 Root.COLUMN_AVAILABLE_BYTES 86 }; 87 private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { 88 Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, 89 Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, 90 }; 91 92 private final Map<String, StubDocument> mStorage = new HashMap<>(); 93 private final Map<String, RootInfo> mRoots = new HashMap<>(); 94 private final Object mWriteLock = new Object(); 95 96 private String mAuthority = DEFAULT_AUTHORITY; 97 private SharedPreferences mPrefs; 98 private Set<String> mSimulateReadErrorIds = new HashSet<>(); 99 private long mLoadingDuration = 0; 100 private boolean mRootNotification = true; 101 102 @Override attachInfo(Context context, ProviderInfo info)103 public void attachInfo(Context context, ProviderInfo info) { 104 mAuthority = info.authority; 105 super.attachInfo(context, info); 106 } 107 108 @Override onCreate()109 public boolean onCreate() { 110 clearCacheAndBuildRoots(); 111 return true; 112 } 113 114 @VisibleForTesting clearCacheAndBuildRoots()115 public void clearCacheAndBuildRoots() { 116 Log.d(TAG, "Resetting storage."); 117 removeChildrenRecursively(getContext().getCacheDir()); 118 mStorage.clear(); 119 mSimulateReadErrorIds.clear(); 120 121 mPrefs = getContext().getSharedPreferences( 122 "com.android.documentsui.stubprovider.preferences", Context.MODE_PRIVATE); 123 Collection<String> rootIds = mPrefs.getStringSet("roots", null); 124 if (rootIds == null) { 125 rootIds = Arrays.asList(new String[] { ROOT_0_ID, ROOT_1_ID }); 126 } 127 128 mRoots.clear(); 129 for (String rootId : rootIds) { 130 // Make a subdir in the cache dir for each root. 131 final File file = new File(getContext().getCacheDir(), rootId); 132 if (file.mkdir()) { 133 Log.i(TAG, "Created new root directory @ " + file.getPath()); 134 } 135 final RootInfo rootInfo = new RootInfo(file, getSize(rootId)); 136 137 if(rootId.equals(ROOT_1_ID)) { 138 rootInfo.setSearchEnabled(false); 139 } 140 141 mStorage.put(rootInfo.document.documentId, rootInfo.document); 142 mRoots.put(rootId, rootInfo); 143 } 144 145 mLoadingDuration = 0; 146 } 147 148 /** 149 * @return Storage size, in bytes. 150 */ getSize(String rootId)151 private long getSize(String rootId) { 152 final String key = STORAGE_SIZE_KEY + "." + rootId; 153 return mPrefs.getLong(key, DEFAULT_ROOT_SIZE); 154 } 155 156 @Override queryRoots(String[] projection)157 public Cursor queryRoots(String[] projection) throws FileNotFoundException { 158 final MatrixCursor result = new MatrixCursor(projection != null ? projection 159 : DEFAULT_ROOT_PROJECTION); 160 for (Map.Entry<String, RootInfo> entry : mRoots.entrySet()) { 161 final String id = entry.getKey(); 162 final RootInfo info = entry.getValue(); 163 final RowBuilder row = result.newRow(); 164 row.add(Root.COLUMN_ROOT_ID, id); 165 row.add(Root.COLUMN_FLAGS, info.flags); 166 row.add(Root.COLUMN_TITLE, id); 167 row.add(Root.COLUMN_DOCUMENT_ID, info.document.documentId); 168 row.add(Root.COLUMN_AVAILABLE_BYTES, info.getRemainingCapacity()); 169 } 170 return result; 171 } 172 173 @Override queryDocument(String documentId, String[] projection)174 public Cursor queryDocument(String documentId, String[] projection) 175 throws FileNotFoundException { 176 final MatrixCursor result = new MatrixCursor(projection != null ? projection 177 : DEFAULT_DOCUMENT_PROJECTION); 178 final StubDocument file = mStorage.get(documentId); 179 if (file == null) { 180 throw new FileNotFoundException(); 181 } 182 includeDocument(result, file); 183 return result; 184 } 185 186 @Override isChildDocument(String parentDocId, String docId)187 public boolean isChildDocument(String parentDocId, String docId) { 188 final StubDocument parentDocument = mStorage.get(parentDocId); 189 final StubDocument childDocument = mStorage.get(docId); 190 191 if (parentDocument.file == null || childDocument.file == null) { 192 return false; 193 } 194 195 return contains( 196 parentDocument.file.getAbsolutePath(), childDocument.file.getAbsolutePath()); 197 } 198 contains(String dirPath, String filePath)199 private static boolean contains(String dirPath, String filePath) { 200 if (dirPath.equals(filePath)) { 201 return true; 202 } 203 if (!dirPath.endsWith("/")) { 204 dirPath += "/"; 205 } 206 return filePath.startsWith(dirPath); 207 } 208 209 @Override createDocument(String parentId, String mimeType, String displayName)210 public String createDocument(String parentId, String mimeType, String displayName) 211 throws FileNotFoundException { 212 StubDocument parent = mStorage.get(parentId); 213 File file = createFile(parent, mimeType, displayName); 214 215 final StubDocument document = StubDocument.createRegularDocument(file, mimeType, parent); 216 mStorage.put(document.documentId, document); 217 Log.d(TAG, "Created document " + document.documentId); 218 notifyParentChanged(document.parentId); 219 getContext().getContentResolver().notifyChange( 220 DocumentsContract.buildDocumentUri(mAuthority, document.documentId), 221 null, false); 222 223 return document.documentId; 224 } 225 226 @Override deleteDocument(String documentId)227 public void deleteDocument(String documentId) 228 throws FileNotFoundException { 229 final StubDocument document = mStorage.get(documentId); 230 final long fileSize = document.file.length(); 231 if (document == null || !document.file.delete()) 232 throw new FileNotFoundException(); 233 synchronized (mWriteLock) { 234 document.rootInfo.size -= fileSize; 235 mStorage.remove(documentId); 236 } 237 Log.d(TAG, "Document deleted: " + documentId); 238 notifyParentChanged(document.parentId); 239 getContext().getContentResolver().notifyChange( 240 DocumentsContract.buildDocumentUri(mAuthority, document.documentId), 241 null, false); 242 } 243 244 @Override queryChildDocumentsForManage(String parentDocumentId, String[] projection, String sortOrder)245 public Cursor queryChildDocumentsForManage(String parentDocumentId, String[] projection, 246 String sortOrder) throws FileNotFoundException { 247 return queryChildDocuments(parentDocumentId, projection, sortOrder); 248 } 249 250 @Override queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder)251 public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) 252 throws FileNotFoundException { 253 if (mLoadingDuration > 0) { 254 final Uri notifyUri = DocumentsContract.buildDocumentUri(mAuthority, parentDocumentId); 255 final ContentResolver resolver = getContext().getContentResolver(); 256 new Handler(Looper.getMainLooper()).postDelayed( 257 () -> resolver.notifyChange(notifyUri, null, false), 258 mLoadingDuration); 259 mLoadingDuration = 0; 260 261 MatrixCursor cursor = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); 262 Bundle bundle = new Bundle(); 263 bundle.putBoolean(DocumentsContract.EXTRA_LOADING, true); 264 cursor.setExtras(bundle); 265 cursor.setNotificationUri(resolver, notifyUri); 266 return cursor; 267 } else { 268 final StubDocument parentDocument = mStorage.get(parentDocumentId); 269 if (parentDocument == null || parentDocument.file.isFile()) { 270 throw new FileNotFoundException(); 271 } 272 final MatrixCursor result = new MatrixCursor(projection != null ? projection 273 : DEFAULT_DOCUMENT_PROJECTION); 274 result.setNotificationUri(getContext().getContentResolver(), 275 DocumentsContract.buildChildDocumentsUri(mAuthority, parentDocumentId)); 276 StubDocument document; 277 for (File file : parentDocument.file.listFiles()) { 278 document = mStorage.get(getDocumentIdForFile(file)); 279 if (document != null) { 280 includeDocument(result, document); 281 } 282 } 283 return result; 284 } 285 } 286 287 @Override queryRecentDocuments(String rootId, String[] projection)288 public Cursor queryRecentDocuments(String rootId, String[] projection) 289 throws FileNotFoundException { 290 final MatrixCursor result = new MatrixCursor(projection != null ? projection 291 : DEFAULT_DOCUMENT_PROJECTION); 292 return result; 293 } 294 295 @Override querySearchDocuments(String rootId, String query, String[] projection)296 public Cursor querySearchDocuments(String rootId, String query, String[] projection) 297 throws FileNotFoundException { 298 299 StubDocument parentDocument = mRoots.get(rootId).document; 300 if (parentDocument == null || parentDocument.file.isFile()) { 301 throw new FileNotFoundException(); 302 } 303 304 final MatrixCursor result = new MatrixCursor( 305 projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION); 306 307 for (File file : parentDocument.file.listFiles()) { 308 if (file.getName().toLowerCase().contains(query)) { 309 StubDocument document = mStorage.get(getDocumentIdForFile(file)); 310 if (document != null) { 311 includeDocument(result, document); 312 } 313 } 314 } 315 return result; 316 } 317 318 @Override renameDocument(String documentId, String displayName)319 public String renameDocument(String documentId, String displayName) 320 throws FileNotFoundException { 321 322 StubDocument oldDoc = mStorage.get(documentId); 323 324 File before = oldDoc.file; 325 File after = new File(before.getParentFile(), displayName); 326 327 if (after.exists()) { 328 throw new IllegalStateException("Already exists " + after); 329 } 330 331 boolean result = before.renameTo(after); 332 333 if (!result) { 334 throw new IllegalStateException("Failed to rename to " + after); 335 } 336 337 StubDocument newDoc = StubDocument.createRegularDocument(after, oldDoc.mimeType, 338 mStorage.get(oldDoc.parentId)); 339 340 mStorage.remove(documentId); 341 notifyParentChanged(oldDoc.parentId); 342 getContext().getContentResolver().notifyChange( 343 DocumentsContract.buildDocumentUri(mAuthority, oldDoc.documentId), null, false); 344 345 mStorage.put(newDoc.documentId, newDoc); 346 notifyParentChanged(newDoc.parentId); 347 getContext().getContentResolver().notifyChange( 348 DocumentsContract.buildDocumentUri(mAuthority, newDoc.documentId), null, false); 349 350 if (!TextUtils.equals(documentId, newDoc.documentId)) { 351 return newDoc.documentId; 352 } else { 353 return null; 354 } 355 } 356 357 @Override openDocument(String docId, String mode, CancellationSignal signal)358 public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal) 359 throws FileNotFoundException { 360 361 final StubDocument document = mStorage.get(docId); 362 if (document == null || !document.file.isFile()) { 363 throw new FileNotFoundException(); 364 } 365 if ((document.flags & Document.FLAG_VIRTUAL_DOCUMENT) != 0) { 366 throw new IllegalStateException("Tried to open a virtual file."); 367 } 368 369 if ("r".equals(mode)) { 370 if (mSimulateReadErrorIds.contains(docId)) { 371 Log.d(TAG, "Simulated errs enabled. Open in the wrong mode."); 372 return ParcelFileDescriptor.open( 373 document.file, ParcelFileDescriptor.MODE_WRITE_ONLY); 374 } 375 return ParcelFileDescriptor.open(document.file, ParcelFileDescriptor.MODE_READ_ONLY); 376 } 377 if ("w".equals(mode)) { 378 return startWrite(document); 379 } 380 if ("wa".equals(mode)) { 381 return startWrite(document, true); 382 } 383 384 385 throw new FileNotFoundException(); 386 } 387 388 @VisibleForTesting simulateReadErrorsForFile(Uri uri)389 public void simulateReadErrorsForFile(Uri uri) { 390 simulateReadErrorsForFile(DocumentsContract.getDocumentId(uri)); 391 } 392 simulateReadErrorsForFile(String id)393 public void simulateReadErrorsForFile(String id) { 394 mSimulateReadErrorIds.add(id); 395 } 396 397 @Override openDocumentThumbnail( String docId, Point sizeHint, CancellationSignal signal)398 public AssetFileDescriptor openDocumentThumbnail( 399 String docId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException { 400 throw new FileNotFoundException(); 401 } 402 403 @Override openTypedDocument( String docId, String mimeTypeFilter, Bundle opts, CancellationSignal signal)404 public AssetFileDescriptor openTypedDocument( 405 String docId, String mimeTypeFilter, Bundle opts, CancellationSignal signal) 406 throws FileNotFoundException { 407 final StubDocument document = mStorage.get(docId); 408 if (document == null || !document.file.isFile() || document.streamTypes == null) { 409 throw new FileNotFoundException(); 410 } 411 for (final String mimeType : document.streamTypes) { 412 // Strict compare won't accept wildcards, but that's OK for tests, as DocumentsUI 413 // doesn't use them for getStreamTypes nor openTypedDocument. 414 if (mimeType.equals(mimeTypeFilter)) { 415 ParcelFileDescriptor pfd = ParcelFileDescriptor.open( 416 document.file, ParcelFileDescriptor.MODE_READ_ONLY); 417 if (mSimulateReadErrorIds.contains(docId)) { 418 pfd = new ParcelFileDescriptor(pfd) { 419 @Override 420 public void checkError() throws IOException { 421 throw new IOException("Test error"); 422 } 423 }; 424 } 425 return new AssetFileDescriptor(pfd, 0, document.file.length()); 426 } 427 } 428 throw new IllegalArgumentException("Invalid MIME type filter for openTypedDocument()."); 429 } 430 431 @Override getStreamTypes(Uri uri, String mimeTypeFilter)432 public String[] getStreamTypes(Uri uri, String mimeTypeFilter) { 433 final StubDocument document = mStorage.get(DocumentsContract.getDocumentId(uri)); 434 if (document == null) { 435 throw new IllegalArgumentException( 436 "The provided Uri is incorrect, or the file is gone."); 437 } 438 if (!"*/*".equals(mimeTypeFilter)) { 439 // Not used by DocumentsUI, so don't bother implementing it. 440 throw new UnsupportedOperationException(); 441 } 442 if (document.streamTypes == null) { 443 return null; 444 } 445 return document.streamTypes.toArray(new String[document.streamTypes.size()]); 446 } 447 startWrite(final StubDocument document)448 private ParcelFileDescriptor startWrite(final StubDocument document) 449 throws FileNotFoundException { 450 return startWrite(document, false); 451 } 452 startWrite(final StubDocument document, boolean append)453 private ParcelFileDescriptor startWrite(final StubDocument document, boolean append) 454 throws FileNotFoundException { 455 ParcelFileDescriptor[] pipe; 456 try { 457 pipe = ParcelFileDescriptor.createReliablePipe(); 458 } catch (IOException exception) { 459 throw new FileNotFoundException(); 460 } 461 final ParcelFileDescriptor readPipe = pipe[0]; 462 final ParcelFileDescriptor writePipe = pipe[1]; 463 464 postToMainThread(() -> { 465 InputStream inputStream = null; 466 OutputStream outputStream = null; 467 try { 468 Log.d(TAG, "Opening write stream on file " + document.documentId); 469 inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPipe); 470 outputStream = new FileOutputStream(document.file, append); 471 byte[] buffer = new byte[32 * 1024]; 472 int bytesToRead; 473 int bytesRead = 0; 474 while (bytesRead != -1) { 475 synchronized (mWriteLock) { 476 // This cast is safe because the max possible value is buffer.length. 477 bytesToRead = (int) Math.min(document.rootInfo.getRemainingCapacity(), 478 buffer.length); 479 if (bytesToRead == 0) { 480 closePipeWithErrorSilently(readPipe, "Not enough space."); 481 break; 482 } 483 bytesRead = inputStream.read(buffer, 0, bytesToRead); 484 if (bytesRead == -1) { 485 break; 486 } 487 outputStream.write(buffer, 0, bytesRead); 488 document.rootInfo.size += bytesRead; 489 } 490 } 491 } catch (IOException e) { 492 Log.e(TAG, "Error on close", e); 493 closePipeWithErrorSilently(readPipe, e.getMessage()); 494 } finally { 495 FileUtils.closeQuietly(inputStream); 496 FileUtils.closeQuietly(outputStream); 497 Log.d(TAG, "Closing write stream on file " + document.documentId); 498 notifyParentChanged(document.parentId); 499 getContext().getContentResolver().notifyChange( 500 DocumentsContract.buildDocumentUri(mAuthority, document.documentId), 501 null, false); 502 } 503 }); 504 505 return writePipe; 506 } 507 closePipeWithErrorSilently(ParcelFileDescriptor pipe, String error)508 private void closePipeWithErrorSilently(ParcelFileDescriptor pipe, String error) { 509 try { 510 pipe.closeWithError(error); 511 } catch (IOException ignore) { 512 } 513 } 514 515 @Override call(String method, String arg, Bundle extras)516 public Bundle call(String method, String arg, Bundle extras) { 517 // We're not supposed to override any of the default DocumentsProvider 518 // methods that are supported by "call", so javadoc asks that we 519 // always call super.call first and return if response is not null. 520 Bundle result = super.call(method, arg, extras); 521 if (result != null) { 522 return result; 523 } 524 525 switch (method) { 526 case "clear": 527 clearCacheAndBuildRoots(); 528 return null; 529 case "configure": 530 configure(arg, extras); 531 return null; 532 case "createVirtualFile": 533 return createVirtualFileFromBundle(extras); 534 case "simulateReadErrorsForFile": 535 simulateReadErrorsForFile(arg); 536 return null; 537 case "createDocumentWithFlags": 538 return dispatchCreateDocumentWithFlags(extras); 539 case "setLoadingDuration": 540 mLoadingDuration = extras.getLong(DocumentsContract.EXTRA_LOADING); 541 return null; 542 case "waitForWrite": 543 waitForWrite(); 544 return null; 545 } 546 547 return null; 548 } 549 createVirtualFileFromBundle(Bundle extras)550 private Bundle createVirtualFileFromBundle(Bundle extras) { 551 try { 552 Uri uri = createVirtualFile( 553 extras.getString(EXTRA_ROOT), 554 extras.getString(EXTRA_PATH), 555 extras.getString(Document.COLUMN_MIME_TYPE), 556 extras.getStringArrayList(EXTRA_STREAM_TYPES), 557 extras.getByteArray(EXTRA_CONTENT)); 558 559 String documentId = DocumentsContract.getDocumentId(uri); 560 Bundle result = new Bundle(); 561 result.putString(Document.COLUMN_DOCUMENT_ID, documentId); 562 return result; 563 } catch (IOException e) { 564 Log.e(TAG, "Couldn't create virtual file."); 565 } 566 567 return null; 568 } 569 dispatchCreateDocumentWithFlags(Bundle extras)570 private Bundle dispatchCreateDocumentWithFlags(Bundle extras) { 571 String rootId = extras.getString(EXTRA_PARENT_ID); 572 String mimeType = extras.getString(Document.COLUMN_MIME_TYPE); 573 String name = extras.getString(Document.COLUMN_DISPLAY_NAME); 574 List<String> streamTypes = extras.getStringArrayList(EXTRA_STREAM_TYPES); 575 int flags = extras.getInt(EXTRA_FLAGS); 576 577 Bundle out = new Bundle(); 578 String documentId = null; 579 try { 580 documentId = createDocument(rootId, mimeType, name, flags, streamTypes); 581 Uri uri = DocumentsContract.buildDocumentUri(mAuthority, documentId); 582 out.putParcelable(DocumentsContract.EXTRA_URI, uri); 583 } catch (FileNotFoundException e) { 584 Log.d(TAG, "Creating document with flags failed" + name); 585 } 586 return out; 587 } 588 waitForWrite()589 private void waitForWrite() { 590 try { 591 CountDownLatch latch = new CountDownLatch(1); 592 postToMainThread(latch::countDown); 593 latch.await(); 594 Log.d(TAG, "All writing is done."); 595 } catch (InterruptedException e) { 596 // should never happen 597 throw new RuntimeException(e); 598 } 599 } 600 postToMainThread(Runnable r)601 private void postToMainThread(Runnable r) { 602 new Handler(Looper.getMainLooper()).post(r); 603 } 604 createDocument(String parentId, String mimeType, String displayName, int flags, List<String> streamTypes)605 public String createDocument(String parentId, String mimeType, String displayName, int flags, 606 List<String> streamTypes) throws FileNotFoundException { 607 608 StubDocument parent = mStorage.get(parentId); 609 File file = createFile(parent, mimeType, displayName); 610 611 final StubDocument document = StubDocument.createDocumentWithFlags(file, mimeType, parent, 612 flags, streamTypes); 613 mStorage.put(document.documentId, document); 614 Log.d(TAG, "Created document " + document.documentId); 615 notifyParentChanged(document.parentId); 616 getContext().getContentResolver().notifyChange( 617 DocumentsContract.buildDocumentUri(mAuthority, document.documentId), 618 null, false); 619 620 return document.documentId; 621 } 622 createFile(StubDocument parent, String mimeType, String displayName)623 private File createFile(StubDocument parent, String mimeType, String displayName) 624 throws FileNotFoundException { 625 if (parent == null) { 626 throw new IllegalArgumentException( 627 "Can't create file " + displayName + " in null parent."); 628 } 629 if (!parent.file.isDirectory()) { 630 throw new IllegalArgumentException( 631 "Can't create file " + displayName + " inside non-directory parent " 632 + parent.file.getName()); 633 } 634 635 final File file = new File(parent.file, displayName); 636 if (file.exists()) { 637 throw new FileNotFoundException( 638 "Duplicate file names not supported for " + file); 639 } 640 641 if (mimeType.equals(Document.MIME_TYPE_DIR)) { 642 if (!file.mkdirs()) { 643 throw new FileNotFoundException("Failed to create directory(s): " + file); 644 } 645 Log.i(TAG, "Created new directory: " + file); 646 } else { 647 boolean created = false; 648 try { 649 created = file.createNewFile(); 650 } catch (IOException e) { 651 // We'll throw an FNF exception later :) 652 Log.e(TAG, "createNewFile operation failed for file: " + file, e); 653 } 654 if (!created) { 655 throw new FileNotFoundException("createNewFile operation failed for: " + file); 656 } 657 Log.i(TAG, "Created new file: " + file); 658 } 659 return file; 660 } 661 configure(String arg, Bundle extras)662 private void configure(String arg, Bundle extras) { 663 Log.d(TAG, "Configure " + arg); 664 String rootName = extras.getString(EXTRA_ROOT, ROOT_0_ID); 665 long rootSize = extras.getLong(EXTRA_SIZE, 100) * 1024 * 1024; 666 setSize(rootName, rootSize); 667 mRootNotification = extras.getBoolean(EXTRA_ENABLE_ROOT_NOTIFICATION, true); 668 } 669 notifyParentChanged(String parentId)670 private void notifyParentChanged(String parentId) { 671 getContext().getContentResolver().notifyChange( 672 DocumentsContract.buildChildDocumentsUri(mAuthority, parentId), null, false); 673 if (mRootNotification) { 674 // Notify also about possible change in remaining space on the root. 675 getContext().getContentResolver().notifyChange( 676 DocumentsContract.buildRootsUri(mAuthority), null, false); 677 } 678 } 679 includeDocument(MatrixCursor result, StubDocument document)680 private void includeDocument(MatrixCursor result, StubDocument document) { 681 final RowBuilder row = result.newRow(); 682 row.add(Document.COLUMN_DOCUMENT_ID, document.documentId); 683 row.add(Document.COLUMN_DISPLAY_NAME, document.file.getName()); 684 row.add(Document.COLUMN_SIZE, document.file.length()); 685 row.add(Document.COLUMN_MIME_TYPE, document.mimeType); 686 row.add(Document.COLUMN_FLAGS, document.flags); 687 row.add(Document.COLUMN_LAST_MODIFIED, document.file.lastModified()); 688 } 689 removeChildrenRecursively(File file)690 private void removeChildrenRecursively(File file) { 691 for (File childFile : file.listFiles()) { 692 if (childFile.isDirectory()) { 693 removeChildrenRecursively(childFile); 694 } 695 childFile.delete(); 696 } 697 } 698 setSize(String rootId, long rootSize)699 public void setSize(String rootId, long rootSize) { 700 RootInfo root = mRoots.get(rootId); 701 if (root != null) { 702 final String key = STORAGE_SIZE_KEY + "." + rootId; 703 Log.d(TAG, "Set size of " + key + " : " + rootSize); 704 705 // Persist the size. 706 SharedPreferences.Editor editor = mPrefs.edit(); 707 editor.putLong(key, rootSize); 708 editor.apply(); 709 // Apply the size in the current instance of this provider. 710 root.capacity = rootSize; 711 getContext().getContentResolver().notifyChange( 712 DocumentsContract.buildRootsUri(mAuthority), 713 null, false); 714 } else { 715 Log.e(TAG, "Attempt to configure non-existent root: " + rootId); 716 } 717 } 718 719 @VisibleForTesting createRegularFile(String rootId, String path, String mimeType, byte[] content)720 public Uri createRegularFile(String rootId, String path, String mimeType, byte[] content) 721 throws FileNotFoundException, IOException { 722 final File file = createFile(rootId, path, mimeType, content); 723 final StubDocument parent = mStorage.get(getDocumentIdForFile(file.getParentFile())); 724 if (parent == null) { 725 throw new FileNotFoundException("Parent not found."); 726 } 727 final StubDocument document = StubDocument.createRegularDocument(file, mimeType, parent); 728 mStorage.put(document.documentId, document); 729 return DocumentsContract.buildDocumentUri(mAuthority, document.documentId); 730 } 731 732 @VisibleForTesting createVirtualFile( String rootId, String path, String mimeType, List<String> streamTypes, byte[] content)733 public Uri createVirtualFile( 734 String rootId, String path, String mimeType, List<String> streamTypes, byte[] content) 735 throws FileNotFoundException, IOException { 736 737 final File file = createFile(rootId, path, mimeType, content); 738 final StubDocument parent = mStorage.get(getDocumentIdForFile(file.getParentFile())); 739 if (parent == null) { 740 throw new FileNotFoundException("Parent not found."); 741 } 742 final StubDocument document = StubDocument.createVirtualDocument( 743 file, mimeType, streamTypes, parent); 744 mStorage.put(document.documentId, document); 745 return DocumentsContract.buildDocumentUri(mAuthority, document.documentId); 746 } 747 748 @VisibleForTesting getFile(String rootId, String path)749 public File getFile(String rootId, String path) throws FileNotFoundException { 750 StubDocument root = mRoots.get(rootId).document; 751 if (root == null) { 752 throw new FileNotFoundException("No roots with the ID " + rootId + " were found"); 753 } 754 // Convert the path string into a path that's relative to the root. 755 File needle = new File(root.file, path.substring(1)); 756 757 StubDocument found = mStorage.get(getDocumentIdForFile(needle)); 758 if (found == null) { 759 return null; 760 } 761 return found.file; 762 } 763 createFile(String rootId, String path, String mimeType, byte[] content)764 private File createFile(String rootId, String path, String mimeType, byte[] content) 765 throws FileNotFoundException, IOException { 766 Log.d(TAG, "Creating test file " + rootId + " : " + path); 767 StubDocument root = mRoots.get(rootId).document; 768 if (root == null) { 769 throw new FileNotFoundException("No roots with the ID " + rootId + " were found"); 770 } 771 final File file = new File(root.file, path.substring(1)); 772 if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType)) { 773 if (!file.mkdirs()) { 774 throw new FileNotFoundException("Couldn't create directory " + file.getPath()); 775 } 776 } else { 777 if (!file.createNewFile()) { 778 throw new FileNotFoundException("Couldn't create file " + file.getPath()); 779 } 780 try (final FileOutputStream fout = new FileOutputStream(file)) { 781 fout.write(content); 782 } 783 } 784 return file; 785 } 786 787 final static class RootInfo { 788 private static final int DEFAULT_ROOTS_FLAGS = Root.FLAG_SUPPORTS_SEARCH 789 | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_IS_CHILD; 790 791 public final String name; 792 public final StubDocument document; 793 public long capacity; 794 public long size; 795 public int flags; 796 RootInfo(File file, long capacity)797 RootInfo(File file, long capacity) { 798 this.name = file.getName(); 799 this.capacity = 1024 * 1024; 800 this.flags = DEFAULT_ROOTS_FLAGS; 801 this.capacity = capacity; 802 this.size = 0; 803 this.document = StubDocument.createRootDocument(file, this); 804 } 805 getRemainingCapacity()806 public long getRemainingCapacity() { 807 return capacity - size; 808 } 809 setSearchEnabled(boolean enabled)810 public void setSearchEnabled(boolean enabled) { 811 flags = enabled ? (flags | Root.FLAG_SUPPORTS_SEARCH) 812 : (flags & ~Root.FLAG_SUPPORTS_SEARCH); 813 } 814 815 } 816 817 final static class StubDocument { 818 public final File file; 819 public final String documentId; 820 public final String mimeType; 821 public final List<String> streamTypes; 822 public final int flags; 823 public final String parentId; 824 public final RootInfo rootInfo; 825 StubDocument(File file, String mimeType, List<String> streamTypes, int flags, StubDocument parent)826 private StubDocument(File file, String mimeType, List<String> streamTypes, int flags, 827 StubDocument parent) { 828 this.file = file; 829 this.documentId = getDocumentIdForFile(file); 830 this.mimeType = mimeType; 831 this.streamTypes = streamTypes; 832 this.flags = flags; 833 this.parentId = parent.documentId; 834 this.rootInfo = parent.rootInfo; 835 } 836 StubDocument(File file, RootInfo rootInfo)837 private StubDocument(File file, RootInfo rootInfo) { 838 this.file = file; 839 this.documentId = getDocumentIdForFile(file); 840 this.mimeType = Document.MIME_TYPE_DIR; 841 this.streamTypes = new ArrayList<>(); 842 this.flags = Document.FLAG_DIR_SUPPORTS_CREATE | Document.FLAG_SUPPORTS_RENAME; 843 this.parentId = null; 844 this.rootInfo = rootInfo; 845 } 846 createRootDocument(File file, RootInfo rootInfo)847 public static StubDocument createRootDocument(File file, RootInfo rootInfo) { 848 return new StubDocument(file, rootInfo); 849 } 850 createRegularDocument( File file, String mimeType, StubDocument parent)851 public static StubDocument createRegularDocument( 852 File file, String mimeType, StubDocument parent) { 853 int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME; 854 if (file.isDirectory()) { 855 flags |= Document.FLAG_DIR_SUPPORTS_CREATE; 856 } else { 857 flags |= Document.FLAG_SUPPORTS_WRITE; 858 } 859 return new StubDocument(file, mimeType, new ArrayList<String>(), flags, parent); 860 } 861 createDocumentWithFlags( File file, String mimeType, StubDocument parent, int flags, List<String> streamTypes)862 public static StubDocument createDocumentWithFlags( 863 File file, String mimeType, StubDocument parent, int flags, 864 List<String> streamTypes) { 865 return new StubDocument(file, mimeType, streamTypes, flags, parent); 866 } 867 createVirtualDocument( File file, String mimeType, List<String> streamTypes, StubDocument parent)868 public static StubDocument createVirtualDocument( 869 File file, String mimeType, List<String> streamTypes, StubDocument parent) { 870 int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE 871 | Document.FLAG_VIRTUAL_DOCUMENT; 872 return new StubDocument(file, mimeType, streamTypes, flags, parent); 873 } 874 875 @Override toString()876 public String toString() { 877 return "StubDocument{" 878 + "path:" + file.getPath() 879 + ", documentId:" + documentId 880 + ", mimeType:" + mimeType 881 + ", streamTypes:" + streamTypes.toString() 882 + ", flags:" + flags 883 + ", parentId:" + parentId 884 + ", rootInfo:" + rootInfo 885 + "}"; 886 } 887 } 888 getDocumentIdForFile(File file)889 private static String getDocumentIdForFile(File file) { 890 return file.getAbsolutePath(); 891 } 892 } 893