Android 10 ๋ณ๊ฒฝ ์ฌํญ : Scoped Storage
Android 10์ ํฐ ๋ณ๊ฒฝ ์ฌํญ ์ค ํ๋์ธ Scoped Storage์ ๋ํด ์ ๋ฆฌํด ๋ณด์์ต๋๋ค.
(์์ ์ฝ๋๋ ๋ชจ๋ Kotlin ์ ๋๋ค.)
Scoped Storage
์ฌ์ฉ์์๊ฒ ํ์ผ์ ๋ํ ๋ ๋ง์ ๊ถํ์ ์ฃผ๊ธฐ ์ํด ์ฑ ๋ด๋ถ ๋๋ ํ ๋ฆฌ ๋ฐ ์ฑ์์ ์์ฑํ ์ธ๋ถ ๋๋ ํ ๋ฆฌ์ ํ์ผ ์ธ์๋ ์ ๊ทผ์ด ์ ํ
์ ์ฉ๋๋ ๋ฒ์
- TargetSdkVersion โฅ 29 ๋ถํฐ ์ ์ฉ
- ์ผ์์ ์ผ๋ก ํด์ ๊ฐ๋ฅ (๊ธฐ์กด ๋ฐฉ์ ์ฌ์ฉ)
<manifest ... >
<!-- Android 10 ์ด์์์ default = "false" -->
<application android:requestLegacyExternalStorage="true" ... >
...
</application>
</manifest>
- Android 11๋ถํฐ๋ TargetSdkVersion๊ณผ ์๊ด์์ด ์ ์ฉ๋ ์์ ์ด๋ผ๋ ์๊ธฐ๋ ์์
๋ฌ๋ผ์ง๋ ์
์ฑ ์ ์ฉ ๋๋ ํ ๋ฆฌ
- Internal
- External
ํ์ผํ์
- persist file
- cache file
- ์ผ์์ ์ธ ์บ์ ํ์ผ
- ๊ธฐ๊ธฐ ๋ฉ๋ชจ๋ฆฌ๊ฐ ๋ถ์กฑํด์ง๋ฉด ์์คํ ์์ ์ญ์ ํ ์๋ ์์
Internal
- ๋ค๋ฅธ ์ฑ์ด ์ ๊ทผํ ์ ์์
- Android 10 ์ด์ ๊ธฐ๊ธฐ๋ถํฐ๋ ์ํธํ๋๋ ๊ณต๊ฐ
-
Internal์ ํ ๋น๋ ์ฉ๋์ด ์ ๊ธฐ ๋๋ฌธ์, ํ์ผ์ ์ฐ๊ธฐ ์ ์ ์ฌ์ ๊ณต๊ฐ ํ์ธ ํ์
- persistent file
//ํ์ผ ์ ๊ทผ ๋ฐ ์์ฑ val file = File(context.filesDir, filename)
- cache file
//์์ฑ File.createTempFile(filename, null, context.cacheDir) //์ ๊ทผ val cacheFile = File(context.cacheDir, filename)
External
- ์ฌ์ฉ์๊ฐ ๋ฌผ๋ฆฌ์ ์ผ๋ก ์ ๊ฑฐํ ์๋ ์๋ ์ธ์ฅ ๊ณต๊ฐ (ex, sd card)
- ๊ถํ์ ๊ฐ์ง ๋ค๋ฅธ ์ฑ์ด ์ ๊ทผํ ์๋ ์์ง๋ง, ์ด ๊ณต๊ฐ์ ์๋๋ ์์ ํ ์ฑ์์๋ง ๋๋ ๊ณต๊ฐ
- -> ์์ฑํ ํ์ผ์ด ๋ค๋ฅธ ์ฑ์์๋ ์ ๊ทผ ๊ฐ๋ฅํด์ผ ํ๋ค๋ฉด, External storage ๋ณด๋ค๋ ๊ณต์ฉ ๊ณต๊ฐ์ ์ ์ฅํ๋ ๊ฒ์ ๊ถ์ฅ
//Persistent file
val appSpecificExternalDir = File(context.getExternalFilesDir(), filename)
//Cache file
val externalCacheFile = File(context.externalCacheDir, filename)
์ฉ๋ ํ์ธ
StorageStatsManager.getFreeBytes()
/ StorageStatsManager.getTotalBytes()
๊ณต์ฉ ๊ณต๊ฐ์ ํ์ผ ์ ๊ทผ
- MediaStore
-
Documents & etc.
- ๋ค๋ฅธ ์ฑ์์ ์ ๊ทผ ๊ฐ๋ฅ
- ํ์ผ์ ์์ฑํ ์ฑ์ด ์ญ์ ๋๋๋ผ๋ ํ์ผ ์ ์ง
Media Store
- ์์คํ ์์ ํ์ผ ์ข ๋ฅ์ ๋ฐ๋ผ public directory๋ฅผ ์ ๊ณต
- ์ด๋ฏธ์ง, ์ค๋์ค, ๋น๋์ค ๋ฑ ํ์ผ ์ข ๋ฅ์ ๋ง๋ ๊ณตํต ๊ณต๊ฐ์ ์ ์ฅ๋จ
- ์ด๋ฏธ์ง: ์ฌ์ง, ์คํฌ๋ฆฐ์ท ๋ฑ /
MediaStore.Images
- ๋น๋์ค:
MediaStore.Video
- ์ค๋์ค:
MediaStore.Audio
- ๋ค์ด๋ก๋ ํ์ผ: Android 10 ์ด์ ๊ธฐ๊ธฐ๋ถํฐ /
MediaStore.Downloads
MediaStore.Files
- Scoped Storage - On: ์ฑ์ด ์์ฑํ ์ด๋ฏธ์ง, ๋น๋์ค, ์ค๋์ค๋ง ๋ฐํ
- Scoped Storage - Off: ๋ชจ๋ ๋ฏธ๋์ด ํ์ผ ๋ฐํ
Permission ๋ณ๊ฒฝ์ฌํญ
Scoped Storage - On
- ์ฑ์์ ์์ฑํ ๋ฏธ๋์ด ํ์ผ์๋ง ์ ๊ทผํ๋ค๋ฉด Android 10 ์ด์ ๊ธฐ๊ธฐ์์๋ ๋ณ๋์ ๊ถํ ์์ฒญ์ด ํ์์์
- 28 ์ดํ ๊ธฐ๊ธฐ์์๋ง ์์ฒญํ๋๋ก ๋ถ๊ธฐ์ฒ๋ฆฌ ๊ฐ๋ฅ
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
- ๋ค๋ฅธ ์ฑ์ด ์์ฑํ ๋ฏธ๋์ด ํ์ผ์ ์ฝ๋๋ค๋ฉด
READ_EXTERNAL_STORAGE
๊ถํ ํ์ - ๋ฏธ๋์ด์ ์์น ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๋ ค๋ฉด
ACCESS_MEDIA_LOCATION
๊ถํ ํ์ -> ๋ฐํ์ ์์ฒญ ๊ถํ
Scoped Storage - Off || 28์ดํ ๊ธฐ๊ธฐ
- ๋์์ ๋ฐ๋ผ
READ_EXTERNAL_STORAGE
/WRITE_EXTERNAL_STORAGE
๊ถํ ํ์
๋ฏธ๋์ด ๊ณต์
content://
URI ์ฌ์ฉํ์ฌ ๊ณต์
ํ์ผ ๊ฒฝ๋ก๋ก ๋ฏธ๋์ด์ ์ ๊ทผ
์) /sdcard/DCIM/IMG1024.JPG
- Scoped Storage ์ฌ์ฉํ๋ ๊ฒฝ์ฐ,
READ_EXTERNAL_STORAGE
๊ถํ์ด ์์ด๋ ์ฑ ๋๋ ํ ๋ฆฌ๊ฐ ์๋ ํ์ผ์ ๊ฒฝ๋ก๋ก ์ ๊ทผ ๋ถ๊ฐ๋ฅ- ์ฑ๋ณ ๋๋ ํ ๋ฆฌ๊ฐ ์๋ ๊ฒฝ์ฐ
FileNotFoundException
๋ฐ์ - ์ด์ : /sdcard๊ฐ ๊ฐ ์ฑ๋ณ ํ ๋๋ ํ ๋ฆฌ๋ก ๋งคํ -> ์ค์ ๋ก๋ /sdcard/Android/sandbox/<package-name>/DCIM/IMG1024.JPG
- ์ฑ๋ณ ๋๋ ํ ๋ฆฌ๊ฐ ์๋ ๊ฒฝ์ฐ
- MediaStore API ์ฌ์ฉํด์ผ ํจ
๋ฏธ๋์ด ํ์ผ ์ถ๊ฐํ๊ธฐ
// Add a specific media item.
val resolver = applicationContext.contentResolver
// primary external storage์์ ์ค๋์ค ์ปฌ๋ ์
์ฟผ๋ฆฌ
val audioCollection = MediaStore.Audio.Media.getContentUri(
MediaStore.VOLUME_EXTERNAL_PRIMARY) // API <= 28 : VOLUME_EXTERNAL ์ฌ์ฉ
// ์ ์ค๋์ค ์ปจํ
ํธ ์์ฑ
val newSongDetails = ContentValues().apply {
put(MediaStore.Audio.Media.DISPLAY_NAME, "My Song.mp3")
}
// ๋์ค์ ์์ ์ด ํ์ํ ๋ ์ด URI ํตํด์ ์ ๊ทผ
val myFavoriteSongUri = resolver.insert(audioCollection, newSongDetails)
ํ์ผ ์ถ๊ฐ์ ๋๋ ํ ๋ฆฌ ์ง์ ํ๊ธฐ
Android 10 ์ด์ ๊ธฐ๊ธฐ์์๋ dafault - ํ์ผ ํ์
๊ธฐ๋ฐ์ผ๋ก ๋ถ๋ฅ
์) ์ด๋ฏธ์ง ํ์ผ์ ์ถ๊ฐํ๋ฉด Environment.DIRECTORY_PICTURES
๊ฒฝ๋ก์ ์ ์ฅ
Pictures/MyVacationPictures ์ฒ๋ผ ํ์ ํด๋๋ฅผ ์ง์ ํ๊ณ ์ถ๋ค๋ฉด ํ์ผ ์ถ๊ฐ์ MediaColumns.RELATIVE_PATH
์นผ๋ผ ์ค์
์ ๋ฐ์ดํธ / ์ญ์
๋ด ์ฑ์ด ์์ฑํ ๋ฏธ๋์ด
Scoped Storage On/Off์ ์๊ด์์ด ๋ชจ๋ ๋์
๋ค๋ฅธ ์ฑ์ด ์์ฑํ ๋ฏธ๋์ด && Scoped Storage ์ฌ์ฉ
์ ๋ฐ์ดํธ / ์ญ์ ๋์์ด ํ์ํ ๋๋ง๋ค ํด๋น ๋ฏธ๋์ด ํ์ผ์ ๋ํ ๊ถํ์ ๋ฐ์์ผ ํจ
- ์์ ์ฝ๋
try { contentResolver.openFileDescriptor(image-content-uri, "w")?.use { setGrayscaleFilter(it) } } catch (securityException: SecurityException) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val recoverableSecurityException = securityException as? RecoverableSecurityException ?: throw RuntimeException(securityException.message, securityException) val intentSender = recoverableSecurityException.userAction.actionIntent.intentSender intentSender?.let { //๊ถํ ์์ฒญ startIntentSenderForResult(intentSender, image-request-code, null, 0, 0, 0, null) } } else { throw RuntimeException(securityException.message, securityException) } }
MediaStore๊ฐ ์๋ ๋ค๋ฅธ ๋ฐฉ๋ฒ์ ์ฌ์ฉํด์ผ ํ๋ Use cases
๋ฏธ๋์ด ํ์ผ ์ฌ๋ฌ ๊ฐ๋ฅผ ์ฒ๋ฆฌํ๋ ๊ฒฝ์ฐ
ACTION_OPEN_DOCUMENT_TREE
์ธํ
ํธ ์ฌ์ฉ
๋ค๋ฅธ ํ์ ์ ํ์ผ์ ์ฒ๋ฆฌํ๋ ๊ฒฝ์ฐ
MediaStore์์ ์ธํฐํ์ด์ค๋ฅผ ์ ๊ณตํ์ง ์๋ ๋ฌธ์๋ ๋ค๋ฅธ ํ์ผ์ ACTION_OPEN_DOCUMENT
์ธํ
ํธ ์ฌ์ฉ
Documents & etc.
- ๋ฌธ์๋ฅผ ํฌํจํ ๊ธฐํ ํ์ผ
- Android 4.4๋ถํฐ ACTION_OPEN_DOCUMENT ์ธํ
ํธ ์ถ๊ฐ
- ์์คํ ํ์ผ ์ ํ๊ธฐ๋ฅผ ํตํด ํ์ผ ์ ํ ๊ธฐ๋ฅ
Action | OS Version | ์ญํ |
---|---|---|
ACTION_GET_CONTENT | 1 ~ | ๋จ์ํ ๋ฐ์ดํฐ ์ฝ๊ณ ๊ฐ์ ธ์ค๊ธฐ (์ฌ๋ณธ์ ์ฝ์ด์ด) |
ACTION_OPEN_DOCUMENT | 19 ~ | DocumentProvider์ ํ์ผ์ ์ฅ๊ธฐ์ , ์ง์์ ์ก์ธ์ค ๊ถํ์ด ํ์ํ ๊ฒฝ์ฐ |
ACTION_OPEN_DOCUMENT_TREE | 21 ~ | ๋๋ ํ ๋ฆฌ ์ ํ |
- ๊ฐ๋จํ ํ๋ก์ฐ
- ์ฑ์์ ํ์ผ ์์ฑ/์ด๊ธฐ ๋ฑ ์คํ ๋ฆฌ์ง ๊ด๋ จ ์ธํ ํธ ์คํ
- ์์คํ ํ์ผ ์ ํ๊ธฐ UI ํตํด์ ์ฌ์ฉ์๊ฐ ํ์ผ ๋๋ ๋๋ ํ ๋ฆฌ๋ฅผ ์ ํ
- ์ฑ์ด ํด๋น ํ์ผ ๋๋ ๋๋ ํ ๋ฆฌ์ ๊ถํ์ ์ป์ด URI๋ฅผ ํตํด ์ ๊ทผ
ํ์ผ ์์ฑ
ACTION_CREATE_DOCUMENT
- ์ฌ์ฉ์๊ฐ ์์คํ ํ์ผ ์ ํ๊ธฐ UI์์ ํ์ผ์ ์ ์ฅํ ์์น ์ ํ
-
๋ฎ์ด์ฐ๊ธฐ ์ง์ X -> ์ด๋ฆ์ด ์ค๋ณต๋ ๊ฒฝ์ฐ ํ์ผ ์ด๋ฆ ๋ค์ numbering
- Intent์ ํ์ผ ์ ํ๊ธฐ๊ฐ ์ด๋ฆด ํด๋์ URI ์ง์ ๊ฐ๋ฅ -
EXTRA_INITIAL_URI
- ์ ํจํ์ง ์์ ๊ฒฝ์ฐ ์ด๋ฆด ํด๋๋ฅผ ์์คํ ์ด ์์์ ์ง์
// 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")
// Optional
putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri) //์์คํ
ํ์ผ ์ ํ๊ธฐ๊ฐ ์ด๋ฆด ์์น ์ง์
}
startActivityForResult(intent, CREATE_FILE)
}
ํ์ผ ์ด๊ธฐ
ACTION_OPEN_DOCUMENT
EXTRA_INITIAL_URI
์ง์ ๊ฐ๋ฅ
ํด๋์ ๋ํ ๊ถํ ์ป๊ธฐ
ACTION_OPEN_DOCUMENT_TREE
(๋กค๋ฆฌํ ๋ฒ์ ๋ถํฐ ์ถ๊ฐ)- ์ ํํ ํด๋์ ๋ชจ๋ ํ์ ํ์ผ ๋ฐ ํด๋์ ๋ํ ๊ถํ
EXTRA_INITIAL_URI
์ง์ ๊ฐ๋ฅ
fun openDirectory(pickerInitialUri: Uri) {
// Choose a directory using the system's file picker.
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
// Provide read access to files and sub-directories in the user-selected
// directory.
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
// 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)
}
๊ถํ ํ์ฉํ URI ์ป๊ธฐ
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.
}
}
}
์๊ตฌ์ ์ธ ๊ถํ ์ป๊ธฐ
- ์ผ๋ฐ์ ์ธ ๊ถํ ์์ฒญ์ ๊ธฐ๊ธฐ ์ฌ์์ ์ดํ์ ์ด๊ธฐํ๋จ
- ์๊ตฌ์ ์ธ ๊ถํ์ ์ป์ผ๋ฉด ๊ธฐ๊ธฐ ์ฌ์์ ํ์๋ ๊ถํ ์ ์ง
- ์ฃผ์) ํด๋น ํ์ผ/ํด๋๊ฐ ์ด๋ ๋๋ ์ญ์ ๋ ๊ฒฝ์ฐ ์ ํจํ์ง ์์ผ๋ฏ๋ก, ๊ถํ์ ๋ค์ ๋ฐ์์ผ ํจ
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)
ํ์ผ ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ
val documentsTree = DocumentFile.fromTreeUri(getApplication(), directoryUri) ?: return
val childDocuments = documentsTree.listFiles().toCachingList()
์ญ์ ํ๊ธฐ
DocumentsContract.deleteDocument(applicationContext.contentResolver, uri)
ํ์ผ ์ด๊ธฐ, ์์ ๋ฑ ์์ ์ฝ๋๋
์ฐธ๊ณ ๋งํฌ
Comments