יצירת ספק מסמכים בהתאמה אישית

אם אתם מפתחים אפליקציה שמספקת שירותי אחסון לקבצים (כמו שירות שמור בענן), אפשר להפוך את הקבצים לזמינים באמצעות Storage Access Framework (SAF) באמצעות כתיבת ספק מסמכים בהתאמה אישית. בדף הזה נסביר איך ליצור ספק מסמכים בהתאמה אישית.

למידע נוסף על אופן הפעולה של Storage Access Framework אפשר לעיין במאמר בנושא סקירה כללית על Storage Access Framework

מניפסט

כדי להטמיע ספק מסמכים מותאם אישית, צריך להוסיף את הפרטים הבאים לקובץ של האפליקציה מניפסט:

  • יעד ברמת 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. כברירת מחדל, הספק זמין לכולם. הוספת ההרשאה הזו מגבילה את הספק למערכת. ההגבלה הזו חשובה מטעמי אבטחה.
  • מסנן Intent שכולל את הפעולה android.content.action.DOCUMENTS_PROVIDER, כדי שהספק תופיע בבורר כשהמערכת תחפש ספקים.

הנה קטעים מתוך מניפסט לדוגמה שכולל ספק:

<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 ומטה

Intent מסוג ACTION_OPEN_DOCUMENT זמין רק במכשירים עם Android 4.4 ומעלה. כדי שהאפליקציה תתמוך ב-ACTION_GET_CONTENT כדי להתאים למכשירים שבהם פועלת מערכת Android 4.3 ומטה, עליך השבתה של מסנן Intent ACTION_GET_CONTENT ב- המניפסט עבור מכשירים שבהם פועלת מערכת Android 4.4 ומעלה. א' יש להביא בחשבון את ספק המסמך ואת ACTION_GET_CONTENT בלעדיים. אם אתם תומכים בשתיהן בו-זמנית, האפליקציה שלכם מופיעה פעמיים בממשק המשתמש של בורר המערכת, ומציעה שתי דרכים שונות לגשת של הנתונים השמורים שלכם. מצב כזה מבלבל את המשתמשים.

זו הדרך המומלצת להשבתת מסנן Intent אחד (ACTION_GET_CONTENT) לפי מכשירים מערכת הפעלה Android מגרסה 4.4 ומעלה:

  1. בקובץ המשאבים bool.xml בקטע res/values/, יש להוסיף השורה הזו:
    <bool name="atMostJellyBeanMR2">true</bool>
  2. בקובץ המשאבים bool.xml בקטע res/values-v19/, יש להוסיף השורה הזו:
    <bool name="atMostJellyBeanMR2">false</bool>
  3. הוספת פעילות כתובת אימייל חלופית כדי להשבית את ה-Intent ACTION_GET_CONTENT לסנן לפי גרסה 4.4 (רמת API 19) ואילך. מוצרים לדוגמה:
    <!-- 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 מספק לכם את הסוגים האלה של החוזה, כך שלא תצטרכו לכתוב owner:

לדוגמה, אלו העמודות שאפשר להחזיר בסמן ספק המסמך מבקש לקבל מסמכים או הרשאות לרמה הבסיסית (root):

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 מייצג את שדות ספציפיים שהמתקשר רוצה לחזור. קטע הקוד יוצר סמן חדש ומוסיפה לו שורה אחת – שורש אחד, ספרייה ברמה עליונה, הורדות או תמונות. לרוב הספקים יש רק הרמה הבסיסית (root) אחת. יכול להיות שיש לכם יותר מאישור אחד, לדוגמה, במקרה של מספר חשבונות משתמשים. במקרה הזה, פשוט צריך להוסיף את השורה השנייה לסמן.

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.

מתבצעת קריאה לשיטה הזו כשהמשתמש בוחר את הרמה הבסיסית (root) בממשק המשתמש של הבורר. השיטה מאחזרת את הצאצאים של מזהה המסמך שצוין על ידי COLUMN_DOCUMENT_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.AutoCloseOutputStream או ParcelFileDescriptor.AutoCloseInputStream.

תמיכה במסמכים מהזמן האחרון ובחיפוש

אפשר להוסיף רשימה של מסמכים ששונו לאחרונה ברמה הבסיסית (root) של של ספק המסמכים באמצעות שינוי אמצעי תשלום אחד (queryRecentDocuments()) וחוזרים FLAG_SUPPORTS_RECENTS, קטע הקוד הבא מדגים איך להטמיע את התג queryRecentDocuments() methods.

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, ספק המסמכים יכול לאפשר לאפליקציית הלקוח ליצור מסמכים חדשים אצל ספק המסמך.

כדי לתמוך ביצירת מסמך, הרמה הבסיסית (root) צריכה להיות סימון FLAG_SUPPORTS_CREATE. ספריות שמאפשרות ליצור קבצים חדשים בהן צריכות להיות FLAG_DIR_SUPPORTS_CREATE לסמן.

ספק המסמכים צריך גם להטמיע את אמצעי תשלום אחד (createDocument()). כאשר משתמש בוחר ספרייה בתוך ספק המסמך כדי לשמור קובץ חדש, ספק המסמך מקבל קריאה createDocument() בתוך היישום של אמצעי תשלום אחד (createDocument()), החזרת מוצר חדש COLUMN_DOCUMENT_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) כדי לציין את הפונקציונליות הנתמכת. צריך גם להטמיע ה-method המתאים של 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 24), מאפשרת לספקי מסמכים כדי לספק גישת צפייה לקבצים שאין להם ייצוג בייטקוד ישיר. כדי לאפשר לאפליקציות אחרות להציג קבצים וירטואליים: ספק המסמך צריך להפיק קובץ חלופי שניתן לפתוח של הקבצים הווירטואליים.

לדוגמה, נניח שספק מסמך מכיל קובץ פורמט שאפליקציות אחרות לא יכולות לפתוח ישירות, למעשה קובץ וירטואלי. כשאפליקציית לקוח שולחת Intent מסוג ACTION_VIEW בלי הקטגוריה CATEGORY_OPENABLE, המשתמשים יוכלו לבחור בקבצים הווירטואליים האלה בתוך ספק המסמך לצפייה. לאחר מכן ספק המסמך מחזיר את הקובץ הווירטואלי בפורמט קובץ שונה אך פתוח, כמו תמונה. לאחר מכן אפליקציית הלקוח יכולה לפתוח את הקובץ הווירטואלי ולהציג אותו למשתמש.

כדי להצהיר שמסמך בספק הוא וירטואלי, צריך להוסיף את FLAG_VIRTUAL_DOCUMENT בקובץ המוחזר על ידי queryDocument() . הסימון הזה מתריע לאפליקציות לקוח על כך שלקובץ אין מזהה ישיר ייצוג בייטקוד (bytecode) ולא ניתן לפתוח אותו באופן ישיר.

אם מצהירים שקובץ המסמך בספק המסמכים הוא וירטואלי, מומלץ מאוד להפוך אותו לזמין סוג MIME, כמו תמונה או קובץ PDF. ספק המסמך הצהרה על סוגי MIME החלופיים תומך בהצגת קובץ וירטואלי באמצעות עקיפת getDocumentStreamTypes() . כשאפליקציות לקוח מבצעות קריאה getStreamTypes(android.net.Uri, java.lang.String) ל-method, המערכת קוראת 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);
}

לקוד לדוגמה שקשור לדף הזה, אפשר לעיין ב:

לסרטונים שקשורים לדף הזה:

מידע נוסף בנושא זמין במאמרים הבאים: