맞춤 문서 제공자 만들기

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

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

Manifest

맞춤 문서 제공자를 구현하려면 애플리케이션의 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 이상을 실행하는 기기의 manifest에서 ACTION_GET_CONTENT 인텐트 필터를 중지해야 합니다. 문서 제공자 및 ACTION_GET_CONTENT는 상호 배타적이어야 합니다. 두 가지를 동시에 지원하면 앱이 시스템 선택도구 UI에 두 번 표시되어 저장된 데이터에 액세스하는 방법이 두 가지로 다르게 제공됩니다. 이는 사용자에게 혼동을 줍니다.

다음은 Android 버전 4.4 이상을 실행하는 기기에서 ACTION_GET_CONTENT 인텐트 필터를 중지하는 데 추천하는 방법입니다.

  1. res/values/ 아래 bool.xml 리소스 파일에 다음 라인을 추가합니다.
    <bool name="atMostJellyBeanMR2">true</bool>
  2. res/values-v19/ 아래 bool.xml 리소스 파일에 다음 라인을 추가합니다.
    <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
    )
    

자바

    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
    }
    

자바

    @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)
    

자바

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

자바

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

자바

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

자바

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

자바

    @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를 반환하여 문서 제공자의 루트 아래에서 최근 수정된 문서 목록을 제공할 수 있습니다. 다음 코드 스니펫은 메서드를 구현하는 방법의 예를 보여줍니다.

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
    }
    

자바

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

자바

    @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 유형의 배열을 반환합니다.

문서 제공자가 볼 수 있는 파일 형식으로 문서를 생성할 수 있다고 클라이언트에서 판단하면 클라이언트 앱은 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()
            }
        }
    }
    

자바


    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
        }
    

자바

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

자바

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

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

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

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