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