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