创建自定义文档提供程序

如果您正在开发一个为文件提供存储服务(例如云存储服务)的应用,可以通过编写自定义文档提供程序,让您的文件通过存储访问框架 (SAF) 访问。本页面介绍了如何创建自定义文档提供程序。

如需详细了解存储访问框架的工作原理,请参阅存储访问框架概览

Manifest

要实现自定义文档提供程序,请将以下内容添加到应用的 manifest 中:

  • 目标 API 级别 19 或更高。
  • 声明您的自定义存储提供程序的 <provider> 元素。
  • 将属性 android:name 设置为 DocumentsProvider 子类的名称,即其类名(包括包名)。

    com.example.android.storageprovider.MyCloudProvider.

  • 属性 android:authority,它是您的包名(在此示例中为 com.example.android.storageprovider)加上内容提供程序的类型(documents)。
  • 将属性 android:exported 设置为 "true"。必须导出提供程序,以便其他应用可以看到它。
  • 将属性 android:grantUriPermissions 设置为 "true"。此设置允许系统授予其他应用访问提供程序内容的权限。如需了解这些其他应用如何持久保留对提供程序内容的访问权限,请参阅持久保留权限
  • MANAGE_DOCUMENTS 权限。默认情况下,提供程序对所有人可用。添加此权限可将您的提供程序限制为系统。此限制对于安全性非常重要。
  • 包含 android.content.action.DOCUMENTS_PROVIDER 操作的 Intent 过滤器,以便在系统搜索提供程序时,您的提供程序会显示在选择器中。

以下是包含提供程序的示例 manifest 摘录:

<manifest... >
    ...
    <uses-sdk
        android:minSdkVersion="19"
        android:targetSdkVersion="19" />
        ....
        <provider
            android:name="com.example.android.storageprovider.MyCloudProvider"
            android:authorities="com.example.android.storageprovider.documents"
            android:grantUriPermissions="true"
            android:exported="true"
            android:permission="android.permission.MANAGE_DOCUMENTS">
            <intent-filter>
                <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
            </intent-filter>
        </provider>
    </application>

</manifest>

支持运行 Android 4.3 及更低版本的设备

ACTION_OPEN_DOCUMENT Intent 仅适用于运行 Android 4.4 及更高版本的设备。如果您的应用想要支持 ACTION_GET_CONTENT 以兼容运行 Android 4.3 及更低版本的设备,应在 manifest 中为运行 Android 4.4 或更高版本的设备禁用 ACTION_GET_CONTENT Intent 过滤器。文档提供程序和 ACTION_GET_CONTENT 应视为互斥。如果您同时支持两者,您的应用会在系统选择器界面中出现两次,提供两种不同的数据访问方式,这会让用户感到困惑。

以下是为运行 Android 4.4 或更高版本的设备禁用 ACTION_GET_CONTENT Intent 过滤器的推荐方法:

  1. res/values/ 下的 bool.xml 资源文件中,添加此行:
    <bool name="atMostJellyBeanMR2">true</bool>
  2. res/values-v19/ 下的 bool.xml 资源文件中,添加此行:
    <bool name="atMostJellyBeanMR2">false</bool>
  3. 添加一个activity alias 来禁用版本 4.4 (API level 19) 及更高版本的 ACTION_GET_CONTENT Intent 过滤器。例如:
    <!-- This activity alias is added so that GET_CONTENT intent-filter
         can be disabled for builds on API level 19 and higher. -->
    <activity-alias android:name="com.android.example.app.MyPicker"
            android:targetActivity="com.android.example.app.MyActivity"
            ...
            android:enabled="@bool/atMostJellyBeanMR2">
        <intent-filter>
            <action android:name="android.intent.action.GET_CONTENT" />
            <category android:name="android.intent.category.OPENABLE" />
            <category android:name="android.intent.category.DEFAULT" />
            <data android:mimeType="image/*" />
            <data android:mimeType="video/*" />
        </intent-filter>
    </activity-alias>

合约

通常,在编写自定义内容提供程序时,任务之一是实现合约类,如内容提供程序开发者指南中所述。合约类是一个 public final 类,其中包含与提供程序相关的 URI、列名、MIME 类型及其他元数据的常量定义。SAF 为您提供了这些合约类,因此您无需自行编写:

例如,当查询文档提供程序以获取文档或根目录时,您可能在游标中返回以下列:

Kotlin

private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
        DocumentsContract.Root.COLUMN_ROOT_ID,
        DocumentsContract.Root.COLUMN_MIME_TYPES,
        DocumentsContract.Root.COLUMN_FLAGS,
        DocumentsContract.Root.COLUMN_ICON,
        DocumentsContract.Root.COLUMN_TITLE,
        DocumentsContract.Root.COLUMN_SUMMARY,
        DocumentsContract.Root.COLUMN_DOCUMENT_ID,
        DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
)
private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf(
        DocumentsContract.Document.COLUMN_DOCUMENT_ID,
        DocumentsContract.Document.COLUMN_MIME_TYPE,
        DocumentsContract.Document.COLUMN_DISPLAY_NAME,
        DocumentsContract.Document.COLUMN_LAST_MODIFIED,
        DocumentsContract.Document.COLUMN_FLAGS,
        DocumentsContract.Document.COLUMN_SIZE
)

Java

private static final String[] DEFAULT_ROOT_PROJECTION =
        new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_MIME_TYPES,
        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,};

您的根目录游标需要包含一些必需的列。这些列是:

文档游标需要包含以下必需的列:

创建 DocumentsProvider 的子类

编写自定义文档提供程序的下一步是继承抽象类 DocumentsProvider 并创建子类。您至少必须实现以下方法:

这些是您严格必须实现的方法,但您可能还需要实现更多其他方法。请参阅 DocumentsProvider 了解详细信息。

定义根目录

您实现的 queryRoots() 需要返回一个 Cursor,指向文档提供程序的所有根目录,使用 DocumentsContract.Root 中定义的列。

在以下代码段中,projection 参数表示调用方想要获取的特定字段。代码段创建了一个新的游标,并向其中添加了一行 - 一个根目录,即顶层目录,例如 Downloads 或 Images。大多数提供程序只有一个根目录。您可能有多个根目录,例如在有多个用户账号的情况下。在这种情况下,只需向游标添加第二行即可。

Kotlin

override fun queryRoots(projection: Array<out String>?): Cursor {
    // Use a MatrixCursor to build a cursor
    // with either the requested fields, or the default
    // projection if "projection" is null.
    val result = MatrixCursor(resolveRootProjection(projection))

    // If user is not logged in, return an empty root cursor.  This removes our
    // provider from the list entirely.
    if (!isUserLoggedIn()) {
        return result
    }

    // It's possible to have multiple roots (e.g. for multiple accounts in the
    // same app) -- just add multiple cursor rows.
    result.newRow().apply {
        add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT)

        // You can provide an optional summary, which helps distinguish roots
        // with the same title. You can also use this field for displaying an
        // user account name.
        add(DocumentsContract.Root.COLUMN_SUMMARY, context.getString(R.string.root_summary))

        // FLAG_SUPPORTS_CREATE means at least one directory under the root supports
        // creating documents. FLAG_SUPPORTS_RECENTS means your application's most
        // recently used documents will show up in the "Recents" category.
        // FLAG_SUPPORTS_SEARCH allows users to search all documents the application
        // shares.
        add(
            DocumentsContract.Root.COLUMN_FLAGS,
            DocumentsContract.Root.FLAG_SUPPORTS_CREATE or
                DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
                DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
        )

        // COLUMN_TITLE is the root title (e.g. Gallery, Drive).
        add(DocumentsContract.Root.COLUMN_TITLE, context.getString(R.string.title))

        // This document id cannot change after it's shared.
        add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocIdForFile(baseDir))

        // The child MIME types are used to filter the roots and only present to the
        // user those roots that contain the desired type somewhere in their file hierarchy.
        add(DocumentsContract.Root.COLUMN_MIME_TYPES, getChildMimeTypes(baseDir))
        add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDir.freeSpace)
        add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_launcher)
    }

    return result
}

Java

@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {

    // Use a MatrixCursor to build a cursor
    // with either the requested fields, or the default
    // projection if "projection" is null.
    final MatrixCursor result =
            new MatrixCursor(resolveRootProjection(projection));

    // If user is not logged in, return an empty root cursor.  This removes our
    // provider from the list entirely.
    if (!isUserLoggedIn()) {
        return result;
    }

    // It's possible to have multiple roots (e.g. for multiple accounts in the
    // same app) -- just add multiple cursor rows.
    final MatrixCursor.RowBuilder row = result.newRow();
    row.add(Root.COLUMN_ROOT_ID, ROOT);

    // You can provide an optional summary, which helps distinguish roots
    // with the same title. You can also use this field for displaying an
    // user account name.
    row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.root_summary));

    // FLAG_SUPPORTS_CREATE means at least one directory under the root supports
    // creating documents. FLAG_SUPPORTS_RECENTS means your application's most
    // recently used documents will show up in the "Recents" category.
    // FLAG_SUPPORTS_SEARCH allows users to search all documents the application
    // shares.
    row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE |
            Root.FLAG_SUPPORTS_RECENTS |
            Root.FLAG_SUPPORTS_SEARCH);

    // COLUMN_TITLE is the root title (e.g. Gallery, Drive).
    row.add(Root.COLUMN_TITLE, getContext().getString(R.string.title));

    // This document id cannot change after it's shared.
    row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(baseDir));

    // The child MIME types are used to filter the roots and only present to the
    // user those roots that contain the desired type somewhere in their file hierarchy.
    row.add(Root.COLUMN_MIME_TYPES, getChildMimeTypes(baseDir));
    row.add(Root.COLUMN_AVAILABLE_BYTES, baseDir.getFreeSpace());
    row.add(Root.COLUMN_ICON, R.drawable.ic_launcher);

    return result;
}

如果您的文档提供程序连接到一组动态变化的根目录(例如,可能断开连接的 USB 设备或用户可以从中退出的账号),您可以使用 ContentResolver.notifyChange() 方法更新文档界面,使其与这些变化保持同步,如以下代码段所示。

Kotlin

val rootsUri: Uri = DocumentsContract.buildRootsUri(BuildConfig.DOCUMENTS_AUTHORITY)
context.contentResolver.notifyChange(rootsUri, null)

Java

Uri rootsUri = DocumentsContract.buildRootsUri(BuildConfig.DOCUMENTS_AUTHORITY);
context.getContentResolver().notifyChange(rootsUri, null);

列出提供程序中的文档

您实现的 queryChildDocuments() 必须返回一个 Cursor,指向指定目录中的所有文件,使用 DocumentsContract.Document 中定义的列。

当用户在选择器界面中选择您的根目录时,会调用此方法。该方法会检索由 COLUMN_DOCUMENT_ID 指定的文档 ID 的子项。然后,每当用户在您的文档提供程序中选择一个子目录时,系统都会调用此方法。

此代码段使用请求的列创建一个新的游标,然后将父目录中每个直接子项的信息添加到游标中。子项可以是图片、另一个目录或任何文件。

Kotlin

override fun queryChildDocuments(
        parentDocumentId: String?,
        projection: Array<out String>?,
        sortOrder: String?
): Cursor {
    return MatrixCursor(resolveDocumentProjection(projection)).apply {
        val parent: File = getFileForDocId(parentDocumentId)
        parent.listFiles()
                .forEach { file ->
                    includeFile(this, null, file)
                }
    }
}

Java

@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection,
                              String sortOrder) throws FileNotFoundException {

    final MatrixCursor result = new
            MatrixCursor(resolveDocumentProjection(projection));
    final File parent = getFileForDocId(parentDocumentId);
    for (File file : parent.listFiles()) {
        // Adds the file's display name, MIME type, size, and so on.
        includeFile(result, null, file);
    }
    return result;
}

获取文档信息

您实现的 queryDocument() 必须返回一个 Cursor,指向指定文件,使用 DocumentsContract.Document 中定义的列。

queryDocument() 方法返回与 queryChildDocuments() 中传递的信息相同,但针对特定文件。

Kotlin

override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor {
    // Create a cursor with the requested projection, or the default projection.
    return MatrixCursor(resolveDocumentProjection(projection)).apply {
        includeFile(this, documentId, null)
    }
}

Java

@Override
public Cursor queryDocument(String documentId, String[] projection) throws
        FileNotFoundException {

    // Create a cursor with the requested projection, or the default projection.
    final MatrixCursor result = new
            MatrixCursor(resolveDocumentProjection(projection));
    includeFile(result, documentId, null);
    return result;
}

您的文档提供程序还可以通过替换 DocumentsProvider.openDocumentThumbnail() 方法并为支持的文件添加 FLAG_SUPPORTS_THUMBNAIL 标志来为文档提供缩略图。以下代码段提供了一个如何实现 DocumentsProvider.openDocumentThumbnail() 的示例。

Kotlin

override fun openDocumentThumbnail(
        documentId: String?,
        sizeHint: Point?,
        signal: CancellationSignal?
): AssetFileDescriptor {
    val file = getThumbnailFileForDocId(documentId)
    val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
    return AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH)
}

Java

@Override
public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint,
                                                     CancellationSignal signal)
        throws FileNotFoundException {

    final File file = getThumbnailFileForDocId(documentId);
    final ParcelFileDescriptor pfd =
        ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
    return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH);
}

注意:文档提供程序返回的缩略图大小不应超过 sizeHint 参数指定大小的两倍。

打开文档

您必须实现 openDocument() 以返回表示指定文件的 ParcelFileDescriptor。其他应用可以使用返回的 ParcelFileDescriptor 流式传输数据。系统在用户选择文件后调用此方法,客户端应用通过调用 openFileDescriptor() 请求访问该文件。例如:

Kotlin

override fun openDocument(
        documentId: String,
        mode: String,
        signal: CancellationSignal
): ParcelFileDescriptor {
    Log.v(TAG, "openDocument, mode: $mode")
    // It's OK to do network operations in this method to download the document,
    // as long as you periodically check the CancellationSignal. If you have an
    // extremely large file to transfer from the network, a better solution may
    // be pipes or sockets (see ParcelFileDescriptor for helper methods).

    val file: File = getFileForDocId(documentId)
    val accessMode: Int = ParcelFileDescriptor.parseMode(mode)

    val isWrite: Boolean = mode.contains("w")
    return if (isWrite) {
        val handler = Handler(context.mainLooper)
        // Attach a close listener if the document is opened in write mode.
        try {
            ParcelFileDescriptor.open(file, accessMode, handler) {
                // Update the file with the cloud server. The client is done writing.
                Log.i(TAG, "A file with id $documentId has been closed! Time to update the server.")
            }
        } catch (e: IOException) {
            throw FileNotFoundException(
                    "Failed to open document with id $documentId and mode $mode"
            )
        }
    } else {
        ParcelFileDescriptor.open(file, accessMode)
    }
}

Java

@Override
public ParcelFileDescriptor openDocument(final String documentId,
                                         final String mode,
                                         CancellationSignal signal) throws
        FileNotFoundException {
    Log.v(TAG, "openDocument, mode: " + mode);
    // It's OK to do network operations in this method to download the document,
    // as long as you periodically check the CancellationSignal. If you have an
    // extremely large file to transfer from the network, a better solution may
    // be pipes or sockets (see ParcelFileDescriptor for helper methods).

    final File file = getFileForDocId(documentId);
    final int accessMode = ParcelFileDescriptor.parseMode(mode);

    final boolean isWrite = (mode.indexOf('w') != -1);
    if(isWrite) {
        // Attach a close listener if the document is opened in write mode.
        try {
            Handler handler = new Handler(getContext().getMainLooper());
            return ParcelFileDescriptor.open(file, accessMode, handler,
                        new ParcelFileDescriptor.OnCloseListener() {
                @Override
                public void onClose(IOException e) {

                    // Update the file with the cloud server. The client is done
                    // writing.
                    Log.i(TAG, "A file with id " +
                    documentId + " has been closed! Time to " +
                    "update the server.");
                }

            });
        } catch (IOException e) {
            throw new FileNotFoundException("Failed to open document with id"
            + documentId + " and mode " + mode);
        }
    } else {
        return ParcelFileDescriptor.open(file, accessMode);
    }
}

如果您的文档提供程序流式传输文件或处理复杂的数据结构,请考虑实现 createReliablePipe()createReliableSocketPair() 方法。这些方法允许您创建一对 ParcelFileDescriptor 对象,您可以返回其中一个,并通过 ParcelFileDescriptor.AutoCloseOutputStreamParcelFileDescriptor.AutoCloseInputStream 发送另一个。

支持最近的文档和搜索

您可以通过替换 queryRecentDocuments() 方法并返回 FLAG_SUPPORTS_RECENTS 来在文档提供程序的根目录下提供最近修改的文档列表。以下代码段展示了如何实现 queryRecentDocuments() 方法的示例。

Kotlin

override fun queryRecentDocuments(rootId: String?, projection: Array<out String>?): Cursor {
    // This example implementation walks a
    // local file structure to find the most recently
    // modified files.  Other implementations might
    // include making a network call to query a
    // server.

    // Create a cursor with the requested projection, or the default projection.
    val result = MatrixCursor(resolveDocumentProjection(projection))

    val parent: File = getFileForDocId(rootId)

    // Create a queue to store the most recent documents,
    // which orders by last modified.
    val lastModifiedFiles = PriorityQueue(
            5,
            Comparator<File> { i, j ->
                Long.compare(i.lastModified(), j.lastModified())
            }
    )

    // Iterate through all files and directories
    // in the file structure under the root.  If
    // the file is more recent than the least
    // recently modified, add it to the queue,
    // limiting the number of results.
    val pending : MutableList<File> = mutableListOf()

    // Start by adding the parent to the list of files to be processed
    pending.add(parent)

    // Do while we still have unexamined files
    while (pending.isNotEmpty()) {
        // Take a file from the list of unprocessed files
        val file: File = pending.removeAt(0)
        if (file.isDirectory) {
            // If it's a directory, add all its children to the unprocessed list
            pending += file.listFiles()
        } else {
            // If it's a file, add it to the ordered queue.
            lastModifiedFiles.add(file)
        }
    }

    // Add the most recent files to the cursor,
    // not exceeding the max number of results.
    for (i in 0 until Math.min(MAX_LAST_MODIFIED + 1, lastModifiedFiles.size)) {
        val file: File = lastModifiedFiles.remove()
        includeFile(result, null, file)
    }
    return result
}

Java

@Override
public Cursor queryRecentDocuments(String rootId, String[] projection)
        throws FileNotFoundException {

    // This example implementation walks a
    // local file structure to find the most recently
    // modified files.  Other implementations might
    // include making a network call to query a
    // server.

    // Create a cursor with the requested projection, or the default projection.
    final MatrixCursor result =
        new MatrixCursor(resolveDocumentProjection(projection));

    final File parent = getFileForDocId(rootId);

    // Create a queue to store the most recent documents,
    // which orders by last modified.
    PriorityQueue lastModifiedFiles =
        new PriorityQueue(5, new Comparator() {

        public int compare(File i, File j) {
            return Long.compare(i.lastModified(), j.lastModified());
        }
    });

    // Iterate through all files and directories
    // in the file structure under the root.  If
    // the file is more recent than the least
    // recently modified, add it to the queue,
    // limiting the number of results.
    final LinkedList pending = new LinkedList();

    // Start by adding the parent to the list of files to be processed
    pending.add(parent);

    // Do while we still have unexamined files
    while (!pending.isEmpty()) {
        // Take a file from the list of unprocessed files
        final File file = pending.removeFirst();
        if (file.isDirectory()) {
            // If it's a directory, add all its children to the unprocessed list
            Collections.addAll(pending, file.listFiles());
        } else {
            // If it's a file, add it to the ordered queue.
            lastModifiedFiles.add(file);
        }
    }

    // Add the most recent files to the cursor,
    // not exceeding the max number of results.
    for (int i = 0; i < Math.min(MAX_LAST_MODIFIED + 1, lastModifiedFiles.size()); i++) {
        final File file = lastModifiedFiles.remove();
        includeFile(result, null, file);
    }
    return result;
}

您可以下载 StorageProvider 代码示例,获取上述代码段的完整代码。

支持文档创建

您可以允许客户端应用在您的文档提供程序内创建文件。如果客户端应用发送 ACTION_CREATE_DOCUMENT Intent,您的文档提供程序可以允许该客户端应用在文档提供程序内创建新文档。

为了支持文档创建,您的根目录需要具有 FLAG_SUPPORTS_CREATE 标志。允许在其内创建新文件的目录需要具有 FLAG_DIR_SUPPORTS_CREATE 标志。

您的文档提供程序还需要实现 createDocument() 方法。当用户在您的文档提供程序内选择目录以保存新文件时,文档提供程序会收到对 createDocument() 的调用。在 createDocument() 方法的实现中,您需要为文件返回新的 COLUMN_DOCUMENT_ID。然后,客户端应用可以使用该 ID 获取文件的句柄,并最终调用 openDocument() 写入新文件。

以下代码段演示了如何在文档提供程序内创建新文件。

Kotlin

override fun createDocument(documentId: String?, mimeType: String?, displayName: String?): String {
    val parent: File = getFileForDocId(documentId)
    val file: File = try {
        File(parent.path, displayName).apply {
            createNewFile()
            setWritable(true)
            setReadable(true)
        }
    } catch (e: IOException) {
        throw FileNotFoundException(
                "Failed to create document with name $displayName and documentId $documentId"
        )
    }

    return getDocIdForFile(file)
}

Java

@Override
public String createDocument(String documentId, String mimeType, String displayName)
        throws FileNotFoundException {

    File parent = getFileForDocId(documentId);
    File file = new File(parent.getPath(), displayName);
    try {
        file.createNewFile();
        file.setWritable(true);
        file.setReadable(true);
    } catch (IOException e) {
        throw new FileNotFoundException("Failed to create document with name " +
                displayName +" and documentId " + documentId);
    }
    return getDocIdForFile(file);
}

您可以下载 StorageProvider 代码示例,获取上述代码段的完整代码。

支持文档管理功能

除了打开、创建和查看文件之外,您的文档提供程序还可以允许客户端应用重命名、复制、移动和删除文件。要向文档提供程序添加文档管理功能,请向文档的 COLUMN_FLAGS 列添加一个标志,以指示支持的功能。您还需要实现 DocumentsProvider 类的相应方法。

下表列出了文档提供程序为公开特定功能需要实现的 COLUMN_FLAGS 标志和 DocumentsProvider 方法。

功能 标志 方法
删除文件 FLAG_SUPPORTS_DELETE deleteDocument()
重命名文件 FLAG_SUPPORTS_RENAME renameDocument()
将文件复制到文档提供程序中的新父目录 FLAG_SUPPORTS_COPY copyDocument()
在文档提供程序中将文件从一个目录移动到另一个目录 FLAG_SUPPORTS_MOVE moveDocument()
从父目录中移除文件 FLAG_SUPPORTS_REMOVE removeDocument()

支持虚拟文件和备用文件格式

虚拟文件是 Android 7.0 (API level 24) 中引入的一项功能,允许文档提供程序提供对没有直接字节码表示的文件的查看访问。为了让其他应用查看虚拟文件,您的文档提供程序需要为虚拟文件生成可打开的备用文件表示形式。

例如,假设文档提供程序包含其他应用无法直接打开的文件格式,本质上是一个虚拟文件。当客户端应用发送不带 ACTION_VIEW 类别的 CATEGORY_OPENABLE Intent 时,用户可以在文档提供程序中选择这些虚拟文件进行查看。然后,文档提供程序会以不同的(但可打开的)文件格式(例如图像)返回虚拟文件。客户端应用随后可以打开虚拟文件供用户查看。

要声明提供程序中的文档是虚拟的,您需要将 FLAG_VIRTUAL_DOCUMENT 标志添加到 queryDocument() 方法返回的文件中。此标志会提醒客户端应用该文件没有直接的字节码表示,无法直接打开。

如果您声明文档提供程序中的文件是虚拟文件,则强烈建议您以另一种 MIME 类型(例如图像或 PDF)提供该文件。文档提供程序通过替换 getDocumentStreamTypes() 方法来声明其支持用于查看虚拟文件的备用 MIME 类型。当客户端应用调用 getStreamTypes(android.net.Uri, java.lang.String) 方法时,系统会调用文档提供程序的 getDocumentStreamTypes() 方法。getDocumentStreamTypes() 方法随后返回文档提供程序支持用于该文件的备用 MIME 类型数组。

客户端确定文档提供程序可以以可查看的文件格式生成文档后,客户端应用会调用 openTypedAssetFileDescriptor() 方法,该方法内部会调用文档提供程序的 openTypedDocument() 方法。文档提供程序会以请求的文件格式将文件返回给客户端应用。

以下代码段演示了 getDocumentStreamTypes()openTypedDocument() 方法的简单实现。

Kotlin

var SUPPORTED_MIME_TYPES : Array<String> = arrayOf("image/png", "image/jpg")
override fun openTypedDocument(
        documentId: String?,
        mimeTypeFilter: String,
        opts: Bundle?,
        signal: CancellationSignal?
): AssetFileDescriptor? {
    return try {
        // Determine which supported MIME type the client app requested.
        when(mimeTypeFilter) {
            "image/jpg" -> openJpgDocument(documentId)
            "image/png", "image/*", "*/*" -> openPngDocument(documentId)
            else -> throw IllegalArgumentException("Invalid mimeTypeFilter $mimeTypeFilter")
        }
    } catch (ex: Exception) {
        Log.e(TAG, ex.message)
        null
    }
}

override fun getDocumentStreamTypes(documentId: String, mimeTypeFilter: String): Array<String> {
    return when (mimeTypeFilter) {
        "*/*", "image/*" -> {
            // Return all supported MIME types if the client app
            // passes in '*/*' or 'image/*'.
            SUPPORTED_MIME_TYPES
        }
        else -> {
            // Filter the list of supported mime types to find a match.
            SUPPORTED_MIME_TYPES.filter { it == mimeTypeFilter }.toTypedArray()
        }
    }
}

Java

public static String[] SUPPORTED_MIME_TYPES = {"image/png", "image/jpg"};

@Override
public AssetFileDescriptor openTypedDocument(String documentId,
    String mimeTypeFilter,
    Bundle opts,
    CancellationSignal signal) {

    try {

        // Determine which supported MIME type the client app requested.
        if ("image/png".equals(mimeTypeFilter) ||
            "image/*".equals(mimeTypeFilter) ||
            "*/*".equals(mimeTypeFilter)) {

            // Return the file in the specified format.
            return openPngDocument(documentId);

        } else if ("image/jpg".equals(mimeTypeFilter)) {
            return openJpgDocument(documentId);
        } else {
            throw new IllegalArgumentException("Invalid mimeTypeFilter " + mimeTypeFilter);
        }

    } catch (Exception ex) {
        Log.e(TAG, ex.getMessage());
    } finally {
        return null;
    }
}

@Override
public String[] getDocumentStreamTypes(String documentId, String mimeTypeFilter) {

    // Return all supported MIME tyupes if the client app
    // passes in '*/*' or 'image/*'.
    if ("*/*".equals(mimeTypeFilter) ||
        "image/*".equals(mimeTypeFilter)) {
        return SUPPORTED_MIME_TYPES;
    }

    ArrayList requestedMimeTypes = new ArrayList&lt;&gt;();

    // Iterate over the list of supported mime types to find a match.
    for (int i=0; i &lt; SUPPORTED_MIME_TYPES.length; i++) {
        if (SUPPORTED_MIME_TYPES[i].equals(mimeTypeFilter)) {
            requestedMimeTypes.add(SUPPORTED_MIME_TYPES[i]);
        }
    }
    return (String[])requestedMimeTypes.toArray();
}

安全

假设您的文档提供程序是一个密码保护的云存储服务,并且您想确保用户在开始共享文件之前已登录。如果用户未登录,您的应用应该怎么做?解决方案是在实现 queryRoots() 时返回零个根目录。也就是说,返回一个空的根目录游标。

Kotlin

override fun queryRoots(projection: Array<out String>): Cursor {
...
    // If user is not logged in, return an empty root cursor.  This removes our
    // provider from the list entirely.
    if (!isUserLoggedIn()) {
        return result
    }

Java

public Cursor queryRoots(String[] projection) throws FileNotFoundException {
...
    // If user is not logged in, return an empty root cursor.  This removes our
    // provider from the list entirely.
    if (!isUserLoggedIn()) {
        return result;
}

另一个步骤是调用 getContentResolver().notifyChange()。还记得 DocumentsContract 吗?我们正在使用它来构建此 URI。以下代码段告诉系统在用户登录状态更改时查询您的文档提供程序的根目录。如果用户未登录,对 queryRoots() 的调用将返回一个空游标,如上所示。这确保了提供程序的文档仅在用户登录到提供程序后才可用。

Kotlin

private fun onLoginButtonClick() {
    loginOrLogout()
    getContentResolver().notifyChange(
        DocumentsContract.buildRootsUri(AUTHORITY),
        null
    )
}

Java

private void onLoginButtonClick() {
    loginOrLogout();
    getContentResolver().notifyChange(DocumentsContract
            .buildRootsUri(AUTHORITY), null);
}

有关本页相关的示例代码,请参考:

有关本页相关的视频,请参考:

有关更多相关信息,请参考: