blob: c2f176221f4b2e11eca16c59ad952e10bfc94008 [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.documentsui;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.ProviderInfo;
import android.content.res.AssetFileDescriptor;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MatrixCursor.RowBuilder;
import android.graphics.Point;
import android.net.Uri;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.FileUtils;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import android.provider.DocumentsProvider;
import android.support.annotation.VisibleForTesting;
import android.util.Log;
import com.google.android.collect.Maps;
import libcore.io.IoUtils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
public class StubProvider extends DocumentsProvider {
private static final String EXTRA_SIZE = "com.android.documentsui.stubprovider.SIZE";
private static final String EXTRA_ROOT = "com.android.documentsui.stubprovider.ROOT";
private static final String STORAGE_SIZE_KEY = "documentsui.stubprovider.size";
private static int DEFAULT_SIZE = 1024 * 1024; // 1 MB.
private static final String TAG = "StubProvider";
private static final String MY_ROOT_ID = "sd0";
private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_AVAILABLE_BYTES
};
private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
};
private HashMap<String, StubDocument> mStorage = new HashMap<String, StubDocument>();
private Object mWriteLock = new Object();
private String mAuthority;
private SharedPreferences mPrefs;
private Map<String, RootInfo> mRoots;
private String mSimulateReadErrors;
@Override
public void attachInfo(Context context, ProviderInfo info) {
mAuthority = info.authority;
super.attachInfo(context, info);
}
@Override
public boolean onCreate() {
clearCacheAndBuildRoots();
return true;
}
@VisibleForTesting
public void clearCacheAndBuildRoots() {
final File cacheDir = getContext().getCacheDir();
removeRecursively(cacheDir);
mStorage.clear();
mPrefs = getContext().getSharedPreferences(
"com.android.documentsui.stubprovider.preferences", Context.MODE_PRIVATE);
Collection<String> rootIds = mPrefs.getStringSet("roots", null);
if (rootIds == null) {
rootIds = Arrays.asList(new String[] {
"sd0", "sd1"
});
}
// Create new roots.
mRoots = Maps.newHashMap();
for (String rootId : rootIds) {
final RootInfo rootInfo = new RootInfo(rootId, getSize(rootId));
mRoots.put(rootId, rootInfo);
}
}
/**
* @return Storage size, in bytes.
*/
private long getSize(String rootId) {
final String key = STORAGE_SIZE_KEY + "." + rootId;
return mPrefs.getLong(key, DEFAULT_SIZE);
}
@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {
final MatrixCursor result = new MatrixCursor(projection != null ? projection
: DEFAULT_ROOT_PROJECTION);
for (Map.Entry<String, RootInfo> entry : mRoots.entrySet()) {
final String id = entry.getKey();
final RootInfo info = entry.getValue();
final RowBuilder row = result.newRow();
row.add(Root.COLUMN_ROOT_ID, id);
row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_IS_CHILD);
row.add(Root.COLUMN_TITLE, id);
row.add(Root.COLUMN_DOCUMENT_ID, info.rootDocument.documentId);
row.add(Root.COLUMN_AVAILABLE_BYTES, info.getRemainingCapacity());
}
return result;
}
@Override
public Cursor queryDocument(String documentId, String[] projection)
throws FileNotFoundException {
final MatrixCursor result = new MatrixCursor(projection != null ? projection
: DEFAULT_DOCUMENT_PROJECTION);
final StubDocument file = mStorage.get(documentId);
if (file == null) {
throw new FileNotFoundException();
}
includeDocument(result, file);
return result;
}
@Override
public boolean isChildDocument(String parentDocId, String docId) {
final StubDocument parentDocument = mStorage.get(parentDocId);
final StubDocument childDocument = mStorage.get(docId);
return FileUtils.contains(parentDocument.file, childDocument.file);
}
@Override
public String createDocument(String parentDocumentId, String mimeType, String displayName)
throws FileNotFoundException {
final StubDocument parentDocument = mStorage.get(parentDocumentId);
if (parentDocument == null || !parentDocument.file.isDirectory()) {
throw new FileNotFoundException();
}
final File file = new File(parentDocument.file, displayName);
if (mimeType.equals(Document.MIME_TYPE_DIR)) {
if (!file.mkdirs()) {
throw new FileNotFoundException();
}
} else {
try {
if (!file.createNewFile()) {
throw new IllegalStateException("The file " + file.getPath() + " already exists");
}
} catch (IOException e) {
throw new FileNotFoundException();
}
}
final StubDocument document = new StubDocument(file, mimeType, parentDocument);
Log.d(TAG, "Created document " + document.documentId);
notifyParentChanged(document.parentId);
getContext().getContentResolver().notifyChange(
DocumentsContract.buildDocumentUri(mAuthority, document.documentId),
null, false);
return document.documentId;
}
@Override
public void deleteDocument(String documentId)
throws FileNotFoundException {
final StubDocument document = mStorage.get(documentId);
final long fileSize = document.file.length();
if (document == null || !document.file.delete())
throw new FileNotFoundException();
synchronized (mWriteLock) {
document.rootInfo.size -= fileSize;
mStorage.remove(documentId);
}
Log.d(TAG, "Document deleted: " + documentId);
notifyParentChanged(document.parentId);
getContext().getContentResolver().notifyChange(
DocumentsContract.buildDocumentUri(mAuthority, document.documentId),
null, false);
}
@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder)
throws FileNotFoundException {
final StubDocument parentDocument = mStorage.get(parentDocumentId);
if (parentDocument == null || parentDocument.file.isFile()) {
throw new FileNotFoundException();
}
final MatrixCursor result = new MatrixCursor(projection != null ? projection
: DEFAULT_DOCUMENT_PROJECTION);
result.setNotificationUri(getContext().getContentResolver(),
DocumentsContract.buildChildDocumentsUri(mAuthority, parentDocumentId));
StubDocument document;
for (File file : parentDocument.file.listFiles()) {
document = mStorage.get(getDocumentIdForFile(file));
if (document != null) {
includeDocument(result, document);
}
}
return result;
}
@Override
public Cursor queryRecentDocuments(String rootId, String[] projection)
throws FileNotFoundException {
final MatrixCursor result = new MatrixCursor(projection != null ? projection
: DEFAULT_DOCUMENT_PROJECTION);
return result;
}
@Override
public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal)
throws FileNotFoundException {
final StubDocument document = mStorage.get(docId);
if (document == null || !document.file.isFile())
throw new FileNotFoundException();
if ("r".equals(mode)) {
ParcelFileDescriptor pfd = ParcelFileDescriptor.open(document.file,
ParcelFileDescriptor.MODE_READ_ONLY);
if (docId.equals(mSimulateReadErrors)) {
pfd = new ParcelFileDescriptor(pfd) {
@Override
public void checkError() throws IOException {
throw new IOException("Test error");
}
};
}
return pfd;
}
if ("w".equals(mode)) {
return startWrite(document);
}
throw new FileNotFoundException();
}
@VisibleForTesting
public void simulateReadErrorsForFile(Uri uri) {
mSimulateReadErrors = DocumentsContract.getDocumentId(uri);
}
@Override
public AssetFileDescriptor openDocumentThumbnail(
String docId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException {
throw new FileNotFoundException();
}
private ParcelFileDescriptor startWrite(final StubDocument document)
throws FileNotFoundException {
ParcelFileDescriptor[] pipe;
try {
pipe = ParcelFileDescriptor.createReliablePipe();
} catch (IOException exception) {
throw new FileNotFoundException();
}
final ParcelFileDescriptor readPipe = pipe[0];
final ParcelFileDescriptor writePipe = pipe[1];
new Thread() {
@Override
public void run() {
InputStream inputStream = null;
OutputStream outputStream = null;
try {
Log.d(TAG, "Opening write stream on file " + document.documentId);
inputStream = new ParcelFileDescriptor.AutoCloseInputStream(readPipe);
outputStream = new FileOutputStream(document.file);
byte[] buffer = new byte[32 * 1024];
int bytesToRead;
int bytesRead = 0;
while (bytesRead != -1) {
synchronized (mWriteLock) {
// This cast is safe because the max possible value is buffer.length.
bytesToRead = (int) Math.min(document.rootInfo.getRemainingCapacity(),
buffer.length);
if (bytesToRead == 0) {
closePipeWithErrorSilently(readPipe, "Not enough space.");
break;
}
bytesRead = inputStream.read(buffer, 0, bytesToRead);
if (bytesRead == -1) {
break;
}
outputStream.write(buffer, 0, bytesRead);
document.rootInfo.size += bytesRead;
}
}
} catch (IOException e) {
Log.e(TAG, "Error on close", e);
closePipeWithErrorSilently(readPipe, e.getMessage());
} finally {
IoUtils.closeQuietly(inputStream);
IoUtils.closeQuietly(outputStream);
Log.d(TAG, "Closing write stream on file " + document.documentId);
notifyParentChanged(document.parentId);
getContext().getContentResolver().notifyChange(
DocumentsContract.buildDocumentUri(mAuthority, document.documentId),
null, false);
}
}
}.start();
return writePipe;
}
private void closePipeWithErrorSilently(ParcelFileDescriptor pipe, String error) {
try {
pipe.closeWithError(error);
} catch (IOException ignore) {
}
}
@Override
public Bundle call(String method, String arg, Bundle extras) {
switch (method) {
case "clear":
clearCacheAndBuildRoots();
return null;
case "configure":
configure(arg, extras);
return null;
default:
return super.call(method, arg, extras);
}
}
private void configure(String arg, Bundle extras) {
Log.d(TAG, "Configure " + arg);
String rootName = extras.getString(EXTRA_ROOT, MY_ROOT_ID);
long rootSize = extras.getLong(EXTRA_SIZE, 1) * 1024 * 1024;
setSize(rootName, rootSize);
}
private void notifyParentChanged(String parentId) {
getContext().getContentResolver().notifyChange(
DocumentsContract.buildChildDocumentsUri(mAuthority, parentId), null, false);
// Notify also about possible change in remaining space on the root.
getContext().getContentResolver().notifyChange(DocumentsContract.buildRootsUri(mAuthority),
null, false);
}
private void includeDocument(MatrixCursor result, StubDocument document) {
final RowBuilder row = result.newRow();
row.add(Document.COLUMN_DOCUMENT_ID, document.documentId);
row.add(Document.COLUMN_DISPLAY_NAME, document.file.getName());
row.add(Document.COLUMN_SIZE, document.file.length());
row.add(Document.COLUMN_MIME_TYPE, document.mimeType);
int flags = Document.FLAG_SUPPORTS_DELETE;
// TODO: Add support for renaming.
if (document.file.isDirectory()) {
flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
} else {
flags |= Document.FLAG_SUPPORTS_WRITE;
}
row.add(Document.COLUMN_FLAGS, flags);
row.add(Document.COLUMN_LAST_MODIFIED, document.file.lastModified());
}
private void removeRecursively(File file) {
for (File childFile : file.listFiles()) {
if (childFile.isDirectory()) {
removeRecursively(childFile);
}
childFile.delete();
}
}
public void setSize(String rootId, long rootSize) {
RootInfo root = mRoots.get(rootId);
if (root != null) {
final String key = STORAGE_SIZE_KEY + "." + rootId;
Log.d(TAG, "Set size of " + key + " : " + rootSize);
// Persist the size.
SharedPreferences.Editor editor = mPrefs.edit();
editor.putLong(key, rootSize);
editor.apply();
// Apply the size in the current instance of this provider.
root.capacity = rootSize;
getContext().getContentResolver().notifyChange(
DocumentsContract.buildRootsUri(mAuthority),
null, false);
} else {
Log.e(TAG, "Attempt to configure non-existent root: " + rootId);
}
}
@VisibleForTesting
public Uri createFile(String rootId, String path, String mimeType, byte[] content)
throws FileNotFoundException, IOException {
Log.d(TAG, "Creating file " + rootId + ":" + path);
StubDocument root = mRoots.get(rootId).rootDocument;
if (root == null) {
throw new FileNotFoundException("No roots with the ID " + rootId + " were found");
}
File file = new File(root.file, path.substring(1));
StubDocument parent = mStorage.get(getDocumentIdForFile(file.getParentFile()));
if (parent == null) {
parent = mStorage.get(createFile(rootId, file.getParentFile().getPath(),
DocumentsContract.Document.MIME_TYPE_DIR, null));
Log.d(TAG, "Created parent " + parent.documentId);
} else {
Log.d(TAG, "Found parent " + parent.documentId);
}
if (DocumentsContract.Document.MIME_TYPE_DIR.equals(mimeType)) {
if (!file.mkdirs()) {
throw new FileNotFoundException("Couldn't create directory " + file.getPath());
}
} else {
if (!file.createNewFile()) {
throw new FileNotFoundException("Couldn't create file " + file.getPath());
}
// Add content to the file.
FileOutputStream fout = new FileOutputStream(file);
fout.write(content);
fout.close();
}
final StubDocument document = new StubDocument(file, mimeType, parent);
return DocumentsContract.buildDocumentUri(mAuthority, document.documentId);
}
@VisibleForTesting
public File getFile(String rootId, String path) throws FileNotFoundException {
StubDocument root = mRoots.get(rootId).rootDocument;
if (root == null) {
throw new FileNotFoundException("No roots with the ID " + rootId + " were found");
}
// Convert the path string into a path that's relative to the root.
File needle = new File(root.file, path.substring(1));
StubDocument found = mStorage.get(getDocumentIdForFile(needle));
if (found == null) {
return null;
}
return found.file;
}
final class RootInfo {
public final String name;
public final StubDocument rootDocument;
public long capacity;
public long size;
RootInfo(String name, long capacity) {
this.name = name;
this.capacity = 1024 * 1024;
// Make a subdir in the cache dir for each root.
File rootDir = new File(getContext().getCacheDir(), name);
rootDir.mkdir();
this.rootDocument = new StubDocument(rootDir, Document.MIME_TYPE_DIR, this);
this.capacity = capacity;
this.size = 0;
}
public long getRemainingCapacity() {
return capacity - size;
}
}
final class StubDocument {
public final File file;
public final String mimeType;
public final String documentId;
public final String parentId;
public final RootInfo rootInfo;
StubDocument(File file, String mimeType, StubDocument parent) {
this.file = file;
this.mimeType = mimeType;
this.documentId = getDocumentIdForFile(file);
this.parentId = parent.documentId;
this.rootInfo = parent.rootInfo;
mStorage.put(this.documentId, this);
}
StubDocument(File file, String mimeType, RootInfo rootInfo) {
this.file = file;
this.mimeType = mimeType;
this.documentId = getDocumentIdForFile(file);
this.parentId = null;
this.rootInfo = rootInfo;
mStorage.put(this.documentId, this);
}
}
private static String getDocumentIdForFile(File file) {
return file.getAbsolutePath();
}
}