Define storage roots, external GUIDs, creation.
Allow storage backends to publish multiple roots into the UI, which
are defined by a directory GUID, type, and label details. Update
external provider to surface a primary external storage root, and
switch to burning file path into the returned GUIDs so they remain
durable.
Added insert, update, and delete support to external provider. Adds
file extensions to display names when needed to match MIME type.
Add flags for searching and deletion, and extras for Cursor
pagination. Add directory creation dialog to UI. Opening a document
always gives write access.
Change-Id: I9bea1aa0dcde909a5ab86aefeece7451ab920cf1
diff --git a/api/current.txt b/api/current.txt
index 6bb40e1..09c2385 100644
--- a/api/current.txt
+++ b/api/current.txt
@@ -20245,15 +20245,25 @@
ctor public DocumentsContract();
method public static android.net.Uri buildContentsUri(android.net.Uri);
method public static android.net.Uri buildDocumentUri(java.lang.String, java.lang.String);
- method public static android.net.Uri buildSearchUri(java.lang.String, java.lang.String);
+ method public static android.net.Uri buildRootsUri(java.lang.String);
+ method public static android.net.Uri buildSearchUri(android.net.Uri, java.lang.String);
method public static android.graphics.Bitmap getThumbnail(android.content.ContentResolver, android.net.Uri, android.graphics.Point);
method public static boolean renameDocument(android.content.ContentResolver, android.net.Uri, java.lang.String);
+ field public static final java.lang.String EXTRA_HAS_MORE = "has_more";
+ field public static final java.lang.String EXTRA_REQUEST_MORE = "request_more";
field public static final java.lang.String EXTRA_THUMBNAIL_SIZE = "thumbnail_size";
field public static final int FLAG_SUPPORTS_CREATE = 1; // 0x1
+ field public static final int FLAG_SUPPORTS_DELETE = 4; // 0x4
field public static final int FLAG_SUPPORTS_RENAME = 2; // 0x2
- field public static final int FLAG_SUPPORTS_THUMBNAIL = 4; // 0x4
+ field public static final int FLAG_SUPPORTS_SEARCH = 16; // 0x10
+ field public static final int FLAG_SUPPORTS_THUMBNAIL = 8; // 0x8
field public static final java.lang.String MIME_TYPE_DIRECTORY = "vnd.android.cursor.dir/doc";
+ field public static final java.lang.String PARAM_QUERY = "query";
field public static final java.lang.String ROOT_GUID = "0";
+ field public static final int ROOT_TYPE_DEVICE = 3; // 0x3
+ field public static final int ROOT_TYPE_DEVICE_ADVANCED = 4; // 0x4
+ field public static final int ROOT_TYPE_SERVICE = 1; // 0x1
+ field public static final int ROOT_TYPE_SHORTCUT = 2; // 0x2
}
public static abstract interface DocumentsContract.DocumentColumns implements android.provider.OpenableColumns {
@@ -20263,6 +20273,15 @@
field public static final java.lang.String MIME_TYPE = "mime_type";
}
+ public static abstract interface DocumentsContract.RootColumns {
+ field public static final java.lang.String AVAILABLE_BYTES = "available_bytes";
+ field public static final java.lang.String GUID = "guid";
+ field public static final java.lang.String ICON = "icon";
+ field public static final java.lang.String ROOT_TYPE = "root_type";
+ field public static final java.lang.String SUMMARY = "summary";
+ field public static final java.lang.String TITLE = "title";
+ }
+
public final deprecated class LiveFolders implements android.provider.BaseColumns {
field public static final java.lang.String ACTION_CREATE_LIVE_FOLDER = "android.intent.action.CREATE_LIVE_FOLDER";
field public static final java.lang.String DESCRIPTION = "description";
diff --git a/core/java/android/provider/DocumentsContract.java b/core/java/android/provider/DocumentsContract.java
index c26f6d4..979a5a3 100644
--- a/core/java/android/provider/DocumentsContract.java
+++ b/core/java/android/provider/DocumentsContract.java
@@ -16,10 +16,13 @@
package android.provider;
+import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Intent;
+import android.content.pm.ProviderInfo;
import android.content.res.AssetFileDescriptor;
+import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Point;
@@ -39,9 +42,10 @@
public final class DocumentsContract {
private static final String TAG = "Documents";
+ // content://com.example/roots/
// content://com.example/docs/0/
// content://com.example/docs/0/contents/
- // content://com.example/search/?query=pony
+ // content://com.example/docs/0/search/?query=pony
/**
* MIME type of a document which is a directory that may contain additional
@@ -78,25 +82,69 @@
public static final int FLAG_SUPPORTS_RENAME = 1 << 1;
/**
+ * Flag indicating that a document is deletable.
+ *
+ * @see DocumentColumns#FLAGS
+ */
+ public static final int FLAG_SUPPORTS_DELETE = 1 << 2;
+
+ /**
* Flag indicating that a document can be represented as a thumbnail.
*
* @see DocumentColumns#FLAGS
* @see #getThumbnail(ContentResolver, Uri, Point)
*/
- public static final int FLAG_SUPPORTS_THUMBNAIL = 1 << 2;
+ public static final int FLAG_SUPPORTS_THUMBNAIL = 1 << 3;
+
+ /**
+ * Flag indicating that a document is a directory that supports search.
+ *
+ * @see DocumentColumns#FLAGS
+ */
+ public static final int FLAG_SUPPORTS_SEARCH = 1 << 4;
/**
* Optimal dimensions for a document thumbnail request, stored as a
* {@link Point} object. This is only a hint, and the returned thumbnail may
* have different dimensions.
+ *
+ * @see ContentProvider#openTypedAssetFile(Uri, String, Bundle)
*/
public static final String EXTRA_THUMBNAIL_SIZE = "thumbnail_size";
+ /**
+ * Extra boolean flag included in a directory {@link Cursor#getExtras()}
+ * indicating that the backend can provide additional data if requested,
+ * such as additional search results.
+ */
+ public static final String EXTRA_HAS_MORE = "has_more";
+
+ /**
+ * Extra boolean flag included in a {@link Cursor#respond(Bundle)} call to a
+ * directory to request that additional data should be fetched. When
+ * requested data is ready, the provider should send a change notification
+ * to cause a requery.
+ *
+ * @see Cursor#respond(Bundle)
+ * @see ContentResolver#notifyChange(Uri, android.database.ContentObserver,
+ * boolean)
+ */
+ public static final String EXTRA_REQUEST_MORE = "request_more";
+
+ private static final String PATH_ROOTS = "roots";
private static final String PATH_DOCS = "docs";
private static final String PATH_CONTENTS = "contents";
private static final String PATH_SEARCH = "search";
- private static final String PARAM_QUERY = "query";
+ public static final String PARAM_QUERY = "query";
+
+ /**
+ * Build URI representing the custom roots in a storage backend.
+ */
+ public static Uri buildRootsUri(String authority) {
+ return new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
+ .authority(authority).appendPath(PATH_ROOTS).build();
+ }
/**
* Build URI representing the given {@link DocumentColumns#GUID} in a
@@ -108,11 +156,14 @@
}
/**
- * Build URI representing a search for matching documents in a storage
- * backend.
+ * Build URI representing a search for matching documents under a directory
+ * in a storage backend.
+ *
+ * @param documentUri directory to search under, which must have
+ * {@link #FLAG_SUPPORTS_SEARCH}.
*/
- public static Uri buildSearchUri(String authority, String query) {
- return new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT).authority(authority)
+ public static Uri buildSearchUri(Uri documentUri, String query) {
+ return documentUri.buildUpon()
.appendPath(PATH_SEARCH).appendQueryParameter(PARAM_QUERY, query).build();
}
@@ -134,7 +185,8 @@
public interface DocumentColumns extends OpenableColumns {
/**
* The globally unique ID for a document within a storage backend.
- * Values <em>must</em> never change once returned.
+ * Values <em>must</em> never change once returned. This field is
+ * read-only to document clients.
* <p>
* Type: STRING
*
@@ -144,7 +196,9 @@
/**
* MIME type of a document, matching the value returned by
- * {@link ContentResolver#getType(android.net.Uri)}.
+ * {@link ContentResolver#getType(android.net.Uri)}. This field must be
+ * provided when a new document is created, but after that the field is
+ * read-only.
* <p>
* Type: STRING
*
@@ -154,7 +208,8 @@
/**
* Timestamp when a document was last modified, in milliseconds since
- * January 1, 1970 00:00:00.0 UTC.
+ * January 1, 1970 00:00:00.0 UTC. This field is read-only to document
+ * clients.
* <p>
* Type: INTEGER (long)
*
@@ -163,13 +218,74 @@
public static final String LAST_MODIFIED = "last_modified";
/**
- * Flags that apply to a specific document.
+ * Flags that apply to a specific document. This field is read-only to
+ * document clients.
* <p>
* Type: INTEGER (int)
*/
public static final String FLAGS = "flags";
}
+ public static final int ROOT_TYPE_SERVICE = 1;
+ public static final int ROOT_TYPE_SHORTCUT = 2;
+ public static final int ROOT_TYPE_DEVICE = 3;
+ public static final int ROOT_TYPE_DEVICE_ADVANCED = 4;
+
+ /**
+ * These are standard columns for the roots URI.
+ *
+ * @see DocumentsContract#buildRootsUri(String)
+ */
+ public interface RootColumns {
+ /**
+ * Storage root type, use for clustering.
+ * <p>
+ * Type: INTEGER (int)
+ *
+ * @see DocumentsContract#ROOT_TYPE_SERVICE
+ * @see DocumentsContract#ROOT_TYPE_DEVICE
+ */
+ public static final String ROOT_TYPE = "root_type";
+
+ /**
+ * GUID of directory entry for this storage root.
+ * <p>
+ * Type: STRING
+ */
+ public static final String GUID = "guid";
+
+ /**
+ * Icon resource ID for this storage root, or {@code 0} to use the
+ * default {@link ProviderInfo#icon}.
+ * <p>
+ * Type: INTEGER (int)
+ */
+ public static final String ICON = "icon";
+
+ /**
+ * Title for this storage root, or {@code null} to use the default
+ * {@link ProviderInfo#labelRes}.
+ * <p>
+ * Type: STRING
+ */
+ public static final String TITLE = "title";
+
+ /**
+ * Summary for this storage root, or {@code null} to omit.
+ * <p>
+ * Type: STRING
+ */
+ public static final String SUMMARY = "summary";
+
+ /**
+ * Number of free bytes of available in this storage root, or -1 if
+ * unknown or unbounded.
+ * <p>
+ * Type: INTEGER (long)
+ */
+ public static final String AVAILABLE_BYTES = "available_bytes";
+ }
+
/**
* Return thumbnail representing the document at the given URI. Callers are
* responsible for their own caching. Given document must have
diff --git a/packages/DocumentsUI/res/layout/dialog_create_dir.xml b/packages/DocumentsUI/res/layout/dialog_create_dir.xml
new file mode 100644
index 0000000..54e26b4
--- /dev/null
+++ b/packages/DocumentsUI/res/layout/dialog_create_dir.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2013 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.
+-->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="?android:attr/listPreferredItemPaddingEnd">
+
+ <EditText
+ android:id="@android:id/text1"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+</FrameLayout>
diff --git a/packages/DocumentsUI/src/com/android/documentsui/BackendFragment.java b/packages/DocumentsUI/src/com/android/documentsui/BackendFragment.java
index 2980e23..fc13487 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/BackendFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/BackendFragment.java
@@ -21,9 +21,15 @@
import android.app.FragmentTransaction;
import android.content.Context;
import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.ProviderInfo;
+import android.content.res.Resources.NotFoundException;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
import android.os.Bundle;
import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.RootColumns;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -39,11 +45,11 @@
import java.util.List;
/**
- * Display all known storage backends.
+ * Display all known storage roots.
*/
public class BackendFragment extends Fragment {
- // TODO: handle multiple accounts from single backend
+ // TODO: cluster backends by type
private GridView mGridView;
private BackendAdapter mAdapter;
@@ -57,19 +63,69 @@
ft.commitAllowingStateLoss();
}
+ public static class Root {
+ public int rootType;
+ public Uri uri;
+ public Drawable icon;
+ public String title;
+ public String summary;
+
+ public static Root fromCursor(Context context, ProviderInfo info, Cursor cursor) {
+ final Root root = new Root();
+
+ root.rootType = cursor.getInt(cursor.getColumnIndex(RootColumns.ROOT_TYPE));
+ root.uri = DocumentsContract.buildDocumentUri(
+ info.authority, cursor.getString(cursor.getColumnIndex(RootColumns.GUID)));
+
+ final PackageManager pm = context.getPackageManager();
+ final int icon = cursor.getInt(cursor.getColumnIndex(RootColumns.ICON));
+ if (icon != 0) {
+ try {
+ root.icon = pm.getResourcesForApplication(info.applicationInfo)
+ .getDrawable(icon);
+ } catch (NotFoundException e) {
+ throw new RuntimeException(e);
+ } catch (NameNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ } else {
+ root.icon = info.loadIcon(pm);
+ }
+
+ root.title = cursor.getString(cursor.getColumnIndex(RootColumns.TITLE));
+ if (root.title == null) {
+ root.title = info.loadLabel(pm).toString();
+ }
+
+ root.summary = cursor.getString(cursor.getColumnIndex(RootColumns.SUMMARY));
+
+ return root;
+ }
+ }
+
@Override
public View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
final Context context = inflater.getContext();
- // Gather known storage providers
+ // Gather roots from known storage providers
final List<ProviderInfo> providers = context.getPackageManager()
.queryContentProviders(null, -1, PackageManager.GET_META_DATA);
- final List<ProviderInfo> backends = Lists.newArrayList();
+ final List<Root> roots = Lists.newArrayList();
for (ProviderInfo info : providers) {
if (info.metaData != null
&& info.metaData.containsKey(DocumentsContract.META_DATA_DOCUMENT_PROVIDER)) {
- backends.add(info);
+ // TODO: populate roots on background thread, and cache results
+ final Uri uri = DocumentsContract.buildRootsUri(info.authority);
+ final Cursor cursor = context.getContentResolver()
+ .query(uri, null, null, null, null);
+ try {
+ while (cursor.moveToNext()) {
+ roots.add(Root.fromCursor(context, info, cursor));
+ }
+ } finally {
+ cursor.close();
+ }
}
}
@@ -78,7 +134,7 @@
mGridView = (GridView) view.findViewById(R.id.grid);
mGridView.setOnItemClickListener(mItemListener);
- mAdapter = new BackendAdapter(context, backends);
+ mAdapter = new BackendAdapter(context, roots);
mGridView.setAdapter(mAdapter);
mGridView.setNumColumns(GridView.AUTO_FIT);
@@ -88,13 +144,13 @@
private OnItemClickListener mItemListener = new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
- final ProviderInfo info = mAdapter.getItem(position);
- ((DocumentsActivity) getActivity()).onBackendPicked(info);
+ final Root root = mAdapter.getItem(position);
+ ((DocumentsActivity) getActivity()).onRootPicked(root);
}
};
- public static class BackendAdapter extends ArrayAdapter<ProviderInfo> {
- public BackendAdapter(Context context, List<ProviderInfo> list) {
+ public static class BackendAdapter extends ArrayAdapter<Root> {
+ public BackendAdapter(Context context, List<Root> list) {
super(context, android.R.layout.simple_list_item_1, list);
}
@@ -109,9 +165,9 @@
final TextView text1 = (TextView) convertView.findViewById(android.R.id.text1);
final PackageManager pm = parent.getContext().getPackageManager();
- final ProviderInfo info = getItem(position);
- icon.setImageDrawable(info.loadIcon(pm));
- text1.setText(info.loadLabel(pm));
+ final Root root = getItem(position);
+ icon.setImageDrawable(root.icon);
+ text1.setText(root.title);
return convertView;
}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/DocumentsActivity.java b/packages/DocumentsUI/src/com/android/documentsui/DocumentsActivity.java
index c45d2b4..e61cea6 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/DocumentsActivity.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/DocumentsActivity.java
@@ -21,15 +21,20 @@
import android.app.ActionBar;
import android.app.ActionBar.OnNavigationListener;
import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
import android.app.FragmentManager;
import android.app.FragmentManager.BackStackEntry;
import android.app.FragmentManager.OnBackStackChangedListener;
import android.content.ClipData;
import android.content.ContentResolver;
+import android.content.ContentValues;
import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.content.pm.PackageManager;
-import android.content.pm.ProviderInfo;
import android.content.pm.ResolveInfo;
import android.database.Cursor;
import android.graphics.drawable.Drawable;
@@ -44,8 +49,11 @@
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
+import android.widget.EditText;
import android.widget.TextView;
+import com.android.documentsui.BackendFragment.Root;
+
import java.util.Arrays;
import java.util.List;
@@ -160,7 +168,7 @@
getFragmentManager().popBackStack();
updateActionBar();
} else if (id == R.id.menu_create_dir) {
- // TODO: show dialog to create directory
+ CreateDirectoryFragment.show(getFragmentManager());
}
return super.onOptionsItemSelected(item);
}
@@ -232,11 +240,8 @@
invalidateOptionsMenu();
}
- public void onBackendPicked(ProviderInfo info) {
- final Uri uri = DocumentsContract.buildDocumentUri(
- info.authority, DocumentsContract.ROOT_GUID);
- final CharSequence displayName = info.loadLabel(getPackageManager());
- DirectoryFragment.show(getFragmentManager(), uri, displayName.toString());
+ public void onRootPicked(Root root) {
+ DirectoryFragment.show(getFragmentManager(), root.uri, root.title);
}
public void onDocumentPicked(Document doc) {
@@ -263,8 +268,12 @@
}
public void onSaveRequested(String mimeType, String displayName) {
- // TODO: create file, confirming before overwriting
- final Uri uri = null;
+ final ContentValues values = new ContentValues();
+ values.put(DocumentColumns.MIME_TYPE, mimeType);
+ values.put(DocumentColumns.DISPLAY_NAME, displayName);
+
+ // TODO: handle errors from remote side
+ final Uri uri = getContentResolver().insert(mCurrentDir, values);
onFinished(uri);
}
@@ -283,11 +292,10 @@
intent.setClipData(clipData);
}
- intent.addFlags(
- Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_PERSIST_GRANT_URI_PERMISSION);
- if (mAction == ACTION_CREATE) {
- intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
- }
+ // TODO: omit WRITE and PERSIST for GET_CONTENT
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ | Intent.FLAG_PERSIST_GRANT_URI_PERMISSION);
setResult(Activity.RESULT_OK, intent);
finish();
@@ -318,6 +326,24 @@
doc.displayName = getCursorString(cursor, DocumentColumns.DISPLAY_NAME);
return doc;
}
+
+ public static Document fromUri(ContentResolver resolver, Uri uri) {
+ final Document doc = new Document();
+ doc.uri = uri;
+
+ final Cursor cursor = resolver.query(uri, null, null, null, null);
+ try {
+ if (!cursor.moveToFirst()) {
+ throw new IllegalArgumentException("Missing details for " + uri);
+ }
+ doc.mimeType = getCursorString(cursor, DocumentColumns.MIME_TYPE);
+ doc.displayName = getCursorString(cursor, DocumentColumns.DISPLAY_NAME);
+ } finally {
+ cursor.close();
+ }
+
+ return doc;
+ }
}
public static boolean mimeMatches(String filter, String[] tests) {
@@ -359,4 +385,50 @@
}
}
}
+
+ private static final String TAG_CREATE_DIRECTORY = "create_directory";
+
+ public static class CreateDirectoryFragment extends DialogFragment {
+ public static void show(FragmentManager fm) {
+ final CreateDirectoryFragment dialog = new CreateDirectoryFragment();
+ dialog.show(fm, TAG_CREATE_DIRECTORY);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final Context context = getActivity();
+ final ContentResolver resolver = context.getContentResolver();
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ final LayoutInflater dialogInflater = LayoutInflater.from(builder.getContext());
+
+ final View view = dialogInflater.inflate(R.layout.dialog_create_dir, null, false);
+ final EditText text1 = (EditText)view.findViewById(android.R.id.text1);
+
+ builder.setTitle(R.string.menu_create_dir);
+ builder.setView(view);
+
+ builder.setPositiveButton(android.R.string.ok, new OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ final String displayName = text1.getText().toString();
+
+ final ContentValues values = new ContentValues();
+ values.put(DocumentColumns.MIME_TYPE, DocumentsContract.MIME_TYPE_DIRECTORY);
+ values.put(DocumentColumns.DISPLAY_NAME, displayName);
+
+ // TODO: handle errors from remote side
+ final DocumentsActivity activity = (DocumentsActivity) getActivity();
+ final Uri uri = resolver.insert(activity.mCurrentDir, values);
+
+ // Navigate into newly created child
+ final Document doc = Document.fromUri(resolver, uri);
+ activity.onDocumentPicked(doc);
+ }
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+
+ return builder.create();
+ }
+ }
}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/SaveFragment.java b/packages/DocumentsUI/src/com/android/documentsui/SaveFragment.java
index 19a1d2a..a2a4f7c 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/SaveFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/SaveFragment.java
@@ -82,7 +82,7 @@
@Override
public void onClick(View v) {
final String mimeType = getArguments().getString(EXTRA_MIME_TYPE);
- final String displayName = getArguments().getString(EXTRA_DISPLAY_NAME);
+ final String displayName = mDisplayName.getText().toString();
((DocumentsActivity) getActivity()).onSaveRequested(mimeType, displayName);
}
};
diff --git a/packages/ExternalStorageProvider/res/values/strings.xml b/packages/ExternalStorageProvider/res/values/strings.xml
index 4374cfc..0eaf500a 100644
--- a/packages/ExternalStorageProvider/res/values/strings.xml
+++ b/packages/ExternalStorageProvider/res/values/strings.xml
@@ -16,4 +16,5 @@
<resources>
<string name="app_label">External Storage</string>
+ <string name="root_internal_storage">Internal storage</string>
</resources>
diff --git a/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java b/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java
index f75e3bd..bf5811b 100644
--- a/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java
+++ b/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java
@@ -28,14 +28,17 @@
import android.provider.BaseColumns;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.DocumentColumns;
+import android.provider.DocumentsContract.RootColumns;
+import android.util.Log;
import android.webkit.MimeTypeMap;
-import com.android.internal.annotations.GuardedBy;
-import com.google.android.collect.Lists;
+import com.google.android.collect.Maps;
import java.io.File;
import java.io.FileNotFoundException;
-import java.util.ArrayList;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.LinkedList;
public class ExternalStorageProvider extends ContentProvider {
private static final String TAG = "ExternalStorage";
@@ -44,33 +47,65 @@
// TODO: support searching
// TODO: support multiple storage devices
- // TODO: persist GUIDs across launches
private static final UriMatcher sMatcher = new UriMatcher(UriMatcher.NO_MATCH);
- private static final int URI_DOCS_ID = 1;
- private static final int URI_DOCS_ID_CONTENTS = 2;
- private static final int URI_SEARCH = 3;
+ private static final int URI_ROOTS = 1;
+ private static final int URI_DOCS_ID = 2;
+ private static final int URI_DOCS_ID_CONTENTS = 3;
+ private static final int URI_DOCS_ID_SEARCH = 4;
- static {
- sMatcher.addURI(AUTHORITY, "docs/#", URI_DOCS_ID);
- sMatcher.addURI(AUTHORITY, "docs/#/contents", URI_DOCS_ID_CONTENTS);
- sMatcher.addURI(AUTHORITY, "search", URI_SEARCH);
+ private HashMap<String, Root> mRoots = Maps.newHashMap();
+
+ private static class Root {
+ public int rootType;
+ public String name;
+ public int icon = 0;
+ public String title = null;
+ public String summary = null;
+ public File path;
}
- @GuardedBy("mFiles")
- private ArrayList<File> mFiles = Lists.newArrayList();
+ static {
+ sMatcher.addURI(AUTHORITY, "roots", URI_ROOTS);
+ sMatcher.addURI(AUTHORITY, "docs/*", URI_DOCS_ID);
+ sMatcher.addURI(AUTHORITY, "docs/*/contents", URI_DOCS_ID_CONTENTS);
+ sMatcher.addURI(AUTHORITY, "docs/*/search", URI_DOCS_ID_SEARCH);
+ }
@Override
public boolean onCreate() {
- mFiles.clear();
- mFiles.add(Environment.getExternalStorageDirectory());
+ mRoots.clear();
+
+ final Root root = new Root();
+ root.rootType = DocumentsContract.ROOT_TYPE_DEVICE_ADVANCED;
+ root.name = "internal";
+ root.title = getContext().getString(R.string.root_internal_storage);
+ root.path = Environment.getExternalStorageDirectory();
+ mRoots.put(root.name, root);
+
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
+ final int match = sMatcher.match(uri);
+ if (match == URI_ROOTS) {
+ // TODO: support custom projections
+ projection = new String[] {
+ RootColumns.ROOT_TYPE, RootColumns.GUID, RootColumns.ICON, RootColumns.TITLE,
+ RootColumns.SUMMARY, RootColumns.AVAILABLE_BYTES };
+
+ final MatrixCursor cursor = new MatrixCursor(projection);
+ for (Root root : mRoots.values()) {
+ final String guid = fileToGuid(root.path);
+ cursor.addRow(new Object[] {
+ root.rootType, guid, root.icon, root.title, root.summary,
+ root.path.getFreeSpace() });
+ }
+ return cursor;
+ }
// TODO: support custom projections
projection = new String[] {
@@ -79,21 +114,37 @@
DocumentColumns.MIME_TYPE, DocumentColumns.LAST_MODIFIED, DocumentColumns.FLAGS };
final MatrixCursor cursor = new MatrixCursor(projection);
- switch (sMatcher.match(uri)) {
+ switch (match) {
case URI_DOCS_ID: {
- final int id = Integer.parseInt(uri.getPathSegments().get(1));
- synchronized (mFiles) {
- includeFileLocked(cursor, id);
- }
+ final String guid = uri.getPathSegments().get(1);
+ includeFile(cursor, guid);
break;
}
case URI_DOCS_ID_CONTENTS: {
- final int parentId = Integer.parseInt(uri.getPathSegments().get(1));
- synchronized (mFiles) {
- final File parent = mFiles.get(parentId);
- for (File file : parent.listFiles()) {
- final int id = findOrCreateFileLocked(file);
- includeFileLocked(cursor, id);
+ final String guid = uri.getPathSegments().get(1);
+ final File parent = guidToFile(guid);
+ for (File file : parent.listFiles()) {
+ includeFile(cursor, fileToGuid(file));
+ }
+ break;
+ }
+ case URI_DOCS_ID_SEARCH: {
+ final String guid = uri.getPathSegments().get(1);
+ final File parent = guidToFile(guid);
+ final String query = uri.getQueryParameter(DocumentsContract.PARAM_QUERY).toLowerCase();
+
+ final LinkedList<File> pending = new LinkedList<File>();
+ pending.add(parent);
+ while (!pending.isEmpty() && cursor.getCount() < 20) {
+ final File file = pending.removeFirst();
+ if (file.isDirectory()) {
+ for (File child : file.listFiles()) {
+ pending.add(child);
+ }
+ } else {
+ if (file.getName().toLowerCase().contains(query)) {
+ includeFile(cursor, fileToGuid(file));
+ }
}
}
break;
@@ -107,43 +158,61 @@
return cursor;
}
- private int findOrCreateFileLocked(File file) {
- int id = mFiles.indexOf(file);
- if (id == -1) {
- id = mFiles.size();
- mFiles.add(file);
+ private String fileToGuid(File file) {
+ final String path = file.getAbsolutePath();
+ for (Root root : mRoots.values()) {
+ final String rootPath = root.path.getAbsolutePath();
+ if (path.startsWith(rootPath)) {
+ return root.name + ':' + Uri.encode(path.substring(rootPath.length()));
+ }
}
- return id;
+
+ throw new IllegalArgumentException("Failed to find root for " + file);
}
- private void includeFileLocked(MatrixCursor cursor, int id) {
- final File file = mFiles.get(id);
+ private File guidToFile(String guid) {
+ final int split = guid.indexOf(':');
+ final String name = guid.substring(0, split);
+ final Root root = mRoots.get(name);
+ if (root != null) {
+ final String path = Uri.decode(guid.substring(split + 1));
+ return new File(root.path, path);
+ }
+
+ throw new IllegalArgumentException("Failed to find root for " + guid);
+ }
+
+ private void includeFile(MatrixCursor cursor, String guid) {
int flags = 0;
+ final File file = guidToFile(guid);
+ if (file.isDirectory()) {
+ flags |= DocumentsContract.FLAG_SUPPORTS_SEARCH;
+ }
if (file.isDirectory() && file.canWrite()) {
flags |= DocumentsContract.FLAG_SUPPORTS_CREATE;
}
if (file.canWrite()) {
flags |= DocumentsContract.FLAG_SUPPORTS_RENAME;
+ flags |= DocumentsContract.FLAG_SUPPORTS_DELETE;
}
- final String mimeType = getTypeLocked(id);
+ final String mimeType = getTypeForFile(file);
if (mimeType.startsWith("image/")) {
flags |= DocumentsContract.FLAG_SUPPORTS_THUMBNAIL;
}
+ final long id = guid.hashCode();
cursor.addRow(new Object[] {
- id, file.getName(), file.length(), id, mimeType, file.lastModified(), flags });
+ id, file.getName(), file.length(), guid, mimeType, file.lastModified(), flags });
}
@Override
public String getType(Uri uri) {
switch (sMatcher.match(uri)) {
case URI_DOCS_ID: {
- final int id = Integer.parseInt(uri.getPathSegments().get(1));
- synchronized (mFiles) {
- return getTypeLocked(id);
- }
+ final String guid = uri.getPathSegments().get(1);
+ return getTypeForFile(guidToFile(guid));
}
default: {
throw new UnsupportedOperationException("Unsupported Uri " + uri);
@@ -151,16 +220,18 @@
}
}
- private String getTypeLocked(int id) {
- final File file = mFiles.get(id);
-
+ private String getTypeForFile(File file) {
if (file.isDirectory()) {
return DocumentsContract.MIME_TYPE_DIRECTORY;
+ } else {
+ return getTypeForName(file.getName());
}
+ }
- final int lastDot = file.getName().lastIndexOf('.');
+ private String getTypeForName(String name) {
+ final int lastDot = name.lastIndexOf('.');
if (lastDot >= 0) {
- final String extension = file.getName().substring(lastDot + 1);
+ final String extension = name.substring(lastDot + 1);
final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (mime != null) {
return mime;
@@ -174,12 +245,11 @@
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
switch (sMatcher.match(uri)) {
case URI_DOCS_ID: {
- final int id = Integer.parseInt(uri.getPathSegments().get(1));
- synchronized (mFiles) {
- final File file = mFiles.get(id);
- // TODO: turn into thumbnail
- return ParcelFileDescriptor.open(file, ContentResolver.modeToMode(uri, mode));
- }
+ final String guid = uri.getPathSegments().get(1);
+ final File file = guidToFile(guid);
+
+ // TODO: offer as thumbnail
+ return ParcelFileDescriptor.open(file, ContentResolver.modeToMode(uri, mode));
}
default: {
throw new UnsupportedOperationException("Unsupported Uri " + uri);
@@ -189,16 +259,84 @@
@Override
public Uri insert(Uri uri, ContentValues values) {
- throw new UnsupportedOperationException();
+ switch (sMatcher.match(uri)) {
+ case URI_DOCS_ID: {
+ final String guid = uri.getPathSegments().get(1);
+ final File parent = guidToFile(guid);
+
+ final String mimeType = values.getAsString(DocumentColumns.MIME_TYPE);
+ final String name = validateDisplayName(
+ values.getAsString(DocumentColumns.DISPLAY_NAME), mimeType);
+
+ final File file = new File(parent, name);
+ if (DocumentsContract.MIME_TYPE_DIRECTORY.equals(mimeType)) {
+ if (!file.mkdir()) {
+ return null;
+ }
+
+ } else {
+ try {
+ if (!file.createNewFile()) {
+ return null;
+ }
+ } catch (IOException e) {
+ Log.w(TAG, "Failed to create file", e);
+ return null;
+ }
+ }
+
+ return DocumentsContract.buildDocumentUri(AUTHORITY, fileToGuid(file));
+ }
+ default: {
+ throw new UnsupportedOperationException("Unsupported Uri " + uri);
+ }
+ }
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
- throw new UnsupportedOperationException();
+ switch (sMatcher.match(uri)) {
+ case URI_DOCS_ID: {
+ final String guid = uri.getPathSegments().get(1);
+ final File file = guidToFile(guid);
+ final File newFile = new File(
+ file.getParentFile(), values.getAsString(DocumentColumns.DISPLAY_NAME));
+ return file.renameTo(newFile) ? 1 : 0;
+ }
+ default: {
+ throw new UnsupportedOperationException("Unsupported Uri " + uri);
+ }
+ }
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
- throw new UnsupportedOperationException();
+ switch (sMatcher.match(uri)) {
+ case URI_DOCS_ID: {
+ final String guid = uri.getPathSegments().get(1);
+ final File file = guidToFile(guid);
+ return file.delete() ? 1 : 0;
+ }
+ default: {
+ throw new UnsupportedOperationException("Unsupported Uri " + uri);
+ }
+ }
+ }
+
+ private String validateDisplayName(String displayName, String mimeType) {
+ if (DocumentsContract.MIME_TYPE_DIRECTORY.equals(mimeType)) {
+ return displayName;
+ } else {
+ // Try appending meaningful extension if needed
+ if (!mimeType.equals(getTypeForName(displayName))) {
+ final String extension = MimeTypeMap.getSingleton()
+ .getExtensionFromMimeType(mimeType);
+ if (extension != null) {
+ displayName += "." + extension;
+ }
+ }
+
+ return displayName;
+ }
}
}