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