Merge "Define storage roots, external GUIDs, creation."
diff --git a/api/current.txt b/api/current.txt
index fc641bc..db1d832 100644
--- a/api/current.txt
+++ b/api/current.txt
@@ -20259,15 +20259,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 {
@@ -20277,6 +20287,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;
+ }
}
}