맞춤 문서 제공자 만들기

파일의 저장소 서비스 (예: 클라우드 저장 서비스)를 제공하는 앱을 개발하는 경우 맞춤 문서 제공자를 작성하여 저장소 액세스 프레임워크 (SAF)를 통해 파일을 사용할 수 있도록 할 수 있습니다. 이 페이지에서는 맞춤 문서 제공자를 만드는 방법에 관해 설명합니다.

저장소 액세스 프레임워크의 작동 방식에 관한 자세한 내용은 저장소 액세스 프레임워크 개요를 참고하세요.

Manifest

맞춤 문서 제공자를 구현하려면 애플리케이션의 매니페스트에 다음을 추가하세요.

  • API 레벨 19 이상인 타겟.
  • 맞춤 저장소 제공업체를 선언하는 <provider> 요소.
  • 패키지 이름을 포함한 클래스 이름인 DocumentsProvider 서브클래스의 이름으로 설정된 속성 android:name:

    com.example.android.storageprovider.MyCloudProvider

  • 패키지 이름 (이 예에서는 com.example.android.storageprovider)과 콘텐츠 제공자 유형(documents)이 합쳐진 android:authority 속성입니다.
  • "true"로 설정한 android:exported 속성. 다른 앱에서 볼 수 있도록 제공업체를 내보내야 합니다.
  • "true"로 설정된 android:grantUriPermissions 속성. 이 설정을 통해 시스템은 다른 앱에 제공자의 콘텐츠 액세스 권한을 부여할 수 있습니다. 이러한 다른 앱이 제공자의 콘텐츠에 대한 액세스를 유지하는 방법에 관한 자세한 내용은 권한 유지를 참고하세요.
  • MANAGE_DOCUMENTS 권한. 기본적으로 공급자는 모든 사용자가 사용할 수 있습니다. 이 권한을 추가하면 제공자가 시스템으로 제한됩니다. 이 제한은 보안에 중요합니다.
  • 시스템이 제공자를 검색할 때 제공자가 선택 도구에 표시되도록 android.content.action.DOCUMENTS_PROVIDER 작업을 포함하는 인텐트 필터.

다음은 제공자가 포함된 샘플 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 인텐트는 Android 4.4 이상을 실행하는 기기에서만 사용할 수 있습니다. 애플리케이션에서 ACTION_GET_CONTENT을 지원하여 Android 4.3 이하를 실행하는 기기를 수용하려면 Android 4.4 이상을 실행하는 기기의 매니페스트에서 ACTION_GET_CONTENT 인텐트 필터를 사용 중지해야 합니다. 문서 제공자와 ACTION_GET_CONTENT는 상호 배타적인 것으로 간주해야 합니다. 두 가지를 모두 동시에 지원하면 앱이 시스템 선택 도구 UI에 두 번 표시되어 저장된 데이터에 액세스하는 두 가지 방법을 제공합니다. 이는 사용자에게 혼동을 줍니다.

다음은 Android 버전 4.4 이상을 실행하는 기기에서 ACTION_GET_CONTENT 인텐트 필터를 사용 중지할 때 권장되는 방법입니다.

  1. bool.xml 리소스 파일의 res/values/ 아래에 다음 줄을 추가합니다.
    <bool name="atMostJellyBeanMR2">true</bool>
  2. bool.xml 리소스 파일의 res/values-v19/ 아래에 다음 줄을 추가합니다.
    <bool name="atMostJellyBeanMR2">false</bool>
  3. 활동 별칭을 추가하여 버전 4.4 (API 수준 19) 이상에서 ACTION_GET_CONTENT 인텐트 필터를 사용 중지합니다. 예:
    <!-- 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>
    

계약

일반적으로 맞춤 콘텐츠 제공자를 작성할 때 작업 중 하나는 콘텐츠 제공자 개발자 가이드에 설명된 대로 계약 클래스를 구현하는 것입니다. 계약 클래스는 URI, 열 이름, MIME 유형 및 제공자와 관련된 기타 메타데이터의 상수 정의를 포함하는 public final 클래스입니다. 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()의 구현은 DocumentsContract.Root에 정의된 열을 사용하여 문서 제공자의 모든 루트 디렉터리를 가리키는 Cursor를 반환해야 합니다.

다음 스니펫에서 projection 매개변수는 호출자가 가져오려는 특정 필드를 나타냅니다. 스니펫은 새 커서를 만들고 행 한 개(루트 하나, 다운로드 또는 이미지와 같은 최상위 디렉터리)를 추가합니다. 대부분의 제공자에는 하나의 루트만 있습니다. 예를 들어 사용자 계정이 여러 개인 경우 하나 이상이 있을 수 있습니다. 이런 경우에는 커서에 두 번째 행을 추가하기만 하면 됩니다.

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() 메서드를 사용하여 이러한 변경사항과 동기화되도록 문서 UI를 업데이트할 수 있습니다.

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() 구현은 DocumentsContract.Document에 정의된 열을 사용하여 지정된 디렉터리의 모든 파일을 가리키는 Cursor를 반환해야 합니다.

사용자가 선택도구 UI에서 루트를 선택하면 이 메서드가 호출됩니다. 이 메서드는 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() 구현은 DocumentsContract.Document에 정의된 열을 사용하여 지정된 파일을 가리키는 Cursor를 반환해야 합니다.

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를 통해 다른 객체를 전송할 수 있습니다.

최근 문서 지원 및 검색

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 인텐트를 전송하면 문서 제공자는 클라이언트 앱이 문서 제공자 내에 새 문서를 만들도록 허용할 수 있습니다.

문서 생성을 지원하려면 루트에 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 수준 24)에서 도입된 기능인 가상 파일을 사용하면 문서 제공자가 직접적인 바이트 코드 표현이 없는 파일에 보기 권한을 제공할 수 있습니다. 다른 앱에서 가상 파일을 볼 수 있게 하려면 문서 제공자가 가상 파일의 열 수 있는 대체 파일 표현을 생성해야 합니다.

예를 들어 다른 앱에서 직접 열 수 없는 파일 형식, 즉 기본적으로 가상 파일이 문서 제공자에 포함되어 있다고 가정해 보겠습니다. 클라이언트 앱이 CATEGORY_OPENABLE 카테고리 없이 ACTION_VIEW 인텐트를 전송하면 사용자는 문서 제공자 내에서 이러한 가상 파일을 선택하여 볼 수 있습니다. 그런 다음 문서 제공자는 이미지와 같이 다르지만 열 수 있는 파일 형식으로 가상 파일을 반환합니다. 그러면 클라이언트 앱은 가상 파일을 열어 사용자가 보게 할 수 있습니다.

제공자의 문서가 가상이라고 선언하려면 queryDocument() 메서드에서 반환하는 파일에 FLAG_VIRTUAL_DOCUMENT 플래그를 추가해야 합니다. 이 플래그는 파일에 직접적인 바이트 코드 표현이 없으며 파일을 직접 열 수 없음을 클라이언트 앱에 알립니다.

문서 제공자의 파일이 가상이라고 선언한다면 이미지나 PDF와 같은 다른 MIME 유형에서 파일을 사용할 수 있도록 하는 것이 좋습니다. 문서 제공자는 getDocumentStreamTypes() 메서드를 재정의하여 가상 파일 보기를 지원하는 대체 MIME 유형을 선언합니다. 클라이언트 앱이 getStreamTypes(android.net.Uri, java.lang.String) 메서드를 호출하면 시스템은 문서 제공자의 getDocumentStreamTypes() 메서드를 호출합니다. 그런 다음, getDocumentStreamTypes() 메서드는 문서 제공자가 파일에 관해 지원하는 대체 MIME 유형의 배열을 반환합니다.

클라이언트가 문서 제공자가 조회 가능한 파일 형식으로 문서를 생성할 수 있다고 판단하면 클라이언트 앱은 내부적으로 문서 제공자의 openTypedDocument() 메서드를 호출하는 openTypedAssetFileDescriptor() 메서드를 호출합니다. 문서 제공자는 요청된 파일 형식으로 클라이언트 앱에 파일을 반환합니다.

다음 코드 스니펫은 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() 구현에서 0 루트를 반환하는 것입니다. 즉, 다음과 같이 비어있는 루트 커서입니다.

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);
}

이 페이지와 관련된 샘플 코드는 다음을 참조하세요.

이 페이지와 관련된 동영상은 다음을 참조하세요.

추가 관련 정보는 다음을 참조하세요.