Request more documents when EXTRA_HAS_MORE.

Implement EXTRA_HAS_MORE and EXTRA_REQUEST_MORE contract with
document providers.  Providers can include EXTRA_HAS_MORE when
additional data is available with additional cost, such as a network
request.

Listen to content changes based on returned cursor instead of
original Uri.  Include a test backend to exercise.  UX still under
development.

Bug: 10350207
Change-Id: Iaa8954df55a1a1c0aa96eb8a4fd288e12c2fbb01
This commit is contained in:
Jeff Sharkey
2013-08-18 22:26:48 -07:00
parent 4eb407a832
commit b448660a22
9 changed files with 387 additions and 79 deletions

View File

@@ -42,4 +42,12 @@
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:visibility="gone" />
<Button
android:id="@+id/more"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:text="@string/more"
android:visibility="gone" />
</FrameLayout>

View File

@@ -60,4 +60,7 @@
<string name="toast_no_application">Can\'t open file</string>
<string name="toast_failed_delete">Unable to delete some documents</string>
<string name="more">More</string>
<string name="loading">Loading\u2026</string>
</resources>

View File

@@ -0,0 +1,19 @@
<?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.
-->
<documents-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:customRoots="true">
</documents-provider>

View File

@@ -32,6 +32,7 @@ import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.Loader;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Point;
import android.net.Uri;
@@ -54,6 +55,7 @@ import android.widget.AbsListView.MultiChoiceModeListener;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.BaseAdapter;
import android.widget.Button;
import android.widget.GridView;
import android.widget.ImageView;
import android.widget.ListView;
@@ -79,6 +81,7 @@ public class DirectoryFragment extends Fragment {
private View mEmptyView;
private ListView mListView;
private GridView mGridView;
private Button mMoreView;
private AbsListView mCurrentView;
@@ -93,7 +96,7 @@ public class DirectoryFragment extends Fragment {
private Point mThumbSize;
private DocumentsAdapter mAdapter;
private LoaderCallbacks<List<Document>> mCallbacks;
private LoaderCallbacks<DirectoryResult> mCallbacks;
private static final String EXTRA_TYPE = "type";
private static final String EXTRA_URI = "uri";
@@ -150,14 +153,16 @@ public class DirectoryFragment extends Fragment {
mGridView.setOnItemClickListener(mItemListener);
mGridView.setMultiChoiceModeListener(mMultiListener);
mMoreView = (Button) view.findViewById(R.id.more);
mAdapter = new DocumentsAdapter();
final Uri uri = getArguments().getParcelable(EXTRA_URI);
mType = getArguments().getInt(EXTRA_TYPE);
mCallbacks = new LoaderCallbacks<List<Document>>() {
mCallbacks = new LoaderCallbacks<DirectoryResult>() {
@Override
public Loader<List<Document>> onCreateLoader(int id, Bundle args) {
public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) {
final DisplayState state = getDisplayState(DirectoryFragment.this);
mFilter = new MimePredicate(state.acceptMimes);
@@ -189,12 +194,34 @@ public class DirectoryFragment extends Fragment {
}
@Override
public void onLoadFinished(Loader<List<Document>> loader, List<Document> data) {
mAdapter.swapDocuments(data);
public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) {
mAdapter.swapDocuments(result.contents);
final Cursor cursor = result.cursor;
if (cursor != null && cursor.getExtras()
.getBoolean(DocumentsContract.EXTRA_HAS_MORE, false)) {
mMoreView.setText(R.string.more);
mMoreView.setVisibility(View.VISIBLE);
mMoreView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mMoreView.setText(R.string.loading);
final Bundle bundle = new Bundle();
bundle.putBoolean(DocumentsContract.EXTRA_REQUEST_MORE, true);
try {
cursor.respond(bundle);
} catch (Exception e) {
Log.w(TAG, "Failed to respond: " + e);
}
}
});
} else {
mMoreView.setVisibility(View.GONE);
}
}
@Override
public void onLoaderReset(Loader<List<Document>> loader) {
public void onLoaderReset(Loader<DirectoryResult> loader) {
mAdapter.swapDocuments(null);
}
};
@@ -407,7 +434,7 @@ public class DirectoryFragment extends Fragment {
public void swapDocuments(List<Document> documents) {
mDocuments = documents;
if (documents != null && documents.isEmpty()) {
if (mDocuments != null && mDocuments.isEmpty()) {
mEmptyView.setVisibility(View.VISIBLE);
} else {
mEmptyView.setVisibility(View.GONE);

View File

@@ -36,29 +36,27 @@ import com.google.android.collect.Lists;
import libcore.io.IoUtils;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
public class DirectoryLoader extends UriDerivativeLoader<List<Document>> {
class DirectoryResult implements AutoCloseable {
Cursor cursor;
List<Document> contents = Lists.newArrayList();
Exception e;
@Override
public void close() throws Exception {
IoUtils.closeQuietly(cursor);
}
}
public class DirectoryLoader extends UriDerivativeLoader<Uri, DirectoryResult> {
private final int mType;
private Predicate<Document> mFilter;
private Comparator<Document> mSortOrder;
/**
* Stub result that represents an internal error.
*/
public static class ExceptionResult extends LinkedList<Document> {
public final Exception e;
public ExceptionResult(Exception e) {
this.e = e;
}
}
public DirectoryLoader(Context context, Uri uri, int type, Predicate<Document> filter,
Comparator<Document> sortOrder) {
super(context, uri);
@@ -68,53 +66,49 @@ public class DirectoryLoader extends UriDerivativeLoader<List<Document>> {
}
@Override
public List<Document> loadInBackground(Uri uri, CancellationSignal signal) {
public DirectoryResult loadInBackground(Uri uri, CancellationSignal signal) {
final DirectoryResult result = new DirectoryResult();
try {
return loadInBackgroundInternal(uri, signal);
loadInBackgroundInternal(result, uri, signal);
} catch (Exception e) {
return new ExceptionResult(e);
result.e = e;
}
return result;
}
private List<Document> loadInBackgroundInternal(Uri uri, CancellationSignal signal) {
final ArrayList<Document> result = Lists.newArrayList();
// TODO: subscribe to the notify uri from query
private void loadInBackgroundInternal(
DirectoryResult result, Uri uri, CancellationSignal signal) {
final ContentResolver resolver = getContext().getContentResolver();
final Cursor cursor = resolver.query(uri, null, null, null, getQuerySortOrder(), signal);
try {
while (cursor != null && cursor.moveToNext()) {
Document doc = null;
switch (mType) {
case TYPE_NORMAL:
case TYPE_SEARCH:
doc = Document.fromDirectoryCursor(uri, cursor);
break;
case TYPE_RECENT_OPEN:
try {
doc = Document.fromRecentOpenCursor(resolver, cursor);
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to find recent: " + e);
}
break;
default:
throw new IllegalArgumentException("Unknown type");
}
result.cursor = cursor;
result.cursor.registerContentObserver(mObserver);
if (doc != null && (mFilter == null || mFilter.apply(doc))) {
result.add(doc);
}
while (cursor.moveToNext()) {
Document doc = null;
switch (mType) {
case TYPE_NORMAL:
case TYPE_SEARCH:
doc = Document.fromDirectoryCursor(uri, cursor);
break;
case TYPE_RECENT_OPEN:
try {
doc = Document.fromRecentOpenCursor(resolver, cursor);
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to find recent: " + e);
}
break;
default:
throw new IllegalArgumentException("Unknown type");
}
if (doc != null && (mFilter == null || mFilter.apply(doc))) {
result.contents.add(doc);
}
} finally {
IoUtils.closeQuietly(cursor);
}
if (mSortOrder != null) {
Collections.sort(result, mSortOrder);
Collections.sort(result.contents, mSortOrder);
}
return result;
}
private String getQuerySortOrder() {

View File

@@ -124,7 +124,7 @@ public class RecentsCreateFragment extends Fragment {
}
};
public static class RecentsCreateLoader extends UriDerivativeLoader<List<DocumentStack>> {
public static class RecentsCreateLoader extends UriDerivativeLoader<Uri, List<DocumentStack>> {
public RecentsCreateLoader(Context context) {
super(context, RecentsProvider.buildRecentCreate());
}

View File

@@ -19,7 +19,6 @@ package com.android.documentsui;
import android.content.AsyncTaskLoader;
import android.content.Context;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.CancellationSignal;
import android.os.OperationCanceledException;
@@ -28,17 +27,16 @@ import android.os.OperationCanceledException;
* changes while started, manages {@link CancellationSignal}, and caches
* returned results.
*/
public abstract class UriDerivativeLoader<T> extends AsyncTaskLoader<T> {
private final ForceLoadContentObserver mObserver;
private boolean mObserving;
public abstract class UriDerivativeLoader<P, R> extends AsyncTaskLoader<R> {
final ForceLoadContentObserver mObserver;
private final Uri mUri;
private final P mParam;
private T mResult;
private R mResult;
private CancellationSignal mCancellationSignal;
@Override
public final T loadInBackground() {
public final R loadInBackground() {
synchronized (this) {
if (isLoadInBackgroundCanceled()) {
throw new OperationCanceledException();
@@ -46,7 +44,7 @@ public abstract class UriDerivativeLoader<T> extends AsyncTaskLoader<T> {
mCancellationSignal = new CancellationSignal();
}
try {
return loadInBackground(mUri, mCancellationSignal);
return loadInBackground(mParam, mCancellationSignal);
} finally {
synchronized (this) {
mCancellationSignal = null;
@@ -54,7 +52,7 @@ public abstract class UriDerivativeLoader<T> extends AsyncTaskLoader<T> {
}
}
public abstract T loadInBackground(Uri uri, CancellationSignal signal);
public abstract R loadInBackground(P param, CancellationSignal signal);
@Override
public void cancelLoadInBackground() {
@@ -68,12 +66,12 @@ public abstract class UriDerivativeLoader<T> extends AsyncTaskLoader<T> {
}
@Override
public void deliverResult(T result) {
public void deliverResult(R result) {
if (isReset()) {
closeQuietly(result);
return;
}
T oldResult = mResult;
R oldResult = mResult;
mResult = result;
if (isStarted()) {
@@ -85,18 +83,14 @@ public abstract class UriDerivativeLoader<T> extends AsyncTaskLoader<T> {
}
}
public UriDerivativeLoader(Context context, Uri uri) {
public UriDerivativeLoader(Context context, P param) {
super(context);
mObserver = new ForceLoadContentObserver();
mUri = uri;
mParam = param;
}
@Override
protected void onStartLoading() {
if (!mObserving) {
getContext().getContentResolver().registerContentObserver(mUri, false, mObserver);
mObserving = true;
}
if (mResult != null) {
deliverResult(mResult);
}
@@ -111,7 +105,7 @@ public abstract class UriDerivativeLoader<T> extends AsyncTaskLoader<T> {
}
@Override
public void onCanceled(T result) {
public void onCanceled(R result) {
closeQuietly(result);
}
@@ -125,13 +119,10 @@ public abstract class UriDerivativeLoader<T> extends AsyncTaskLoader<T> {
closeQuietly(mResult);
mResult = null;
if (mObserving) {
getContext().getContentResolver().unregisterContentObserver(mObserver);
mObserving = false;
}
getContext().getContentResolver().unregisterContentObserver(mObserver);
}
private void closeQuietly(T result) {
private void closeQuietly(R result) {
if (result instanceof AutoCloseable) {
try {
((AutoCloseable) result).close();

View File

@@ -15,5 +15,18 @@
android:name="android.content.DOCUMENT_PROVIDER"
android:resource="@xml/document_provider" />
</provider>
<!-- TODO: remove when we have real providers -->
<provider
android:name=".CloudTestDocumentsProvider"
android:authorities="com.android.externalstorage.cloudtest"
android:grantUriPermissions="true"
android:exported="true"
android:enabled="false"
android:permission="android.permission.MANAGE_DOCUMENTS">
<meta-data
android:name="android.content.DOCUMENT_PROVIDER"
android:resource="@xml/document_provider" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,253 @@
/*
* 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.ContentProvider;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.MatrixCursor.RowBuilder;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.DocumentColumns;
import android.provider.DocumentsContract.Documents;
import android.provider.DocumentsContract.RootColumns;
import android.provider.DocumentsContract.Roots;
import android.util.Log;
import com.google.android.collect.Lists;
import libcore.io.IoUtils;
import java.io.FileNotFoundException;
import java.util.List;
public class CloudTestDocumentsProvider extends ContentProvider {
private static final String TAG = "CloudTest";
private static final String AUTHORITY = "com.android.externalstorage.cloudtest";
private static final UriMatcher sMatcher = new UriMatcher(UriMatcher.NO_MATCH);
private static final int URI_ROOTS = 1;
private static final int URI_ROOTS_ID = 2;
private static final int URI_DOCS_ID = 3;
private static final int URI_DOCS_ID_CONTENTS = 4;
private static final int URI_DOCS_ID_SEARCH = 5;
static {
sMatcher.addURI(AUTHORITY, "roots", URI_ROOTS);
sMatcher.addURI(AUTHORITY, "roots/*", URI_ROOTS_ID);
sMatcher.addURI(AUTHORITY, "roots/*/docs/*", URI_DOCS_ID);
sMatcher.addURI(AUTHORITY, "roots/*/docs/*/contents", URI_DOCS_ID_CONTENTS);
sMatcher.addURI(AUTHORITY, "roots/*/docs/*/search", URI_DOCS_ID_SEARCH);
}
private static final String[] ALL_ROOTS_COLUMNS = new String[] {
RootColumns.ROOT_ID, RootColumns.ROOT_TYPE, RootColumns.ICON, RootColumns.TITLE,
RootColumns.SUMMARY, RootColumns.AVAILABLE_BYTES
};
private static final String[] ALL_DOCUMENTS_COLUMNS = new String[] {
DocumentColumns.DOC_ID, DocumentColumns.DISPLAY_NAME, DocumentColumns.SIZE,
DocumentColumns.MIME_TYPE, DocumentColumns.LAST_MODIFIED, DocumentColumns.FLAGS
};
private List<String> mKnownDocs = Lists.newArrayList("meow.png", "kittens.pdf");
private int mPage;
@Override
public boolean onCreate() {
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
switch (sMatcher.match(uri)) {
case URI_ROOTS: {
final MatrixCursor result = new MatrixCursor(
projection != null ? projection : ALL_ROOTS_COLUMNS);
includeDefaultRoot(result);
return result;
}
case URI_ROOTS_ID: {
final MatrixCursor result = new MatrixCursor(
projection != null ? projection : ALL_ROOTS_COLUMNS);
includeDefaultRoot(result);
return result;
}
case URI_DOCS_ID: {
final String docId = DocumentsContract.getDocId(uri);
final MatrixCursor result = new MatrixCursor(
projection != null ? projection : ALL_DOCUMENTS_COLUMNS);
includeDoc(result, docId);
return result;
}
case URI_DOCS_ID_CONTENTS: {
final CloudCursor result = new CloudCursor(
projection != null ? projection : ALL_DOCUMENTS_COLUMNS, uri);
for (String docId : mKnownDocs) {
includeDoc(result, docId);
}
if (mPage < 3) {
result.setHasMore();
}
result.setNotificationUri(getContext().getContentResolver(), uri);
return result;
}
default: {
throw new UnsupportedOperationException("Unsupported Uri " + uri);
}
}
}
private void includeDefaultRoot(MatrixCursor result) {
final RowBuilder row = result.newRow();
row.offer(RootColumns.ROOT_ID, "testroot");
row.offer(RootColumns.ROOT_TYPE, Roots.ROOT_TYPE_SERVICE);
row.offer(RootColumns.TITLE, "_TestTitle");
row.offer(RootColumns.SUMMARY, "_TestSummary");
}
private void includeDoc(MatrixCursor result, String docId) {
int flags = 0;
final String mimeType;
if (Documents.DOC_ID_ROOT.equals(docId)) {
mimeType = Documents.MIME_TYPE_DIR;
} else {
mimeType = "application/octet-stream";
}
final RowBuilder row = result.newRow();
row.offer(DocumentColumns.DOC_ID, docId);
row.offer(DocumentColumns.DISPLAY_NAME, docId);
row.offer(DocumentColumns.MIME_TYPE, mimeType);
row.offer(DocumentColumns.LAST_MODIFIED, System.currentTimeMillis());
row.offer(DocumentColumns.FLAGS, flags);
}
private class CloudCursor extends MatrixCursor {
private final Uri mUri;
private Bundle mExtras = new Bundle();
public CloudCursor(String[] columnNames, Uri uri) {
super(columnNames);
mUri = uri;
}
public void setHasMore() {
mExtras.putBoolean(DocumentsContract.EXTRA_HAS_MORE, true);
}
@Override
public Bundle getExtras() {
Log.d(TAG, "getExtras() " + mExtras);
return mExtras;
}
@Override
public Bundle respond(Bundle extras) {
extras.size();
Log.d(TAG, "respond() " + extras);
if (extras.getBoolean(DocumentsContract.EXTRA_REQUEST_MORE, false)) {
new CloudTask().execute(mUri);
}
return Bundle.EMPTY;
}
}
private class CloudTask extends AsyncTask<Uri, Void, Void> {
@Override
protected Void doInBackground(Uri... uris) {
final Uri uri = uris[0];
SystemClock.sleep(1000);
// Grab some files from the cloud
for (int i = 0; i < 5; i++) {
mKnownDocs.add("cloud-page" + mPage + "-file" + i);
}
mPage++;
Log.d(TAG, "Loaded more; notifying " + uri);
getContext().getContentResolver().notifyChange(uri, null, false);
return null;
}
}
private interface TypeQuery {
final String[] PROJECTION = {
DocumentColumns.MIME_TYPE };
final int MIME_TYPE = 0;
}
@Override
public String getType(Uri uri) {
switch (sMatcher.match(uri)) {
case URI_ROOTS: {
return Roots.MIME_TYPE_DIR;
}
case URI_ROOTS_ID: {
return Roots.MIME_TYPE_ITEM;
}
case URI_DOCS_ID: {
final Cursor cursor = query(uri, TypeQuery.PROJECTION, null, null, null);
try {
if (cursor.moveToFirst()) {
return cursor.getString(TypeQuery.MIME_TYPE);
} else {
return null;
}
} finally {
IoUtils.closeQuietly(cursor);
}
}
default: {
throw new UnsupportedOperationException("Unsupported Uri " + uri);
}
}
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
throw new UnsupportedOperationException("Unsupported Uri " + uri);
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("Unsupported Uri " + uri);
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("Unsupported Uri " + uri);
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("Unsupported Uri " + uri);
}
}