diff options
author | 2013-09-03 15:25:52 -0700 | |
---|---|---|
committer | 2013-09-03 17:02:47 -0700 | |
commit | 954be0232655d316bc5decbbd35579af902c75c2 (patch) | |
tree | 745d4ea34ad245f3e9d87ef19a8910d47cdd3ad4 | |
parent | ded77187ef53341765fcab8e29cda94810fc2ca5 (diff) |
Show loading, error, and info messages as footers.
A provider can include extras in their Cursors to indicate that
loading is ongoing, or include an error or informational message,
which are now shown in footer views.
Fix registration to always get change notifications.
Test provider that verifies common provider behavior of holding
a reference to "cloud" resources that are released by GC when the
remote Cursor is closed. Also used to validate Recents behavior
for slow providers.
Bug: 10599268
Change-Id: I331c31058dbb80261e7d279b851197c65ac87e32
9 files changed, 517 insertions, 6 deletions
diff --git a/packages/DocumentsUI/res/layout/item_loading.xml b/packages/DocumentsUI/res/layout/item_loading.xml new file mode 100644 index 000000000000..7da71e3cb4f6 --- /dev/null +++ b/packages/DocumentsUI/res/layout/item_loading.xml @@ -0,0 +1,34 @@ +<?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:minHeight="?android:attr/listPreferredItemHeight" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingTop="8dip" + android:paddingBottom="8dip" + android:orientation="horizontal"> + + <ProgressBar + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:indeterminate="true" + style="?android:attr/progressBarStyle" /> + +</FrameLayout> diff --git a/packages/DocumentsUI/res/layout/item_message_grid.xml b/packages/DocumentsUI/res/layout/item_message_grid.xml new file mode 100644 index 000000000000..941340e9d720 --- /dev/null +++ b/packages/DocumentsUI/res/layout/item_message_grid.xml @@ -0,0 +1,59 @@ +<?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="180dip" + android:paddingBottom="?android:attr/listPreferredItemPaddingEnd" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/chip" + android:foreground="@drawable/item_background" + android:duplicateParentState="true"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingBottom="6dp" + android:orientation="vertical" + android:gravity="center"> + + <ImageView + android:id="@android:id/icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="@null" /> + + <TextView + android:id="@android:id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:singleLine="true" + android:ellipsize="marquee" + android:paddingTop="6dp" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:textAppearance="?android:attr/textAppearanceSmall" + android:textAlignment="viewStart" /> + + </LinearLayout> + + </FrameLayout> + +</FrameLayout> diff --git a/packages/DocumentsUI/res/layout/item_message_list.xml b/packages/DocumentsUI/res/layout/item_message_list.xml new file mode 100644 index 000000000000..dda3c80f2baa --- /dev/null +++ b/packages/DocumentsUI/res/layout/item_message_list.xml @@ -0,0 +1,47 @@ +<?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. +--> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@drawable/item_background" + android:minHeight="?android:attr/listPreferredItemHeight" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingTop="8dip" + android:paddingBottom="8dip" + android:orientation="horizontal"> + + <ImageView + android:id="@android:id/icon" + android:layout_width="@android:dimen/app_icon_size" + android:layout_height="@android:dimen/app_icon_size" + android:layout_marginEnd="8dip" + android:layout_gravity="center_vertical" + android:scaleType="centerInside" + android:contentDescription="@null" /> + + <TextView + android:id="@android:id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:singleLine="true" + android:ellipsize="marquee" + android:textAppearance="?android:attr/textAppearanceMedium" + android:textAlignment="viewStart" /> + +</LinearLayout> diff --git a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java index 1220137f8e3e..33d7d6afd18d 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java @@ -412,11 +412,83 @@ public class DirectoryFragment extends Fragment { return ((DocumentsActivity) fragment.getActivity()).getDisplayState(); } + private interface Footer { + public View getView(View convertView, ViewGroup parent); + } + + private static class LoadingFooter implements Footer { + @Override + public View getView(View convertView, ViewGroup parent) { + final Context context = parent.getContext(); + if (convertView == null) { + final LayoutInflater inflater = LayoutInflater.from(context); + convertView = inflater.inflate(R.layout.item_loading, parent, false); + } + return convertView; + } + } + + private class MessageFooter implements Footer { + private final int mIcon; + private final String mMessage; + + public MessageFooter(int icon, String message) { + mIcon = icon; + mMessage = message; + } + + @Override + public View getView(View convertView, ViewGroup parent) { + final Context context = parent.getContext(); + final State state = getDisplayState(DirectoryFragment.this); + + if (convertView == null) { + final LayoutInflater inflater = LayoutInflater.from(context); + if (state.mode == MODE_LIST) { + convertView = inflater.inflate(R.layout.item_message_list, parent, false); + } else if (state.mode == MODE_GRID) { + convertView = inflater.inflate(R.layout.item_message_grid, parent, false); + } else { + throw new IllegalStateException(); + } + } + + final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon); + final TextView title = (TextView) convertView.findViewById(android.R.id.title); + icon.setImageResource(mIcon); + title.setText(mMessage); + return convertView; + } + } + private class DocumentsAdapter extends BaseAdapter { private Cursor mCursor; + private int mCursorCount; + + private List<Footer> mFooters = Lists.newArrayList(); public void swapCursor(Cursor cursor) { mCursor = cursor; + mCursorCount = cursor != null ? cursor.getCount() : 0; + + mFooters.clear(); + + final Bundle extras = cursor != null ? cursor.getExtras() : null; + if (extras != null) { + final String info = extras.getString(DocumentsContract.EXTRA_INFO); + if (info != null) { + mFooters.add(new MessageFooter( + com.android.internal.R.drawable.ic_menu_info_details, info)); + } + final String error = extras.getString(DocumentsContract.EXTRA_ERROR); + if (error != null) { + mFooters.add(new MessageFooter( + com.android.internal.R.drawable.ic_dialog_alert, error)); + } + if (extras.getBoolean(DocumentsContract.EXTRA_LOADING, false)) { + mFooters.add(new LoadingFooter()); + } + } if (isEmpty()) { mEmptyView.setVisibility(View.VISIBLE); @@ -429,6 +501,15 @@ public class DirectoryFragment extends Fragment { @Override public View getView(int position, View convertView, ViewGroup parent) { + if (position < mCursorCount) { + return getDocumentView(position, convertView, parent); + } else { + position -= mCursorCount; + return mFooters.get(position).getView(convertView, parent); + } + } + + private View getDocumentView(int position, View convertView, ViewGroup parent) { final Context context = parent.getContext(); final State state = getDisplayState(DirectoryFragment.this); @@ -535,21 +616,42 @@ public class DirectoryFragment extends Fragment { @Override public int getCount() { - return mCursor != null ? mCursor.getCount() : 0; + return mCursorCount + mFooters.size(); } @Override public Cursor getItem(int position) { - if (mCursor != null) { + if (position < mCursorCount) { mCursor.moveToPosition(position); + return mCursor; + } else { + return null; } - return mCursor; } @Override public long getItemId(int position) { return position; } + + @Override + public int getItemViewType(int position) { + if (position < mCursorCount) { + return 0; + } else { + return IGNORE_ITEM_VIEW_TYPE; + } + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public boolean isEnabled(int position) { + return position < mCursorCount; + } } private static class ThumbnailAsyncTask extends AsyncTask<Uri, Void, Bitmap> { diff --git a/packages/DocumentsUI/src/com/android/documentsui/DirectoryLoader.java b/packages/DocumentsUI/src/com/android/documentsui/DirectoryLoader.java index 3f016b50143f..6ea57d77cfa6 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/DirectoryLoader.java +++ b/packages/DocumentsUI/src/com/android/documentsui/DirectoryLoader.java @@ -77,11 +77,12 @@ public class DirectoryLoader extends AsyncTaskLoader<DirectoryResult> { .getContentResolver().acquireUnstableContentProviderClient(authority); final Cursor cursor = result.client.query( mUri, null, null, null, getQuerySortOrder(mSortOrder), mSignal); + cursor.registerContentObserver(mObserver); + final Cursor withRoot = new RootCursorWrapper(mUri.getAuthority(), mRootId, cursor, -1); final Cursor sorted = new SortingCursorWrapper(withRoot, mSortOrder); result.cursor = sorted; - result.cursor.registerContentObserver(mObserver); } catch (Exception e) { result.exception = e; ContentProviderClient.closeQuietly(result.client); diff --git a/packages/DocumentsUI/src/com/android/documentsui/RootCursorWrapper.java b/packages/DocumentsUI/src/com/android/documentsui/RootCursorWrapper.java index d0e5ff6312ff..0b58218ab1cc 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/RootCursorWrapper.java +++ b/packages/DocumentsUI/src/com/android/documentsui/RootCursorWrapper.java @@ -18,6 +18,7 @@ package com.android.documentsui; import android.database.AbstractCursor; import android.database.Cursor; +import android.os.Bundle; /** * Cursor wrapper that adds columns to identify which root a document came from. @@ -63,6 +64,11 @@ public class RootCursorWrapper extends AbstractCursor { } @Override + public Bundle getExtras() { + return mCursor.getExtras(); + } + + @Override public void close() { super.close(); mCursor.close(); @@ -128,5 +134,4 @@ public class RootCursorWrapper extends AbstractCursor { public boolean isNull(int column) { return mCursor.isNull(column); } - } diff --git a/packages/DocumentsUI/src/com/android/documentsui/SortingCursorWrapper.java b/packages/DocumentsUI/src/com/android/documentsui/SortingCursorWrapper.java index b434a35c7f2c..19ad2e293c6b 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/SortingCursorWrapper.java +++ b/packages/DocumentsUI/src/com/android/documentsui/SortingCursorWrapper.java @@ -22,6 +22,7 @@ import static com.android.documentsui.DocumentsActivity.State.SORT_ORDER_SIZE; import android.database.AbstractCursor; import android.database.Cursor; +import android.os.Bundle; import android.provider.DocumentsContract.Document; /** @@ -96,6 +97,11 @@ public class SortingCursorWrapper extends AbstractCursor { } @Override + public Bundle getExtras() { + return mCursor.getExtras(); + } + + @Override public void close() { super.close(); mCursor.close(); diff --git a/packages/ExternalStorageProvider/AndroidManifest.xml b/packages/ExternalStorageProvider/AndroidManifest.xml index 52721669cf54..7094efc375b6 100644 --- a/packages/ExternalStorageProvider/AndroidManifest.xml +++ b/packages/ExternalStorageProvider/AndroidManifest.xml @@ -13,7 +13,20 @@ android:permission="android.permission.MANAGE_DOCUMENTS"> <meta-data android:name="android.content.DOCUMENT_PROVIDER" - android:resource="@xml/document_provider" /> + android:value="true" /> + </provider> + + <!-- TODO: find a better place for tests to live --> + <provider + android:name=".TestDocumentsProvider" + android:authorities="com.example.documents" + android:grantUriPermissions="true" + android:exported="true" + android:permission="android.permission.MANAGE_DOCUMENTS" + android:enabled="false"> + <meta-data + android:name="android.content.DOCUMENT_PROVIDER" + android:value="true" /> </provider> </application> </manifest> diff --git a/packages/ExternalStorageProvider/src/com/android/externalstorage/TestDocumentsProvider.java b/packages/ExternalStorageProvider/src/com/android/externalstorage/TestDocumentsProvider.java new file mode 100644 index 000000000000..872974fbfaf5 --- /dev/null +++ b/packages/ExternalStorageProvider/src/com/android/externalstorage/TestDocumentsProvider.java @@ -0,0 +1,244 @@ +/* + * 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. + */ + +package com.android.externalstorage; + +import android.content.ContentResolver; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MatrixCursor.RowBuilder; +import android.net.Uri; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.os.SystemClock; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; +import android.provider.DocumentsContract.Root; +import android.provider.DocumentsProvider; +import android.util.Log; + +import java.io.FileNotFoundException; +import java.lang.ref.WeakReference; + +public class TestDocumentsProvider extends DocumentsProvider { + private static final String TAG = "TestDocuments"; + + private static final boolean CRASH_ROOTS = false; + private static final boolean CRASH_DOCUMENT = false; + + private static final String MY_ROOT_ID = "myRoot"; + private static final String MY_DOC_ID = "myDoc"; + private static final String MY_DOC_NULL = "myNull"; + + private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { + Root.COLUMN_ROOT_ID, Root.COLUMN_ROOT_TYPE, Root.COLUMN_FLAGS, Root.COLUMN_ICON, + Root.COLUMN_TITLE, Root.COLUMN_SUMMARY, 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 static String[] resolveRootProjection(String[] projection) { + return projection != null ? projection : DEFAULT_ROOT_PROJECTION; + } + + private static String[] resolveDocumentProjection(String[] projection) { + return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; + } + + @Override + public Cursor queryRoots(String[] projection) throws FileNotFoundException { + if (CRASH_ROOTS) System.exit(12); + + final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); + final RowBuilder row = result.newRow(); + row.offer(Root.COLUMN_ROOT_ID, MY_ROOT_ID); + row.offer(Root.COLUMN_ROOT_TYPE, Root.ROOT_TYPE_SERVICE); + row.offer(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_RECENTS); + row.offer(Root.COLUMN_TITLE, "_Test title which is really long"); + row.offer(Root.COLUMN_SUMMARY, "_Summary which is also super long text"); + row.offer(Root.COLUMN_DOCUMENT_ID, MY_DOC_ID); + row.offer(Root.COLUMN_AVAILABLE_BYTES, 1024); + return result; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) + throws FileNotFoundException { + if (CRASH_DOCUMENT) System.exit(12); + + final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); + includeFile(result, documentId); + return result; + } + + /** + * Holds any outstanding or finished "network" fetching. + */ + private WeakReference<CloudTask> mTask; + + private static class CloudTask implements Runnable { + + private final ContentResolver mResolver; + private final Uri mNotifyUri; + + private volatile boolean mFinished; + + public CloudTask(ContentResolver resolver, Uri notifyUri) { + mResolver = resolver; + mNotifyUri = notifyUri; + } + + @Override + public void run() { + // Pretend to do some network + Log.d(TAG, hashCode() + ": pretending to do some network!"); + SystemClock.sleep(2000); + Log.d(TAG, hashCode() + ": network done!"); + + mFinished = true; + + // Tell anyone remotely they should requery + mResolver.notifyChange(mNotifyUri, null, false); + } + + public boolean includeIfFinished(MatrixCursor result) { + Log.d(TAG, hashCode() + ": includeIfFinished() found " + mFinished); + if (mFinished) { + includeFile(result, "_networkfile1"); + includeFile(result, "_networkfile2"); + includeFile(result, "_networkfile3"); + return true; + } else { + return false; + } + } + } + + private static class CloudCursor extends MatrixCursor { + public Object keepAlive; + public final Bundle extras = new Bundle(); + + public CloudCursor(String[] columnNames) { + super(columnNames); + } + + @Override + public Bundle getExtras() { + return extras; + } + } + + @Override + public Cursor queryChildDocuments( + String parentDocumentId, String[] projection, String sortOrder) + throws FileNotFoundException { + + final ContentResolver resolver = getContext().getContentResolver(); + final Uri notifyUri = DocumentsContract.buildDocumentUri( + "com.example.documents", parentDocumentId); + + CloudCursor result = new CloudCursor(resolveDocumentProjection(projection)); + result.setNotificationUri(resolver, notifyUri); + + // Always include local results + includeFile(result, MY_DOC_NULL); + includeFile(result, "localfile1"); + includeFile(result, "localfile2"); + + synchronized (this) { + // Try picking up an existing network fetch + CloudTask task = mTask != null ? mTask.get() : null; + if (task == null) { + Log.d(TAG, "No network task found; starting!"); + task = new CloudTask(resolver, notifyUri); + mTask = new WeakReference<CloudTask>(task); + new Thread(task).start(); + + // Aggressively try freeing weak reference above + new Thread() { + @Override + public void run() { + while (mTask.get() != null) { + SystemClock.sleep(200); + System.gc(); + System.runFinalization(); + } + Log.d(TAG, "AHA! THE CLOUD TASK WAS GC'ED!"); + } + }.start(); + } + + // Blend in cloud results if ready + if (task.includeIfFinished(result)) { + result.extras.putString(DocumentsContract.EXTRA_INFO, + "Everything Went Better Than Expected and this message is quite " + + "long and verbose and maybe even too long"); + result.extras.putString(DocumentsContract.EXTRA_ERROR, + "But then again, maybe our server ran into an error, which means " + + "we're going to have a bad time"); + } else { + result.extras.putBoolean(DocumentsContract.EXTRA_LOADING, true); + } + + // Tie the network fetch to the cursor GC lifetime + result.keepAlive = task; + + return result; + } + } + + @Override + public Cursor queryRecentDocuments(String rootId, String[] projection) + throws FileNotFoundException { + // Pretend to take a super long time to respond + SystemClock.sleep(3000); + + final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); + includeFile(result, "It was /worth/ the_wait for?the file:with the&incredibly long name"); + return result; + } + + @Override + public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal) + throws FileNotFoundException { + throw new FileNotFoundException(); + } + + @Override + public boolean onCreate() { + return true; + } + + private static void includeFile(MatrixCursor result, String docId) { + final RowBuilder row = result.newRow(); + row.offer(Document.COLUMN_DOCUMENT_ID, docId); + row.offer(Document.COLUMN_DISPLAY_NAME, docId); + row.offer(Document.COLUMN_LAST_MODIFIED, System.currentTimeMillis()); + + if (MY_DOC_ID.equals(docId)) { + row.offer(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR); + } else if (MY_DOC_NULL.equals(docId)) { + // No MIME type + } else { + row.offer(Document.COLUMN_MIME_TYPE, "application/octet-stream"); + } + } +} |