Android 4.4(API 수준 19) 이상을 실행하는 기기에서 앱은 저장소 액세스 프레임워크를 사용하여 외부 저장소 볼륨 및 클라우드 기반 저장소를 포함한 문서 제공자와 상호작용할 수 있습니다. 이 프레임워크를 통해 사용자는 시스템 선택 도구와 상호작용하여 문서 제공자를 선택하고 앱에서 만들거나 열거나 수정할 특정 문서와 기타 파일을 선택할 수 있습니다.
사용자가 직접 앱이 액세스할 수 있는 파일이나 디렉터리를 선택하므로 이 메커니즘에서는 시스템 권한을 요청할 필요가 없고 따라서 강화된 사용자 제어와 개인 정보 보호를 제공합니다. 또한 앱별 디렉터리 외부 및 미디어 저장소 외부에 저장된 이 파일들은 앱을 제거한 후에도 기기에 남아 있습니다.
프레임워크 사용 절차는 다음을 포함합니다.
- 앱이 저장소 관련 작업이 포함된 인텐트를 호출합니다. 이 작업은 프레임워크에서 제공하는 특정 사용 사례에 상응합니다.
- 사용자는 시스템 선택 도구를 확인하여 문서 제공자를 탐색하고 저장소 관련 작업이 발생하는 위치나 문서를 선택할 수 있습니다.
- 앱은 사용자가 선택한 위치 또는 문서를 나타내는 URI의 읽기 및 쓰기 액세스 권한을 얻습니다. 앱은 이 URI를 사용하여 선택된 위치에서 작업을 실행할 수 있습니다.
Android 9(API 수준 28) 이하를 실행하는 기기에서 미디어 파일 액세스를 지원하려면 READ_EXTERNAL_STORAGE
권한을 선언하고 maxSdkVersion
을 28
로 설정하세요.
이 가이드에서는 프레임워크가 파일 및 기타 문서 작업을 지원하는 다양한 사용 사례를 설명합니다. 또한 사용자가 선택한 위치에서 작업을 실행하는 방법도 설명합니다.
문서 및 기타 파일에 액세스하기 위한 사용 사례
저장소 액세스 프레임워크는 파일 및 기타 문서에 액세스하기 위한 다음 사용 사례를 지원합니다.
- 새 파일 만들기
ACTION_CREATE_DOCUMENT
인텐트 작업을 통해 사용자는 파일을 특정 위치에 저장할 수 있습니다.- 문서 또는 파일 열기
ACTION_OPEN_DOCUMENT
인텐트 작업을 통해 사용자는 특정 문서 또는 파일을 선택하여 열 수 있습니다.- 디렉터리의 콘텐츠에 관한 액세스 권한 부여
- Android 5.0 (API 수준 21) 이상에서 사용할 수 있는
ACTION_OPEN_DOCUMENT_TREE
인텐트 작업을 사용하면 사용자가 특정 디렉터리를 선택하여 그 디렉터리 내의 모든 파일과 하위 디렉터리에 관한 액세스 권한을 앱에 부여할 수 있습니다.
다음 섹션에서는 각 사용 사례를 구성하는 방법을 안내합니다.
새 파일 만들기
ACTION_CREATE_DOCUMENT
인텐트 작업을 사용하여 시스템 파일 선택 도구를 로드하고 사용자가 파일의 콘텐츠를 쓸 위치를 선택할 수 있도록 합니다. 이 프로세스는 다른 운영체제에서 사용하는 '다른 이름으로 저장' 대화상자에서 사용되는 프로세스와 유사합니다.
참고: ACTION_CREATE_DOCUMENT
는 기존 파일을 덮어쓸 수 없습니다. 앱이 동일한 이름으로 파일을 저장하려고 하면 시스템은 파일 이름 끝에 괄호로 묶은 숫자를 추가합니다.
예를 들어 앱이 confirmation.pdf
라는 이름의 파일이 이미 있는 디렉터리에 이 이름을 사용해 파일을 저장하려고 하면 시스템은 새 파일을 confirmation(1).pdf
라는 이름으로 저장합니다.
���텐트를 구성할 때 파일의 이름 및 MIME 유형을 지정하고 EXTRA_INITIAL_URI
추가 인텐트를 사용하여 파일 선택 도구가 처음 로드될 때 표시해야 하는 파일이나 디렉터리의 URI를 선택적으로 지정합니다.
다음 코드 스니펫은 파일을 만들기 위한 인텐트를 생성 및 호출하는 방법을 보여줍니다.
Kotlin
// Request code for creating a PDF document. const val CREATE_FILE = 1 private fun createFile(pickerInitialUri: Uri) { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "application/pdf" putExtra(Intent.EXTRA_TITLE, "invoice.pdf") // Optionally, specify a URI for the directory that should be opened in // the system file picker before your app creates the document. putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri) } startActivityForResult(intent, CREATE_FILE) }
Java
// Request code for creating a PDF document. private static final int CREATE_FILE = 1; private void createFile(Uri pickerInitialUri) { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("application/pdf"); intent.putExtra(Intent.EXTRA_TITLE, "invoice.pdf"); // Optionally, specify a URI for the directory that should be opened in // the system file picker when your app creates the document. intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri); startActivityForResult(intent, CREATE_FILE); }
파일 열기
앱은 사용자가 동료와 공유하거나 다른 문서로 가져올 수 있는 데이터를 입력하는 저장소 단위로 문서를 사용할 수 있습니다. 사용자가 생산성 문서를 열거나 EPUB 파일로 저장된 책을 여는 것 등이 그 예입니다.
이러한 경우 시스템의 파일 선택 도구 앱을 여는 ACTION_OPEN_DOCUMENT
인텐트를 호출하여 사용자가 열 파일을 선택할 수 있게 허용합니다. 앱에서 지원하는 파일 유형만 표시하려면 MIME 유형을 지정합니다. 또한 EXTRA_INITIAL_URI
인텐트 extra를 사용하여 파일 선택기가 처음 로드될 때 표시해야 하는 파일의 URI를 선택적으로 지정할 수 있습니다.
다음 코드 스니펫은 PDF 문서를 열기 위한 인텐트를 생성 및 호출하는 방법을 보여줍니다.
Kotlin
// Request code for selecting a PDF document. const val PICK_PDF_FILE = 2 fun openFile(pickerInitialUri: Uri) { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "application/pdf" // Optionally, specify a URI for the file that should appear in the // system file picker when it loads. putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri) } startActivityForResult(intent, PICK_PDF_FILE) }
Java
// Request code for selecting a PDF document. private static final int PICK_PDF_FILE = 2; private void openFile(Uri pickerInitialUri) { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("application/pdf"); // Optionally, specify a URI for the file that should appear in the // system file picker when it loads. intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri); startActivityForResult(intent, PICK_PDF_FILE); }
액세스 제한
Android 11(API 수준 30) 이상에서는 ACTION_OPEN_DOCUMENT
인텐트 작업을 사용하여 다음 디렉터리에서 개별 파일을 선택하도록 사용자에게 요청할 수 없습니다.
Android/data/
디렉터리 및 모든 하위 디렉터리Android/obb/
디렉터리 및 모든 하위 디렉터리
디렉터리의 콘텐츠에 관한 액세스 권한 부여
파일 관리 앱과 미디어 제작 앱은 일반적으로 디렉터리 계층 구조로 파일 그룹을 관리합니다. 앱에서 이 기능을 제공하려면 사용자가 전체 디렉터리 트리 액세스 권한을 부여할 수 있는 ACTION_OPEN_DOCUMENT_TREE
인텐트 작업을 사용합니다. 단, Android 11(API 수준 30) 이상에서는 일부 예외가 발생합니다. 그러면 앱은 선택된 디렉터리 및 그 하위 디렉터리에 있는 모든 파일에 액세스할 수 있습니다.
ACTION_OPEN_DOCUMENT_TREE
를 사용하면 앱은 사용자가 선택한 디렉터리의 파일에만 액세스할 수 있습니다. 사용자가 선택한 이 디렉터리 외부에 있는 다른 앱의 파일에는 액세스할 수 없습니다. 이러한 사용자 제어 액세스를 통해 사용자는 앱과 편히 공유할 수 있는 콘텐츠를 정확히 선택할 수 있습니다.
선택적으로 EXTRA_INITIAL_URI
추가 인텐트를 사용하여 파일 선택 도구가 처음 로드될 때 표시해야 하는 디렉터리의 URI를 지정할 수 있습니다.
다음 코드 스니펫은 디렉터리를 열기 위한 인텐트를 생성 및 호출하는 방법을 보여줍니다.
Kotlin
fun openDirectory(pickerInitialUri: Uri) { // Choose a directory using the system's file picker. val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { // Optionally, specify a URI for the directory that should be opened in // the system file picker when it loads. putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri) } startActivityForResult(intent, your-request-code) }
Java
public void openDirectory(Uri uriToLoad) { // Choose a directory using the system's file picker. Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); // Optionally, specify a URI for the directory that should be opened in // the system file picker when it loads. intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uriToLoad); startActivityForResult(intent, your-request-code); }
액세스 제한
Android 11(API 수준 30) 이상에서는 ACTION_OPEN_DOCUMENT_TREE
인텐트 작업을 사용하여 다음 디렉터리 액세스 권한을 요청할 수 없습니다.
- 내부 저장소 볼륨의 루트 디렉터리
- 기기 제조업체가 신뢰할 수 있다고 생각하는 각 SD 카드 볼륨의 루트 디렉터리(카드가 에뮬레이션되었거나 삭제 가능한지 여부와 관계없음) 신뢰할 수 있는 볼륨은 앱이 대부분의 경우 성공적으로 액세스할 수 있는 볼륨입니다.
Download
디렉터리
또한 Android 11(API 수준 30) 이상에서는 ACTION_OPEN_DOCUMENT_TREE
인텐트 작업을 사용하여 다음 디렉터리에서 개별 파일을 선택하도록 사용자에게 요청할 수 없습니다.
Android/data/
디렉터리 및 모든 하위 디렉터리Android/obb/
디렉터리 및 모든 하위 디렉터리
선택된 위치에서 작업 실행
사용자가 시스템의 파일 선택 도구를 사용하여 파일이나 디렉터리를 선택하면 앱은 onActivityResult()
에서 다음 코드를 사용하여 선택된 항목의 URI를 가져올 수 있습니다.
Kotlin
override fun onActivityResult( requestCode: Int, resultCode: Int, resultData: Intent?) { if (requestCode == your-request-code && resultCode == Activity.RESULT_OK) { // The result data contains a URI for the document or directory that // the user selected. resultData?.data?.also { uri -> // Perform operations on the document using its URI. } } }
Java
@Override public void onActivityResult(int requestCode, int resultCode, Intent resultData) { if (requestCode == your-request-code && resultCode == Activity.RESULT_OK) { // The result data contains a URI for the document or directory that // the user selected. Uri uri = null; if (resultData != null) { uri = resultData.getData(); // Perform operations on the document using its URI. } } }
선택된 항목의 URI 참조를 확보하면 앱은 그 항목과 관련하여 여러 작업을 실행할 수 있습니다. 예를 들어 항목의 메타데이터에 액세스하고, 사용 중인 항목을 수정하고, 항목을 삭제할 수 있습니다.
다음 섹션에서는 사용자가 선택한 파일에 관한 작업을 완료하는 방법을 보여줍니다.
제공자가 지원하는 작업 파악
다양한 콘텐츠 제공자를 사용하면 문서를 복사하거나 문서의 썸네일을 보는 등 문서에 관한 다양한 작업을 실행할 수 있��니다. 특정 제공업체가 지원하는 작업을 파악하려면 Document.COLUMN_FLAGS
의 값을 확인합니다.
그러면 앱의 UI는 제공업체가 지원하는 옵션만 표시할 수 있습니다.
권한 유지
앱이 읽기 또는 쓰기 작업을 위해 파일을 열면 시스템이 앱에 파일의 URI 권한을 부여합니다. 이는 사용자가 기기를 다시 시작할 때까지 지속됩니다. 그러나 앱이 이미지 편집 앱인데 사용자가 가장 최근에 편집한 5개의 이미지에 이 앱에서 직접 액세스할 수 있어야 한다고 가정해 보겠습니다. 사용자의 기기가 다시 시작되면 사용자를 다시 시스템 선택도구로 보내 파일을 찾도록 해야 합니다.
기기가 다시 시작될 때 파일에 관한 액세스를 유지하고 더 나은 사용자 환경을 구현하기 위해 앱은 다음 코드 스니펫과 같이 시스템에서 제공하는 유지 가능한 URI 권한 부여를 '받을' 수 있습니다.
Kotlin
val contentResolver = applicationContext.contentResolver val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION // Check for the freshest data. contentResolver.takePersistableUriPermission(uri, takeFlags)
자바
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를 얻으면 그 문서의 메타데이터에 액세스할 수 있습니다. 다음 스니펫은 URI로 지정된 문서의 메타데이터를 가져와서 로깅합니다.
Kotlin
val contentResolver = applicationContext.contentResolver fun dumpImageMetaData(uri: Uri) { // The query, because it only applies to a single document, returns only // one row. There's no need to filter, sort, or select fields, // because 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 because an // int can't be null, the behavior is implementation-specific, // and 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, because it only applies to a single document, returns only // one row. There's no need to filter, sort, or select fields, // because 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 because an // int can't be null, the behavior is implementation-specific, // and 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 참조가 있으면 추가 처리를 위해 문서를 열 수 있습니다. 이 섹션에서는 비트맵 및 입력 스트림을 여는 예를 보여줍니다.
비트맵
다음 코드 스니펫은 URI가 지정된 Bitmap
파일을 여는 방법을 보여줍니다.
Kotlin
val contentResolver = applicationContext.contentResolver @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; }
비트맵을 연 후 ImageView
에 표시할 수 있습니다.
입력 스트림
다음 코드 스니펫은 URI가 지정된 InputStream 객체를 여는 방법을 보여줍니다. 이 스니펫에서는 파일의 행을 문자열로 읽습니다.
Kotlin
val contentResolver = applicationContext.contentResolver @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 { StringBuilder stringBuilder = new StringBuilder(); try (InputStream inputStream = getContentResolver().openInputStream(uri); BufferedReader reader = new BufferedReader( new InputStreamReader(Objects.requireNonNull(inputStream)))) { String line; while ((line = reader.readLine()) != null) { stringBuilder.append(line); } } return stringBuilder.toString(); }
문서 편집
저장소 액세스 프레임워크를 사용하여 준비된 텍스트 문서를 편집할 수 있습니다.
다음 코드 스니펫은 지정된 URI로 표시되는 문서의 콘텐츠를 덮어씁니다.
Kotlin
val contentResolver = applicationContext.contentResolver private fun alterDocument(uri: Uri) { try { contentResolver.openFileDescriptor(uri, "w")?.use { FileOutputStream(it.fileDescriptor).use { it.write( ("Overwritten 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 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가 있고 그 문서의 Document.COLUMN_FLAGS
에 SUPPORTS_DELETE
가 포함되어 있다면 문서를 삭제할 수 있습니다. 예:
Kotlin
DocumentsContract.deleteDocument(applicationContext.contentResolver, uri)
Java
DocumentsContract.deleteDocument(applicationContext.contentResolver, uri);
동일한 미디어 URI 검색
getMediaUri()
메서드는 지정된 문서 제공자 URI와 동일한 미디어 저장소 URI를 제공합니다. 두 URI는 같은 기본 항목을 참조합니다. 미디어 저장소 URI를 사용하면 공유 저장소에서 미디어 파일에 더 쉽게 액세스할 수 있습니다.
getMediaUri()
메서드는 ExternalStorageProvider
URI를 지원합니다. Android 12(API 수준 31) 이상에서는 이 메서드가 MediaDocumentsProvider
URI도 지원합니다.
가상 파일 열기
Android 7.0(API 수준 25) 이상에서는 앱이 저장소 액세스 프레임워크에서 제공하는 가상 파일을 활용할 수 있습니다. 가상 파일에 바이너리 표현이 없더라도 앱은 다른 파일 형식으로 강제로 변환하거나 ACTION_VIEW
인텐트 작업을 사용해 파일을 보는 방법으로 콘텐츠를 열 수 있습니다.
가상 파일을 열려면 클라이언트 앱에 이를 처리하기 위한 특수 로직이 포함되어 있어야 합니다. 예를 들어 파일을 미리 보기 위해 파일의 바이트 표현을 얻고 싶다면 문서 제��자의 대체 MIME 유형을 요청해야 합니다.
사용자가 선택한 이후에 다음 코드 스니펫과 같이 결과 데이터의 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 유형(예: "image/png"
)으로 강제 변환할 수 있습니다. 다음 코드 스니펫은 가상 파일을 이미지로 표현할 수 있는지 확인하고 표현이 가능할 경우 가상 파일에서 입력 스트림을 가져오는 방법을 보여줍니다.
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 < 1) { throw new FileNotFoundException(); } return resolver .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null) .createInputStream(); }
추가 리소스
문서 및 기타 파일을 저장하고 액세스하는 방법에 관한 자세한 내용은 다음 리소스를 참조하세요.
샘플
- ActionOpenDocument: GitHub에서 제공합니다.
- ActionOpenDocumentTree: GitHub에서 제공합니다.