저장소 액세스 프레임워크를 사용하여 파일 열기

Android 4.4(API 레벨 19)에서 저장소 액세스 프레임워크(SAF)를 처음 도입하게 되었습니다. SAF는 사용자가 선호하는 문서 저장소 제공자 전체에서 문서, 이미지 및 각종 다른 파일을 탐색하고 여는 작업을 간편하게 해줍니다. 표준형의 사용하기 쉬운 UI를 사용하면 사용자가 각종 앱과 제공자에서 일관된 방식으로 파일을 탐색하고 최근 기록에 액세스할 수 있습니다.

클라우드 또는 로컬 저장소 서비스가 이 생태계에 참여하려면 서비스를 캡슐화하는 DocumentsProvider를 구현하면 됩니다. 제공자의 문서에 액세스해야 하는 클라이언트 앱의 경우 단 몇 줄의 코드만으로 SAF와 통합할 수 있습니다.

SAF에는 다음과 같은 항목이 포함됩니다.

  • 문서 제공자—일종의 콘텐츠 제공자로, 저장소 서비스(예: Google Drive)가 자신이 관리하는 파일을 드러내도록 허용합니다. 문서 제공자는 DocumentsProvider 클래스의 하위 클래스로 구현됩니다. 문서 제공자 스키마는 일반적인 파일 계층 구조에 기초하지만 문서 제공자가 물리적으로 데이터를 저장하는 방식은 개발자가 결정합니다. Android에는 여러 가지 내장된 문서 제공자(예: 다운로드, 이미지, 동영상)가 포함됩니다.
  • 클라이언트 앱—일종의 사용자 지정 앱으로, ACTION_OPEN_DOCUMENT 및/또는 ACTION_CREATE_DOCUMENT 인텐트를 호출하고 문서 제공자가 반환하는 파일을 수신합니다.
  • 선택기—일종의 시스템 UI로, 사용자가 클라이언트 앱의 검색 기준을 만족하는 모든 문서 제공자의 문서에 액세스할 수 있습니다.

SAF가 제공하는 기능을 몇 가지 예로 들면 다음과 같습니다.

  • 사용자가 하나의 앱뿐만 아니라 모든 문서 제공자에서 콘텐츠를 탐색할 수 있습니다.
  • 앱이 문서 제공자가 소유한 문서에 대한 장기적, 지속적 액세스 권한을 가질 수 있습니다. 이 액세스 권한을 통해 사용자가 제공자에 파일을 추가, 편집, 저장 및 삭제할 수 있습니다.
  • 여러 개의 사용자 계정을 지원하며 USB 저장소 제공자와 같은 임시 루트도 지원합니다. 이는 드라이브가 연결되어 있을 때만 나타납니다.

개요

SAF는 DocumentsProvider 클래스의 하위 클래스인 콘텐츠 제공자를 중심으로 작동합니다. 데이터는 문서 제공자 내에서 일반적인 파일 계층으로 구조화됩니다.

데이터 모델

그림 1. 문서 제공자 데이터 모델. 루트 하나가 하나의 문서를 가리키고, 이는 다시 트리 전체를 팬아웃하기 시작합니다.

다음 내용을 참조하세요.

  • 각 문서 제공자는 하나 이상의 '루트'를 보고합니다. 이 루트는 문서 트리를 탐색하는 시작 지점입니다. 각 루트는 고유한 COLUMN_ROOT_ID가 있고 해당 루트 아래에 있는 콘텐츠를 나타내는 문서(디렉토리)를 가리킵니다. 기본적으로 루트는 동적으로 움직여 다중 계정, 임시 USB 저장소 기기, 사용자 로그인/로그아웃과 같은 사용 사례를 지원합니다.
  • 각 루트 아래에 문서가 하나씩 있습니다. 해당 문서는 1부터 N까지의 문서를 가리키는데, 이는 각각 1부터 N의 문서를 가리킬 수 있습니다.
  • 각 저장소 백엔드는 고유한 COLUMN_DOCUMENT_ID로 개별 파일과 디렉토리를 참조하는 방법으로 표시합니다. 문서 ID는 고유해야 하며 한 번 발행되고 나면 변경되지 않습니다. 이들은 기기 재부팅을 통괄하여 영구적인 URI 허가에 사용되기 때문입니다.
  • 문서는 열 수 있는 파일이거나(특정 MIME 유형으로) 추가 문서가 들어 있는 디렉토리일 수 있습니다(MIME_TYPE_DIR MIME 유형으로).
  • 각 문서는 COLUMN_FLAGS에서 설명한 바와 같이 여러 가지 기능이 있습니다. 예: FLAG_SUPPORTS_WRITE, FLAG_SUPPORTS_DELETEFLAG_SUPPORTS_THUMBNAIL. 동일한 COLUMN_DOCUMENT_ID는 여러 디렉토리에 포함될 수 있습니다.

제어 흐름

위에서 언급한 바와 같이, 문서 제공자 데이터 모델은 기존 파일 계층 구조를 기반으로 합니다. 그러나 DocumentsProvider API를 사용하여 이에 액세스할 수 있는 한, 데이터를 물리적으로 저장할 수 있습니다. 예를 들어, 데이터를 저장하는 데 태그 기반 클라우드 저장소를 사용할 수 있습니다.

그림 2는 사진 앱이 SAF를 사용하여 저장된 데이터에 액세스할 수 있는 방법을 보여줍니다.

앱

그림 2. 저장소 액세스 프레임워크 흐름

다음 내용을 참조하세요.

  • SAF에서는 제공자와 클라이언트가 직접 상호작용하지 않습니다. 클라이언트가 파일과 상호작용하기 위한 권한을 요청합니다(다시 말해, 파일을 읽고, 편집하거나 생성 또는 삭제할 권한을 말합니다).
  • 상호작용은 애플리케이션(이 예시에서는 주어진 사진 앱)이 인텐트 ACTION_OPEN_DOCUMENT 또는 ACTION_CREATE_DOCUMENT를 실행시키면 시작합니다. 이 인텐트에는 기준을 한층 더 정밀하게 하기 위한 필터가 포함될 수 있습니다. 예를 들어 "열 수 있는 파일 중에서 '이미지' MIME 유형을 가진 파일을 모두 주세요"라고 할 수 있습니다.
  • 인텐트가 실행되면 시스템 선택기가 각각의 등록된 제공자로 이동하여 사용자에게 일치하는 콘텐츠 루트를 보여줍니다.
  • 선택기는 사용자에게 문서에 액세스하는 데 쓰는 표준 인터페이스를 부여합니다. 이는 기본 문서 제공자 사이에 큰 차이가 있더라도 무관합니다. 예를 들어 그림 2는 Google Drive 제공자, USB 제공자와 클라우드 제공자를 나타낸 것입니다.

그림 3은 이미지를 검색 중인 사용자가 Google Drive 계정을 선택한 선택기를 나타낸 것입니다. 또한 클라이언트 앱에서 사용할 수 있는 모든 루트를 표시합니다.

선택기

그림 3. 선택기

사용자가 Google Drive를 선택하면 이미지가 그림 4처럼 표시됩니다. 그때부터 사용자는 제공자와 클라이언트 앱이 지원하는 방식으로 이미지와 상호작용할 수 있게 됩니다.

선택기

그림 4. 이미지

클라이언트 앱 작성

Android 4.3 이하에서는 앱이 다른 앱에서 파일을 검색할 수 있게 하려면 ACTION_PICK 또는 ACTION_GET_CONTENT와 같은 인텐트를 호출해야 합니다. 그런 다음 사용자가 파일을 선택할 앱을 하나 선택해야 합니다. 선택한 앱은 사용자가 이용 가능한 파일을 탐색하고 선택할 수 있는 사용자 인터페이스를 제공해야 합니다.

Android 4.4 이상에서는 ACTION_OPEN_DOCUMENT 인텐트를 사용하는 추가 옵션이 있습니다. 이는 시스템이 제어하는 선택기 UI를 표시하여 사용자가 다른 앱에서 제공하는 파일을 모두 탐색할 수 있게 해줍니다. 이 하나의 UI에서 사용자는 지원되는 모든 앱에서 파일을 선택할 수 있습니다.

ACTION_OPEN_DOCUMENTACTION_GET_CONTENT를 대체하는 용도가 아닙니다. 각 앱에 무엇이 필요한지에 따라 어느 것을 사용해야 할지 선택해야 합니다.

  • 앱이 단순히 데이터를 읽고/가져오기를 바란다면 ACTION_GET_CONTENT를 사용하세요. 이 방식을 사용하면 앱은 데이터 사본(예: 이미지 파일)을 가져오게 됩니다.
  • 앱이 문서 제공자가 보유한 문서에 장기적, 지속적 액세스 권한을 가지기를 바라는 경우에는 ACTION_OPEN_DOCUMENT를 사용하세요. 일례로 사용자들에게 문서 제공자에 저장된 이미지를 편집할 수 있게 해주는 사진 편집 앱이 있습니다.

이 섹션에서는 ACTION_OPEN_DOCUMENTACTION_CREATE_DOCUMENT 인텐트를 근거로 클라이언트 앱을 작성하는 방법을 설명합니다.

다음 스니펫에서는 ACTION_OPEN_DOCUMENT를 사용하여 이미지 파일이 들어 있는 문서 제공자를 검색합니다.

Kotlin

private const val READ_REQUEST_CODE: Int = 42
...
/**
 * Fires an intent to spin up the "file chooser" UI and select an image.
 */
fun performFileSearch() {

    // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file
    // browser.
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        // Filter to only show results that can be "opened", such as a
        // file (as opposed to a list of contacts or timezones)
        addCategory(Intent.CATEGORY_OPENABLE)

        // Filter to show only images, using the image MIME data type.
        // If one wanted to search for ogg vorbis files, the type would be "audio/ogg".
        // To search for all documents available via installed storage providers,
        // it would be "*/*".
        type = "image/*"
    }

    startActivityForResult(intent, READ_REQUEST_CODE)
}

Java

private static final int READ_REQUEST_CODE = 42;
...
/**
 * Fires an intent to spin up the "file chooser" UI and select an image.
 */
public void performFileSearch() {

    // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file
    // browser.
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);

    // Filter to only show results that can be "opened", such as a
    // file (as opposed to a list of contacts or timezones)
    intent.addCategory(Intent.CATEGORY_OPENABLE);

    // Filter to show only images, using the image MIME data type.
    // If one wanted to search for ogg vorbis files, the type would be "audio/ogg".
    // To search for all documents available via installed storage providers,
    // it would be "*/*".
    intent.setType("image/*");

    startActivityForResult(intent, READ_REQUEST_CODE);
}

다음 내용을 참조하세요.

  • 앱이 ACTION_OPEN_DOCUMENT 인텐트를 실행하면, 인텐트는 일치하는 문서 제공자를 모두 표시하는 선택기를 시작합니다.
  • CATEGORY_OPENABLE 카테고리를 인텐트에 추가하면 결과를 필터링하여 이미지 파일 등의 열 수 있는 문서만 표시합니다.
  • intent.setType("image/*") 문을 사용해서 필터링을 추가로 수행하여 MIME 데이터 유형이 이미지인 문서만 표시하도록 합니다.

결과 처리

사용자가 선택기에서 문서를 선택한 후 onActivityResult()가 호출됩니다. resultData 매개변수에는 선택한 문서를 가리키는 URI가 포함됩니다. getData()를 사용하여 URI를 추출합니다. 이제 추출한 URI를 사용하여 사용자가 원하는 문서를 검색하면 됩니다. 예를 들면 다음과 같습니다.

Kotlin

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {

    // The ACTION_OPEN_DOCUMENT intent was sent with the request code
    // READ_REQUEST_CODE. If the request code seen here doesn't match, it's the
    // response to some other intent, and the code below shouldn't run at all.

    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        // The document selected by the user won't be returned in the intent.
        // Instead, a URI to that document will be contained in the return intent
        // provided to this method as a parameter.
        // Pull that URI using resultData.getData().
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")
            showImage(uri)
        }
    }
}

Java

@Override
public void onActivityResult(int requestCode, int resultCode,
        Intent resultData) {

    // The ACTION_OPEN_DOCUMENT intent was sent with the request code
    // READ_REQUEST_CODE. If the request code seen here doesn't match, it's the
    // response to some other intent, and the code below shouldn't run at all.

    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
        // The document selected by the user won't be returned in the intent.
        // Instead, a URI to that document will be contained in the return intent
        // provided to this method as a parameter.
        // Pull that URI using resultData.getData().
        Uri uri = null;
        if (resultData != null) {
            uri = resultData.getData();
            Log.i(TAG, "Uri: " + uri.toString());
            showImage(uri);
        }
    }
}

문서 메타데이터 검토

문서의 URI를 얻으면 그 문서의 메타데이터에 액세스할 수 있습니다. 이 스니펫은 해당 URI가 나타내는 문서의 메타데이터를 가져와 다음과 같이 기록합니다.

Kotlin

fun dumpImageMetaData(uri: Uri) {

    // The query, since it only applies to a single document, will only return
    // one row. There's no need to filter, sort, or select fields, since we want
    // all fields for one document.
    val cursor: Cursor? = contentResolver.query( uri, null, null, null, null, null)

    cursor?.use {
        // moveToFirst() returns false if the cursor has 0 rows.  Very handy for
        // "if there's anything to look at, look at it" conditionals.
        if (it.moveToFirst()) {

            // Note it's called "Display Name".  This is
            // provider-specific, and might not necessarily be the file name.
            val displayName: String =
                    it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
            Log.i(TAG, "Display Name: $displayName")

            val sizeIndex: Int = it.getColumnIndex(OpenableColumns.SIZE)
            // If the size is unknown, the value stored is null.  But since an
            // int can't be null in Java, the behavior is implementation-specific,
            // which is just a fancy term for "unpredictable".  So as
            // a rule, check if it's null before assigning to an int.  This will
            // happen often:  The storage API allows for remote files, whose
            // size might not be locally known.
            val size: String = if (!it.isNull(sizeIndex)) {
                // Technically the column stores an int, but cursor.getString()
                // will do the conversion automatically.
                it.getString(sizeIndex)
            } else {
                "Unknown"
            }
            Log.i(TAG, "Size: $size")
        }
    }
}

Java

public void dumpImageMetaData(Uri uri) {

    // The query, since it only applies to a single document, will only return
    // one row. There's no need to filter, sort, or select fields, since we want
    // all fields for one document.
    Cursor cursor = getActivity().getContentResolver()
            .query(uri, null, null, null, null, null);

    try {
    // moveToFirst() returns false if the cursor has 0 rows.  Very handy for
    // "if there's anything to look at, look at it" conditionals.
        if (cursor != null && cursor.moveToFirst()) {

            // Note it's called "Display Name".  This is
            // provider-specific, and might not necessarily be the file name.
            String displayName = cursor.getString(
                    cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
            Log.i(TAG, "Display Name: " + displayName);

            int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
            // If the size is unknown, the value stored is null.  But since an
            // int can't be null in Java, the behavior is implementation-specific,
            // which is just a fancy term for "unpredictable".  So as
            // a rule, check if it's null before assigning to an int.  This will
            // happen often:  The storage API allows for remote files, whose
            // size might not be locally known.
            String size = null;
            if (!cursor.isNull(sizeIndex)) {
                // Technically the column stores an int, but cursor.getString()
                // will do the conversion automatically.
                size = cursor.getString(sizeIndex);
            } else {
                size = "Unknown";
            }
            Log.i(TAG, "Size: " + size);
        }
    } finally {
        cursor.close();
    }
}

문서 열기

문서의 URI를 얻은 다음에는 문서를 열 수도 있고 그 외에 원하는 작업을 무엇이든 수행할 수 있습니다.

비트맵

다음은 Bitmap을 여는 방법의 예시입니다.

Kotlin

@Throws(IOException::class)
private fun getBitmapFromUri(uri: Uri): Bitmap {
    val parcelFileDescriptor: ParcelFileDescriptor = contentResolver.openFileDescriptor(uri, "r")
    val fileDescriptor: FileDescriptor = parcelFileDescriptor.fileDescriptor
    val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
    parcelFileDescriptor.close()
    return image
}

Java

private Bitmap getBitmapFromUri(Uri uri) throws IOException {
    ParcelFileDescriptor parcelFileDescriptor =
            getContentResolver().openFileDescriptor(uri, "r");
    FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
    Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
    parcelFileDescriptor.close();
    return image;
}

이 작업을 UI 스레드에서 해서는 안 된다는 점을 유의하세요. 백그라운드에서 하되, AsyncTask를 사용합니다. 비트맵을 열고 나면 이를 ImageView로 표시할 수 있습니다.

InputStream 가져오기

다음은 URI에서 InputStream을 가져오는 방법의 예입니다. 이 스니펫에서는 파일의 줄이 문자열로 읽히고 있습니다.

Kotlin

@Throws(IOException::class)
private fun readTextFromUri(uri: Uri): String {
    val stringBuilder = StringBuilder()
    contentResolver.openInputStream(uri)?.use { inputStream ->
        BufferedReader(InputStreamReader(inputStream)).use { reader ->
            var line: String? = reader.readLine()
            while (line != null) {
                stringBuilder.append(line)
                line = reader.readLine()
            }
        }
    }
    return stringBuilder.toString()
}

Java

private String readTextFromUri(Uri uri) throws IOException {
    InputStream inputStream = getContentResolver().openInputStream(uri);
    BufferedReader reader = new BufferedReader(new InputStreamReader(
            inputStream));
    StringBuilder stringBuilder = new StringBuilder();
    String line;
    while ((line = reader.readLine()) != null) {
        stringBuilder.append(line);
    }
    fileInputStream.close();
    parcelFileDescriptor.close();
    return stringBuilder.toString();
}

문서 생성

앱은 ACTION_CREATE_DOCUMENT 인텐트를 사용하여 문서 제공자에서 새 문서를 생성할 수 있습니다. 파일을 생성하려면 인텐트에 MIME 유형과 파일 이름을 부여하고, 고유한 요청 코드로 이를 시작하면 됩니다. 나머지는 알아서 처리됩니다.

Kotlin

// Here are some examples of how you might call this method.
// The first parameter is the MIME type, and the second parameter is the name
// of the file you are creating:
//
// createFile("text/plain", "foobar.txt");
// createFile("image/png", "mypicture.png");

// Unique request code.
private const val WRITE_REQUEST_CODE: Int = 43
...
private fun createFile(mimeType: String, fileName: String) {
    val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
        // Filter to only show results that can be "opened", such as
        // a file (as opposed to a list of contacts or timezones).
        addCategory(Intent.CATEGORY_OPENABLE)

        // Create a file with the requested MIME type.
        type = mimeType
        putExtra(Intent.EXTRA_TITLE, fileName)
    }

    startActivityForResult(intent, WRITE_REQUEST_CODE)
}

Java

// Here are some examples of how you might call this method.
// The first parameter is the MIME type, and the second parameter is the name
// of the file you are creating:
//
// createFile("text/plain", "foobar.txt");
// createFile("image/png", "mypicture.png");

// Unique request code.
private static final int WRITE_REQUEST_CODE = 43;
...
private void createFile(String mimeType, String fileName) {
    Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);

    // Filter to only show results that can be "opened", such as
    // a file (as opposed to a list of contacts or timezones).
    intent.addCategory(Intent.CATEGORY_OPENABLE);

    // Create a file with the requested MIME type.
    intent.setType(mimeType);
    intent.putExtra(Intent.EXTRA_TITLE, fileName);
    startActivityForResult(intent, WRITE_REQUEST_CODE);
}

새 문서를 생성하고 나면 onActivityResult()에서 URI를 가져와 거기에 계속해서 쓸 수 있습니다.

문서 삭제

어느 문서에 대한 URI가 있고 해당 문서의 Document.COLUMN_FLAGSSUPPORTS_DELETE가 들어 있는 경우, 해당 문서를 삭제할 수 있습니다. 예를 들면 다음과 같습니다.

Kotlin

DocumentsContract.deleteDocument(contentResolver, uri)

Java

DocumentsContract.deleteDocument(getContentResolver(), uri);

문서 편집

SAF를 사용하여 그 자리에서 텍스트 문서를 편집할 수 있습니다. 이 스니펫은 ACTION_OPEN_DOCUMENT 인텐트를 실행하고 카테고리 CATEGORY_OPENABLE을 사용하여 열 수 있는 문서만 표시합니다. 여기에서 추가로 필터링하여 텍스트 파일만 표시하려면 다음과 같이 합니다.

Kotlin

private const val EDIT_REQUEST_CODE: Int = 44
/**
 * Open a file for writing and append some text to it.
 */
private fun editDocument() {
    // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's
    // file browser.
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        // Filter to only show results that can be "opened", such as a
        // file (as opposed to a list of contacts or timezones).
        addCategory(Intent.CATEGORY_OPENABLE)

        // Filter to show only text files.
        type = "text/plain"
    }

    startActivityForResult(intent, EDIT_REQUEST_CODE)
}

Java

private static final int EDIT_REQUEST_CODE = 44;
/**
 * Open a file for writing and append some text to it.
 */
 private void editDocument() {
    // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's
    // file browser.
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);

    // Filter to only show results that can be "opened", such as a
    // file (as opposed to a list of contacts or timezones).
    intent.addCategory(Intent.CATEGORY_OPENABLE);

    // Filter to show only text files.
    intent.setType("text/plain");

    startActivityForResult(intent, EDIT_REQUEST_CODE);
}

다음으로, onActivityResult()(결과 처리 참조)에서 코드를 호출하여 편집 작업을 수행할 수 있습니다. 다음 스니펫은 ContentResolver에서 FileOutputStream을 가져옵니다. 기본적으로 쓰기 모드를 사용합니다. 필수적인 최소한의 액세스 권한만을 요청하는 것이 바람직하므로 쓰기만 필요하다면 읽기/쓰기를 모두 요청하지 마세요.

Kotlin

private fun alterDocument(uri: Uri) {
    try {
        contentResolver.openFileDescriptor(uri, "w")?.use {
            // use{} lets the document provider know you're done by automatically closing the stream
            FileOutputStream(it.fileDescriptor).use {
                it.write(
                    ("Overwritten by MyCloud at ${System.currentTimeMillis()}\n").toByteArray()
                )
            }
        }
    } catch (e: FileNotFoundException) {
        e.printStackTrace()
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

Java

private void alterDocument(Uri uri) {
    try {
        ParcelFileDescriptor pfd = getActivity().getContentResolver().
                openFileDescriptor(uri, "w");
        FileOutputStream fileOutputStream =
                new FileOutputStream(pfd.getFileDescriptor());
        fileOutputStream.write(("Overwritten by MyCloud at " +
                System.currentTimeMillis() + "\n").getBytes());
        // Let the document provider know you're done by closing the stream.
        fileOutputStream.close();
        pfd.close();
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

권한 유지

앱이 읽기 또는 쓰기 작업을 위해 파일을 열면 시스템이 앱에 해당 파일에 대한 URI 권한을 부여합니다. 이는 사용자가 기기를 다시 시작할 때까지 지속됩니다. 하지만 앱이 이미지 편집 앱이고 사용자가 앱에서 직접 편집한 마지막 5개의 이미지에 액세스할 수 있게 하려고 한다고 생각해보세요. 사용자의 기기가 다시 시작되면 사용자를 다시 시스템 선택기로 보내 해당 파일을 찾도록 해야 하는데, 이는 당연히 좋은 방법이 아닙니다.

이런 일이 일어나지 않도록 시스템이 앱에 부여한 권한을 유지할 수 있습니다. 실질적으로 앱은 시스템이 부여하는 유지 가능한 URI 권한을 "받아들입니다". 이렇게 하면 기기가 다시 시작되었더라도 사용자가 앱을 통해 지속적으로 파일에 액세스할 수 있습니다.

Kotlin

val takeFlags: Int = intent.flags and
        (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)

Java

final int takeFlags = intent.getFlags()
            & (Intent.FLAG_GRANT_READ_URI_PERMISSION
            | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
// Check for the freshest data.
getContentResolver().takePersistableUriPermission(uri, takeFlags);

마지막 한 단계가 남아 있습니다. 앱이 액세스한 가장 최근의 URI가 더 이상 유효하지 않을 수 있습니다. 다른 앱에서 문서를 삭제했거나 수정했을 수 있기 때문입니다. 따라서 항상 getContentResolver().takePersistableUriPermission()을 호출하여 최신 데이터를 확인해야 합니다.

가상 파일 열기

Android 7.0에서는 가상 파일이라는 개념이 저장소 액세스 프레임워크에 추가됩니다. 가상 파일에 바이너리 표현이 없더라도 클라이언트 앱은 다른 파일 유형으로 강제로 변환하거나, ACTION_VIEW 인텐트를 사용하여 파일을 보는 방법으로 콘텐츠를 열 수 있습니다.

가상 파일을 열려면 클라이언트 앱에 이를 처리하기 위한 특수한 로직을 포함해야 합니다. 파일의 바이트 표현을 얻고 싶다면(예: 파일 미리보기) 문서 제공자에게 대체 MIME 유형을 요청해야 합니다.

앱에서 가상 문서의 URI를 가져오려면 먼저 Intent를 만들어 파일 선택기 UI를 엽니다. 이는 앞서 문서 검색에서 보여드린 코드와 같습니다.

사용자가 선택한 후에 시스템에서 onActivityResult() 메서드를 호출합니다. 이는 앞서 결과 처리에서 보여드렸던 것과 같습니다. 앱은 파일의 URI를 검색한 다음, 아래의 코드 스니펫과 유사한 메서드를 사용하여 해당 파일이 가상인지 여부를 확인할 수 있습니다.

Kotlin

private fun isVirtualFile(uri: Uri): Boolean {
    if (!DocumentsContract.isDocumentUri(this, uri)) {
        return false
    }

    val cursor: Cursor? = contentResolver.query(
            uri,
            arrayOf(DocumentsContract.Document.COLUMN_FLAGS),
            null,
            null,
            null
    )

    val flags: Int = cursor?.use {
        if (cursor.moveToFirst()) {
            cursor.getInt(0)
        } else {
            0
        }
    } ?: 0

    return flags and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT != 0
}

Java

private boolean isVirtualFile(Uri uri) {
    if (!DocumentsContract.isDocumentUri(this, uri)) {
        return false;
    }

    Cursor cursor = getContentResolver().query(
        uri,
        new String[] { DocumentsContract.Document.COLUMN_FLAGS },
        null, null, null);

    int flags = 0;
    if (cursor.moveToFirst()) {
        flags = cursor.getInt(0);
    }
    cursor.close();

    return (flags & DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0;
}

가상 파일인 것이 확인되면 그 파일을 대체 MIME 유형(예: 이미지 파일)으로 강제 변환할 수 있습니다. 다음 코드 스니펫은 가상 파일을 이미지로 표현할 수 있는지 여부를 확인하고, 표현이 가능할 경우 가상 파일에서 입력 스트림을 가져오는 방법을 보여줍니다.

Kotlin

@Throws(IOException::class)
private fun getInputStreamForVirtualFile(uri: Uri, mimeTypeFilter: String): InputStream {

    val openableMimeTypes: Array<String>? = contentResolver.getStreamTypes(uri, mimeTypeFilter)

    return if (openableMimeTypes?.isNotEmpty() == true) {
        contentResolver
                .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null)
                .createInputStream()
    } else {
        throw FileNotFoundException()
    }
}

Java


private InputStream getInputStreamForVirtualFile(Uri uri, String mimeTypeFilter)
    throws IOException {

    ContentResolver resolver = getContentResolver();

    String[] openableMimeTypes = resolver.getStreamTypes(uri, mimeTypeFilter);

    if (openableMimeTypes == null ||
        openableMimeTypes.length &lt; 1) {
        throw new FileNotFoundException();
    }

    return resolver
        .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null)
        .createInputStream();
}

가상 파일과 이를 저장소 액세스 프레임워크 클라이언트 앱에서 처리하는 방법에 대한 자세한 내용은 저장소 액세스 프레임워크의 가상 파일 동영상을 참조하세요.

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

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

자세한 관련 내용은 다음을 참조하세요.