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
17package com.android.documentsui;
18
19import android.content.ContentResolver;
20import android.content.Context;
21import android.content.SharedPreferences;
22import android.content.pm.ProviderInfo;
23import android.content.res.AssetFileDescriptor;
24import android.database.Cursor;
25import android.database.MatrixCursor;
26import android.database.MatrixCursor.RowBuilder;
27import android.graphics.Point;
28import android.net.Uri;
29import android.os.*;
30import android.provider.DocumentsContract;
31import android.provider.DocumentsContract.Document;
32import android.provider.DocumentsContract.Root;
33import android.provider.DocumentsProvider;
34import android.support.annotation.VisibleForTesting;
35import android.text.TextUtils;
36import android.util.Log;
37
38import libcore.io.IoUtils;
39
40import java.io.File;
41import java.io.FileNotFoundException;
42import java.io.FileOutputStream;
43import java.io.IOException;
44import java.io.InputStream;
45import java.io.OutputStream;
46import java.util.ArrayList;
47import java.util.Arrays;
48import java.util.Collection;
49import java.util.HashMap;
50import java.util.HashSet;
51import java.util.List;
52import java.util.Map;
53import java.util.Set;
54import java.util.concurrent.CountDownLatch;
55
56public 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 * 100; // 100 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
99    public void attachInfo(Context context, ProviderInfo info) {
100        mAuthority = info.authority;
101        super.attachInfo(context, info);
102    }
103
104    @Override
105    public boolean onCreate() {
106        clearCacheAndBuildRoots();
107        return true;
108    }
109
110    @VisibleForTesting
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     */
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
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
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
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
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
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
225    public Cursor queryChildDocumentsForManage(String parentDocumentId, String[] projection,
226            String sortOrder) throws FileNotFoundException {
227        return queryChildDocuments(parentDocumentId, projection, sortOrder);
228    }
229
230    @Override
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
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
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
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
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
361        throw new FileNotFoundException();
362    }
363
364    @VisibleForTesting
365    public void simulateReadErrorsForFile(Uri uri) {
366        simulateReadErrorsForFile(DocumentsContract.getDocumentId(uri));
367    }
368
369    public void simulateReadErrorsForFile(String id) {
370        mSimulateReadErrorIds.add(id);
371    }
372
373    @Override
374    public AssetFileDescriptor openDocumentThumbnail(
375            String docId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException {
376        throw new FileNotFoundException();
377    }
378
379    @Override
380    public AssetFileDescriptor openTypedDocument(
381            String docId, String mimeTypeFilter, Bundle opts, CancellationSignal signal)
382            throws FileNotFoundException {
383        final StubDocument document = mStorage.get(docId);
384        if (document == null || !document.file.isFile() || document.streamTypes == null) {
385            throw new FileNotFoundException();
386        }
387        for (final String mimeType : document.streamTypes) {
388            // Strict compare won't accept wildcards, but that's OK for tests, as DocumentsUI
389            // doesn't use them for getStreamTypes nor openTypedDocument.
390            if (mimeType.equals(mimeTypeFilter)) {
391                ParcelFileDescriptor pfd = ParcelFileDescriptor.open(
392                            document.file, ParcelFileDescriptor.MODE_READ_ONLY);
393                if (mSimulateReadErrorIds.contains(docId)) {
394                    pfd = new ParcelFileDescriptor(pfd) {
395                        @Override
396                        public void checkError() throws IOException {
397                            throw new IOException("Test error");
398                        }
399                    };
400                }
401                return new AssetFileDescriptor(pfd, 0, document.file.length());
402            }
403        }
404        throw new IllegalArgumentException("Invalid MIME type filter for openTypedDocument().");
405    }
406
407    @Override
408    public String[] getStreamTypes(Uri uri, String mimeTypeFilter) {
409        final StubDocument document = mStorage.get(DocumentsContract.getDocumentId(uri));
410        if (document == null) {
411            throw new IllegalArgumentException(
412                    "The provided Uri is incorrect, or the file is gone.");
413        }
414        if (!"*/*".equals(mimeTypeFilter)) {
415            // Not used by DocumentsUI, so don't bother implementing it.
416            throw new UnsupportedOperationException();
417        }
418        if (document.streamTypes == null) {
419            return null;
420        }
421        return document.streamTypes.toArray(new String[document.streamTypes.size()]);
422    }
423
424    private ParcelFileDescriptor startWrite(final StubDocument document)
425            throws FileNotFoundException {
426        ParcelFileDescriptor[] pipe;
427        try {
428            pipe = ParcelFileDescriptor.createReliablePipe();
429        } catch (IOException exception) {
430            throw new FileNotFoundException();
431        }
432        final ParcelFileDescriptor readPipe = pipe[0];
433        final ParcelFileDescriptor writePipe = pipe[1];
434
435        postToMainThread(() -> {
436            InputStream inputStream = null;
437            OutputStream outputStream = null;
438            try {
439                Log.d(TAG, "Opening write stream on file " + document.documentId);
440                inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPipe);
441                outputStream = new FileOutputStream(document.file);
442                byte[] buffer = new byte[32 * 1024];
443                int bytesToRead;
444                int bytesRead = 0;
445                while (bytesRead != -1) {
446                    synchronized (mWriteLock) {
447                        // This cast is safe because the max possible value is buffer.length.
448                        bytesToRead = (int) Math.min(document.rootInfo.getRemainingCapacity(),
449                                buffer.length);
450                        if (bytesToRead == 0) {
451                            closePipeWithErrorSilently(readPipe, "Not enough space.");
452                            break;
453                        }
454                        bytesRead = inputStream.read(buffer, 0, bytesToRead);
455                        if (bytesRead == -1) {
456                            break;
457                        }
458                        outputStream.write(buffer, 0, bytesRead);
459                        document.rootInfo.size += bytesRead;
460                    }
461                }
462            } catch (IOException e) {
463                Log.e(TAG, "Error on close", e);
464                closePipeWithErrorSilently(readPipe, e.getMessage());
465            } finally {
466                IoUtils.closeQuietly(inputStream);
467                IoUtils.closeQuietly(outputStream);
468                Log.d(TAG, "Closing write stream on file " + document.documentId);
469                notifyParentChanged(document.parentId);
470                getContext().getContentResolver().notifyChange(
471                        DocumentsContract.buildDocumentUri(mAuthority, document.documentId),
472                        null, false);
473            }
474        });
475
476        return writePipe;
477    }
478
479    private void closePipeWithErrorSilently(ParcelFileDescriptor pipe, String error) {
480        try {
481            pipe.closeWithError(error);
482        } catch (IOException ignore) {
483        }
484    }
485
486    @Override
487    public Bundle call(String method, String arg, Bundle extras) {
488        // We're not supposed to override any of the default DocumentsProvider
489        // methods that are supported by "call", so javadoc asks that we
490        // always call super.call first and return if response is not null.
491        Bundle result = super.call(method, arg, extras);
492        if (result != null) {
493            return result;
494        }
495
496        switch (method) {
497            case "clear":
498                clearCacheAndBuildRoots();
499                return null;
500            case "configure":
501                configure(arg, extras);
502                return null;
503            case "createVirtualFile":
504                return createVirtualFileFromBundle(extras);
505            case "simulateReadErrorsForFile":
506                simulateReadErrorsForFile(arg);
507                return null;
508            case "createDocumentWithFlags":
509                return dispatchCreateDocumentWithFlags(extras);
510            case "setLoadingDuration":
511                mLoadingDuration = extras.getLong(DocumentsContract.EXTRA_LOADING);
512                return null;
513            case "waitForWrite":
514                waitForWrite();
515                return null;
516        }
517
518        return null;
519    }
520
521    private Bundle createVirtualFileFromBundle(Bundle extras) {
522        try {
523            Uri uri = createVirtualFile(
524                    extras.getString(EXTRA_ROOT),
525                    extras.getString(EXTRA_PATH),
526                    extras.getString(Document.COLUMN_MIME_TYPE),
527                    extras.getStringArrayList(EXTRA_STREAM_TYPES),
528                    extras.getByteArray(EXTRA_CONTENT));
529
530            String documentId = DocumentsContract.getDocumentId(uri);
531            Bundle result = new Bundle();
532            result.putString(Document.COLUMN_DOCUMENT_ID, documentId);
533            return result;
534        } catch (IOException e) {
535            Log.e(TAG, "Couldn't create virtual file.");
536        }
537
538        return null;
539    }
540
541    private Bundle dispatchCreateDocumentWithFlags(Bundle extras) {
542        String rootId = extras.getString(EXTRA_PARENT_ID);
543        String mimeType = extras.getString(Document.COLUMN_MIME_TYPE);
544        String name = extras.getString(Document.COLUMN_DISPLAY_NAME);
545        List<String> streamTypes = extras.getStringArrayList(EXTRA_STREAM_TYPES);
546        int flags = extras.getInt(EXTRA_FLAGS);
547
548        Bundle out = new Bundle();
549        String documentId = null;
550        try {
551            documentId = createDocument(rootId, mimeType, name, flags, streamTypes);
552            Uri uri = DocumentsContract.buildDocumentUri(mAuthority, documentId);
553            out.putParcelable(DocumentsContract.EXTRA_URI, uri);
554        } catch (FileNotFoundException e) {
555            Log.d(TAG, "Creating document with flags failed" + name);
556        }
557        return out;
558    }
559
560    private void waitForWrite() {
561        try {
562            CountDownLatch latch = new CountDownLatch(1);
563            postToMainThread(latch::countDown);
564            latch.await();
565            Log.d(TAG, "All writing is done.");
566        } catch (InterruptedException e) {
567            // should never happen
568            throw new RuntimeException(e);
569        }
570    }
571
572    private void postToMainThread(Runnable r) {
573        new Handler(Looper.getMainLooper()).post(r);
574    }
575
576    public String createDocument(String parentId, String mimeType, String displayName, int flags,
577            List<String> streamTypes) throws FileNotFoundException {
578
579        StubDocument parent = mStorage.get(parentId);
580        File file = createFile(parent, mimeType, displayName);
581
582        final StubDocument document = StubDocument.createDocumentWithFlags(file, mimeType, parent,
583                flags, streamTypes);
584        mStorage.put(document.documentId, document);
585        Log.d(TAG, "Created document " + document.documentId);
586        notifyParentChanged(document.parentId);
587        getContext().getContentResolver().notifyChange(
588                DocumentsContract.buildDocumentUri(mAuthority, document.documentId),
589                null, false);
590
591        return document.documentId;
592    }
593
594    private File createFile(StubDocument parent, String mimeType, String displayName)
595            throws FileNotFoundException {
596        if (parent == null) {
597            throw new IllegalArgumentException(
598                    "Can't create file " + displayName + " in null parent.");
599        }
600        if (!parent.file.isDirectory()) {
601            throw new IllegalArgumentException(
602                    "Can't create file " + displayName + " inside non-directory parent "
603                            + parent.file.getName());
604        }
605
606        final File file = new File(parent.file, displayName);
607        if (file.exists()) {
608            throw new FileNotFoundException(
609                    "Duplicate file names not supported for " + file);
610        }
611
612        if (mimeType.equals(Document.MIME_TYPE_DIR)) {
613            if (!file.mkdirs()) {
614                throw new FileNotFoundException("Failed to create directory(s): " + file);
615            }
616            Log.i(TAG, "Created new directory: " + file);
617        } else {
618            boolean created = false;
619            try {
620                created = file.createNewFile();
621            } catch (IOException e) {
622                // We'll throw an FNF exception later :)
623                Log.e(TAG, "createNewFile operation failed for file: " + file, e);
624            }
625            if (!created) {
626                throw new FileNotFoundException("createNewFile operation failed for: " + file);
627            }
628            Log.i(TAG, "Created new file: " + file);
629        }
630        return file;
631    }
632
633    private void configure(String arg, Bundle extras) {
634        Log.d(TAG, "Configure " + arg);
635        String rootName = extras.getString(EXTRA_ROOT, ROOT_0_ID);
636        long rootSize = extras.getLong(EXTRA_SIZE, 100) * 1024 * 1024;
637        setSize(rootName, rootSize);
638        mRootNotification = extras.getBoolean(EXTRA_ENABLE_ROOT_NOTIFICATION, true);
639    }
640
641    private void notifyParentChanged(String parentId) {
642        getContext().getContentResolver().notifyChange(
643                DocumentsContract.buildChildDocumentsUri(mAuthority, parentId), null, false);
644        if (mRootNotification) {
645            // Notify also about possible change in remaining space on the root.
646            getContext().getContentResolver().notifyChange(
647                    DocumentsContract.buildRootsUri(mAuthority), null, false);
648        }
649    }
650
651    private void includeDocument(MatrixCursor result, StubDocument document) {
652        final RowBuilder row = result.newRow();
653        row.add(Document.COLUMN_DOCUMENT_ID, document.documentId);
654        row.add(Document.COLUMN_DISPLAY_NAME, document.file.getName());
655        row.add(Document.COLUMN_SIZE, document.file.length());
656        row.add(Document.COLUMN_MIME_TYPE, document.mimeType);
657        row.add(Document.COLUMN_FLAGS, document.flags);
658        row.add(Document.COLUMN_LAST_MODIFIED, document.file.lastModified());
659    }
660
661    private void removeChildrenRecursively(File file) {
662        for (File childFile : file.listFiles()) {
663            if (childFile.isDirectory()) {
664                removeChildrenRecursively(childFile);
665            }
666            childFile.delete();
667        }
668    }
669
670    public void setSize(String rootId, long rootSize) {
671        RootInfo root = mRoots.get(rootId);
672        if (root != null) {
673            final String key = STORAGE_SIZE_KEY + "." + rootId;
674            Log.d(TAG, "Set size of " + key + " : " + rootSize);
675
676            // Persist the size.
677            SharedPreferences.Editor editor = mPrefs.edit();
678            editor.putLong(key, rootSize);
679            editor.apply();
680            // Apply the size in the current instance of this provider.
681            root.capacity = rootSize;
682            getContext().getContentResolver().notifyChange(
683                    DocumentsContract.buildRootsUri(mAuthority),
684                    null, false);
685        } else {
686            Log.e(TAG, "Attempt to configure non-existent root: " + rootId);
687        }
688    }
689
690    @VisibleForTesting
691    public Uri createRegularFile(String rootId, String path, String mimeType, byte[] content)
692            throws FileNotFoundException, IOException {
693        final File file = createFile(rootId, path, mimeType, content);
694        final StubDocument parent = mStorage.get(getDocumentIdForFile(file.getParentFile()));
695        if (parent == null) {
696            throw new FileNotFoundException("Parent not found.");
697        }
698        final StubDocument document = StubDocument.createRegularDocument(file, mimeType, parent);
699        mStorage.put(document.documentId, document);
700        return DocumentsContract.buildDocumentUri(mAuthority, document.documentId);
701    }
702
703    @VisibleForTesting
704    public Uri createVirtualFile(
705            String rootId, String path, String mimeType, List<String> streamTypes, byte[] content)
706            throws FileNotFoundException, IOException {
707
708        final File file = createFile(rootId, path, mimeType, content);
709        final StubDocument parent = mStorage.get(getDocumentIdForFile(file.getParentFile()));
710        if (parent == null) {
711            throw new FileNotFoundException("Parent not found.");
712        }
713        final StubDocument document = StubDocument.createVirtualDocument(
714                file, mimeType, streamTypes, parent);
715        mStorage.put(document.documentId, document);
716        return DocumentsContract.buildDocumentUri(mAuthority, document.documentId);
717    }
718
719    @VisibleForTesting
720    public File getFile(String rootId, String path) throws FileNotFoundException {
721        StubDocument root = mRoots.get(rootId).document;
722        if (root == null) {
723            throw new FileNotFoundException("No roots with the ID " + rootId + " were found");
724        }
725        // Convert the path string into a path that's relative to the root.
726        File needle = new File(root.file, path.substring(1));
727
728        StubDocument found = mStorage.get(getDocumentIdForFile(needle));
729        if (found == null) {
730            return null;
731        }
732        return found.file;
733    }
734
735    private File createFile(String rootId, String path, String mimeType, byte[] content)
736            throws FileNotFoundException, IOException {
737        Log.d(TAG, "Creating test file " + rootId + " : " + path);
738        StubDocument root = mRoots.get(rootId).document;
739        if (root == null) {
740            throw new FileNotFoundException("No roots with the ID " + rootId + " were found");
741        }
742        final File file = new File(root.file, path.substring(1));
743        if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType)) {
744            if (!file.mkdirs()) {
745                throw new FileNotFoundException("Couldn't create directory " + file.getPath());
746            }
747        } else {
748            if (!file.createNewFile()) {
749                throw new FileNotFoundException("Couldn't create file " + file.getPath());
750            }
751            try (final FileOutputStream fout = new FileOutputStream(file)) {
752                fout.write(content);
753            }
754        }
755        return file;
756    }
757
758    final static class RootInfo {
759        private static final int DEFAULT_ROOTS_FLAGS = Root.FLAG_SUPPORTS_SEARCH
760                | Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_IS_CHILD;
761
762        public final String name;
763        public final StubDocument document;
764        public long capacity;
765        public long size;
766        public int flags;
767
768        RootInfo(File file, long capacity) {
769            this.name = file.getName();
770            this.capacity = 1024 * 1024;
771            this.flags = DEFAULT_ROOTS_FLAGS;
772            this.capacity = capacity;
773            this.size = 0;
774            this.document = StubDocument.createRootDocument(file, this);
775        }
776
777        public long getRemainingCapacity() {
778            return capacity - size;
779        }
780
781        public void setSearchEnabled(boolean enabled) {
782            flags = enabled ? (flags | Root.FLAG_SUPPORTS_SEARCH)
783                    : (flags & ~Root.FLAG_SUPPORTS_SEARCH);
784        }
785
786    }
787
788    final static class StubDocument {
789        public final File file;
790        public final String documentId;
791        public final String mimeType;
792        public final List<String> streamTypes;
793        public final int flags;
794        public final String parentId;
795        public final RootInfo rootInfo;
796
797        private StubDocument(File file, String mimeType, List<String> streamTypes, int flags,
798                StubDocument parent) {
799            this.file = file;
800            this.documentId = getDocumentIdForFile(file);
801            this.mimeType = mimeType;
802            this.streamTypes = streamTypes;
803            this.flags = flags;
804            this.parentId = parent.documentId;
805            this.rootInfo = parent.rootInfo;
806        }
807
808        private StubDocument(File file, RootInfo rootInfo) {
809            this.file = file;
810            this.documentId = getDocumentIdForFile(file);
811            this.mimeType = Document.MIME_TYPE_DIR;
812            this.streamTypes = new ArrayList<>();
813            this.flags = Document.FLAG_DIR_SUPPORTS_CREATE | Document.FLAG_SUPPORTS_RENAME;
814            this.parentId = null;
815            this.rootInfo = rootInfo;
816        }
817
818        public static StubDocument createRootDocument(File file, RootInfo rootInfo) {
819            return new StubDocument(file, rootInfo);
820        }
821
822        public static StubDocument createRegularDocument(
823                File file, String mimeType, StubDocument parent) {
824            int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME;
825            if (file.isDirectory()) {
826                flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
827            } else {
828                flags |= Document.FLAG_SUPPORTS_WRITE;
829            }
830            return new StubDocument(file, mimeType, new ArrayList<String>(), flags, parent);
831        }
832
833        public static StubDocument createDocumentWithFlags(
834                File file, String mimeType, StubDocument parent, int flags,
835                List<String> streamTypes) {
836            return new StubDocument(file, mimeType, streamTypes, flags, parent);
837        }
838
839        public static StubDocument createVirtualDocument(
840                File file, String mimeType, List<String> streamTypes, StubDocument parent) {
841            int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE
842                    | Document.FLAG_VIRTUAL_DOCUMENT;
843            return new StubDocument(file, mimeType, streamTypes, flags, parent);
844        }
845
846        @Override
847        public String toString() {
848            return "StubDocument{"
849                    + "path:" + file.getPath()
850                    + ", documentId:" + documentId
851                    + ", mimeType:" + mimeType
852                    + ", streamTypes:" + streamTypes.toString()
853                    + ", flags:" + flags
854                    + ", parentId:" + parentId
855                    + ", rootInfo:" + rootInfo
856                    + "}";
857        }
858    }
859
860    private static String getDocumentIdForFile(File file) {
861        return file.getAbsolutePath();
862    }
863}
864