Anthony
Anthony

Reputation: 7828

queryRoots calls queryDocument first, not queryChildDocuments

I'm writing a wrapper for SAF wrapper for Dropbox since everyone (including Google) is too lazy to implement this "very rich" (ie: awful) API. I've got my root in the picker, but I thought queryChildren should be called first. However, queryChildren is never called and it goes straight toqueryDocument`.

override fun queryRoots(projection: Array<out String>?): Cursor {
    // TODO: Likely need to be more strict about projection (ie: map to supported)
    val result = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)

    val row = result.newRow()
    row.add(DocumentsContract.Root.COLUMN_ROOT_ID, "com.anthonymandra.cloudprovider.dropbox")
    row.add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_dropbox_gray)
    row.add(DocumentsContract.Root.COLUMN_TITLE, "Dropbox")
    row.add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_SUPPORTS_CREATE)   // TODO:
    row.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
    return result
}

override fun queryChildDocuments(
    parentDocumentId: String?,
    projection: Array<out String>?,
    sortOrder: String?
): Cursor {
    // TODO: Likely need to be more strict about projection (ie: map to supported)
    val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
    val dropboxPath = if (parentDocumentId == ROOT_DOCUMENT_ID) "" else parentDocumentId

    try {
        val client = DropboxClientFactory.client

        var childFolders = client.files().listFolder(dropboxPath)
        while (true) {
            for (metadata in childFolders.entries) {
                addDocumentRow(result, metadata)
            }

            if (!childFolders.hasMore) {
                break
            }

            childFolders = client.files().listFolderContinue(childFolders.cursor)
        }
    } catch(e: IllegalStateException) { // Test if we can attempt auth thru the provider
        context?.let {
            Auth.startOAuth2Authentication(it, appKey)   // TODO: appKey
        }
    }
    return result
}

override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor {
    // TODO: Likely need to be more strict about projection (ie: map to supported)
    val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)

    try {
        val client = DropboxClientFactory.client
        val metadata = client.files().getMetadata(documentId)
        addDocumentRow(result, metadata)
    } catch(e: IllegalStateException) { // Test if we can attempt auth thru the provider
        context?.let {
            Auth.startOAuth2Authentication(it, appKey)   // TODO: appKey
        }
    }
    return result
}

Error:

java.lang.IllegalArgumentException: String 'path' does not match pattern
    at com.dropbox.core.v2.files.GetMetadataArg.<init>(GetMetadataArg.java:58)
    at com.dropbox.core.v2.files.GetMetadataArg.<init>(GetMetadataArg.java:80)
    at com.dropbox.core.v2.files.DbxUserFilesRequests.getMetadata(DbxUserFilesRequests.java:1285)
    at com.anthonymandra.cloudprovider.dropbox.DropboxProvider.queryDocument(DropboxProvider.kt:98)
    at android.provider.DocumentsProvider.query(DocumentsProvider.java:797)
    at android.content.ContentProvider$Transport.query(ContentProvider.java:240)
    at android.content.ContentProviderNative.onTransact(ContentProviderNative.java:102)
    at android.os.Binder.execTransact(Binder.java:731)

path is ROOT_DOCUMENT_ID which I'm expecting to go to queryChildDocuments first.

What am I missing here?

Upvotes: 2

Views: 699

Answers (2)

tfrysinger
tfrysinger

Reputation: 1336

I have also written a SAF DropBox implementation, and I also was a bit confused at first about this.

From the documentation:

Note the following:

  • Each document provider reports one or more "roots" which are starting points into exploring a tree of documents. Each root has a unique COLUMN_ROOT_ID, and it points to a document (a directory) representing the contents under that root. Roots are dynamic by design to support use cases like multiple accounts, transient USB storage devices, or user login/log out.
  • Under each root is a single document. That document points to 1 to N documents, each of which in turn can point to 1 to N documents.
  • Each storage backend surfaces individual files and directories by referencing them with a unique COLUMN_DOCUMENT_ID. Document IDs must be unique and not change once issued, since they are used for persistent URI grants across device reboots.
  • Documents can be either an openable file (with a specific MIME type), or a directory containing additional documents (with the MIME_TYPE_DIR MIME type).
  • Each document can have different capabilities, as described by COLUMN_FLAGS. For example, FLAG_SUPPORTS_WRITE, FLAG_SUPPORTS_DELETE, and FLAG_SUPPORTS_THUMBNAIL. The same COLUMN_DOCUMENT_ID can be included in multiple directories.

That second bullet is the key bullet. After the return from queryRoots(), for each root you passed back, the SAF makes a call to queryDocument(). This is essentially to create the "root file folder" document that appears in the list. What I did was in queryDocument() I check to see if the documentId passed in matches the unique value I gave to DocumentsContract.Root.COLUMN_ROOT_ID in the queryRoots() call. If it is, then you know this queryDocument() call needs to return a folder representing that root. Otherwise, I use the path from DropBox as my documentId everywhere else, so I use that documentID value in calls via DbxClientV2.

Here is some sample code - note that in my case I created an AbstractStorageProvider class from which all my various providers (Dropbox, Instagram, etc.) extend. The base class handles receiving the calls from SAF, and it does some housekeeping (like creating the cursors) and then calls the methods in the implementing classes to populate the cursors as required by that particular service:

Base Class

public Cursor queryRoots(final String[] projection) {
    Timber.d( "Lifecycle: queryRoots called");

    // If they are not paid up, they do not get to use any of these implementations
    if (!InTouchUtils.isLoginPaidSubscription()) {
        return null;
    }

    // Create a cursor with either the requested fields, or the default projection if "projection" is null.
    final MatrixCursor cursor = new MatrixCursor(projection != null ? projection : getDefaultRootProjection());

    // Classes that extend this one must implement this method
    addRowsToQueryRootsCursor(cursor);

    return cursor;
}

From DropboxProvider addRowsToQueryRootsCursor:

protected void addRowsToQueryRootsCursor(MatrixCursor cursor) {
    // See if we need to init
    long l = System.currentTimeMillis();
    if ( !InTouchUtils.initDropboxClient()) {
        return;
    }
    Timber.d( "Time to test initialization of DropboxClient: %dms.", (System.currentTimeMillis() - l));
    l = System.currentTimeMillis();
    try {
        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(Objects.requireNonNull(getContext()).getApplicationContext());
        String displayname = sharedPrefs.getString(getContext().getString(R.string.pref_dropbox_displayname_token_key),
                getContext().getResources().getString(R.string.pref_dropbox_displayname_token_default));

        batchSize = Long.valueOf(Objects.requireNonNull(sharedPrefs.getString(getContext().getString(R.string.pref_dropbox_query_limit_key),
                getContext().getResources().getString(R.string.pref_dropbox_query_limit_key_default))));

        final MatrixCursor.RowBuilder row = cursor.newRow();

        row.add(DocumentsContract.Root.COLUMN_ROOT_ID, <YOUR_UNIQUE_ROOTS_KEY_HERE>);
        row.add(DocumentsContract.Root.COLUMN_TITLE,
                String.format(getContext().getString(R.string.dropbox_root_title),getContext().getString(R.string.app_name)));
        row.add(DocumentsContract.Root.COLUMN_SUMMARY,displayname+
                getContext().getResources().getString(R.string.dropbox_root_summary));
        row.add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_SUPPORTS_RECENTS | DocumentsContract.Root.FLAG_SUPPORTS_SEARCH);
        row.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID,<YOUR_UNIQUE_ROOT_FOLDER_ID_HERE>);
        row.add(DocumentsContract.Root.COLUMN_ICON, R.drawable.intouch_for_dropbox);
    } catch (Exception e) {
        Timber.d( "Called addRowsToQueryRootsCursor got exception, message was: %s", e.getMessage());
    }
    Timber.d( "Time to queryRoots(): %dms.", (System.currentTimeMillis() - l));
}

Then queryDocument() method in the base class:

@Override
public Cursor queryDocument(final String documentId, final String[] projection) {
    Timber.d( "Lifecycle: queryDocument called for: %s", documentId);

    // Create a cursor with either the requested fields, or the default projection if "projection" is null.
    // Return a cursor with a getExtras() method, to avoid the immutable ArrayMap problem.
    final MatrixCursor cursor = new MatrixCursor(projection != null ? projection : getDefaultDocumentProjection()){
        Bundle cursorExtras = new Bundle();
        @Override
        public Bundle getExtras() {
            return cursorExtras;

        }
    };
    addRowToQueryDocumentCursor(cursor, documentId);
    return cursor;
}

And addRowToQueryDocumentCursor() in DropboxProvider:

protected void addRowToQueryDocumentCursor(MatrixCursor cursor,
                                           String documentId) {

    try {
        SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(Objects.requireNonNull(getContext()).getApplicationContext());
        String displayname = sharedPrefs.getString(getContext().getString(R.string.pref_dropbox_displayname_token_key),
                getContext().getString(R.string.pref_dropbox_displayname_token_default));
        if ( !InTouchUtils.initDropboxClient()) {
            return;
        }

        if ( documentId.equals(<YOUR_UNIQUE_ROOTS_ID_HERE>)) {
            // root Dir
            Timber.d( "addRowToQueryDocumentCursor called for the root");
            final MatrixCursor.RowBuilder row = cursor.newRow();
            row.add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, <YOUR_UNIQUE_FOLDER_ID_HERE>);
            row.add(DocumentsContract.Document.COLUMN_DISPLAY_NAME,
                    String.format(getContext().getString(R.string.dropbox_root_title),
                                  getContext().getString(R.string.app_name)));
            row.add(DocumentsContract.Document.COLUMN_SUMMARY,displayname+
                    getContext().getString(R.string.dropbox_root_summary));
            row.add(DocumentsContract.Document.COLUMN_ICON, R.drawable.folder_icon_dropbox);
            row.add(DocumentsContract.Document.COLUMN_MIME_TYPE, DocumentsContract.Document.MIME_TYPE_DIR);
            row.add(DocumentsContract.Document.COLUMN_FLAGS, 0);
            row.add(DocumentsContract.Document.COLUMN_SIZE, null);
            row.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, null);
            return;
        }
        Timber.d( "addRowToQueryDocumentCursor called for documentId: %s", documentId);
        DbxClientV2 mDbxClient = DropboxClientFactory.getClient();
        Metadata metadata = mDbxClient.files().getMetadata(documentId);

        if ( metadata instanceof FolderMetadata) {
            Timber.d( "Document was a folder");
            includeFolder(cursor, (FolderMetadata)metadata);
        } else {
            Timber.d( "Document was a file");
            includeFile(cursor, (FileMetadata) metadata);
        }
    } catch (Exception e ) {
        Timber.d( "Called addRowToQueryDocumentCursor got exception, message was: %s documentId was: %s.", e.getMessage(), documentId);
    }
}

Upvotes: 3

CommonsWare
CommonsWare

Reputation: 1006944

The documentation for implementing a DocumentsProvider is... limited. In particular, there is no documented guarantee of the order of calls. As such, a DocumentsProvider really should be implemented to make as few assumptions as possible about the order of those calls.

For example, I would not assume that queryRoots() is called first. It probably will be first, if the first use of the DocumentsProvider for this process happens to be the Storage Access Framework UI. However, given that clients can (with care) persist a document or document tree Uri, you might wind up being called with something else first in your process, if the first thing happens to be a client using a persisted Uri.

And, in your specific case, I would not assume that queryChildDocuments() occurs before or after queryDocument().

Upvotes: 1

Related Questions