Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Scoping Scoped Storage

cmota
July 08, 2020

Scoping Scoped Storage

This is still a sensitive subject to most of us due to the impact it had on most apps, but a most needed one! Scoped storage is one of the most important security features that has been released that empowers the user to have the final word when an app is accessing external files.

In this talk, we're going to share our pains about these developments and how everything will get better at the end.

cmota

July 08, 2020
Tweet

More Decks by cmota

Other Decks in Programming

Transcript

  1. 11 10 P N O TBD L JB HC M

    KK ICS F G TBD TBD TBD TBD TBD TBD TBD TBD TBD TBD TBD TBD Scoped Storage Scoping @cafonsomota
  2. Android Dev Lead @WITSoftware Founder @GDGCoimbra and co-founder @Kotlin_Knights ✍

    Author @rwenderlich and @opinioesonline Podcaster wannabe Loves travel, photography and running speakerdeck.com/cmota/scoping-scoped-storage MATERIALS @cafonsomota github.com/cmota/ScopedStorage
  3. 10:00 10:00 challenge sounds fishy (especially with lower case) share

    your storage with the hashtag #nomorescatteredfiles
  4. why Scattered files throughout your disk Files from uninstalled apps

    are kept in disk using up space An app can access any file on external storage after the permission is granted There’s no clear distinction where files should be stored Some times you use full path for files other Uri’s You end up doing “hacks” in some file operations
  5. you are here 10 11 scoped storage (can be opted

    out) scoped storage (mandatory) 6 runtime permissions dark ages 5 install permissions (free for all) 4.4 SAF released (Storage Access Framework) timeline 7 file provider …
  6. concepts No longer rely on file paths to access files,

    but instead Uri’s Files from uninstalled apps are removed (unless if saved through MediaStore/ SAF) MediaStore API is going to be your best friend Easy access to Images, Videos and Audio For other files use SAF Files are now secure and private (as they should be)
  7. directories Type Access method Permissions Access Uninstalled app-specific files (internal

    storage) getFilesDir() /data/user/… Not needed No other app can access Data removed app-specific files (external storage) getExternalFilesDir() /storage/emulated/… Not needed Yes Data removed Media MediaStore API READ_EXTERNAL_STORAGE WRITE_EXTERNAL_STORAGE (<API 28) Yes Nothing Other files SAF None Through the system file picker Nothing
  8. directories Type Access method Permissions Access Uninstalled app-specific files (internal

    storage) getFilesDir() /data/user/… Not needed No other app can access Data removed app-specific files (external storage) getExternalFilesDir() /storage/emulated/… Not needed Yes Data removed Media MediaStore API READ_EXTERNAL_STORAGE WRITE_EXTERNAL_STORAGE (<API 28) Yes Nothing Other files SAF None Through the system file picker Nothing Select the appropriate folder for your app specifications Asking for permissions is always suspicious for the user
  9. Get all media files Filter by images, videos, starred and

    trashed items Create a new file Update one or more existing files Delete one or more files features
  10. getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array<File>?): List<Media> { if (files #==

    null) { return emptyList() } val media = mutableListOf<Media>() for (file in files) { if (file.isDirectory) { media += getMedia(file.listFiles()) } else { if (isAnImage(file.extension)) { media += Media(file.path, file.name, file.length(), file.lastModified()) } } } return media } dark ages
  11. getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array<File>?): List<Media> { if (files #==

    null) { return emptyList() } val media = mutableListOf<Media>() for (file in files) { if (file.isDirectory) { media += getMedia(file.listFiles()) } else { if (isAnImage(file.extension)) { media += Media(file.path, file.name, file.length(), file.lastModified()) } } } return media } dark ages getting all files from disk
  12. getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array<File>?): List<Media> { if (files #==

    null) { return emptyList() } val media = mutableListOf<Media>() for (file in files) { if (file.isDirectory) { media += getMedia(file.listFiles()) } else { if (isAnImage(file.extension)) { media += Media(file.path, file.name, file.length(), file.lastModified()) } } } return media } dark ages
  13. getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array<File>?): List<Media> { if (files #==

    null) { return emptyList() } val media = mutableListOf<Media>() for (file in files) { if (file.isDirectory) { media += getMedia(file.listFiles()) } else { if (isAnImage(file.extension)) { media += Media(file.path, file.name, file.length(), file.lastModified()) } } } return media } deprecated dark ages
  14. dark ages getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array<File>?): List<Media> { if

    (files #== null) { return emptyList() } val media = mutableListOf<Media>() for (file in files) { if (file.isDirectory) { media += getMedia(file.listFiles()) } else { if (isAnImage(file.extension)) { media += Media(file.path, file.name, file.length(), file.lastModified()) } } } return media }
  15. getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array<File>?): List<Media> { if (files #==

    null) { return emptyList() } val media = mutableListOf<Media>() for (file in files) { if (file.isDirectory) { media += getMedia(file.listFiles()) } else { if (isAnImage(file.extension)) { media += Media(file.path, file.name, file.length(), file.lastModified()) } } } return media } Well… not the best approach out there Bad performance Doesn’t take into account that new files can be added/ modified during iteration Uses deprecated methods Individually check for files media type dark ages
  16. suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection =

    arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … } MediaStore API
  17. suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection =

    arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … } MediaStore API you can select which columns you want to access
  18. suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection =

    arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … } MediaStore API
  19. suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection =

    arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … } MediaStore API You can filter the results by adding an instruction, for instance: “${MediaStore.Images.Media.IS_FAVORITE} = 1”
  20. suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection =

    arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … } MediaStore API
  21. deprecated suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection

    = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … } deprecated MediaStore API
  22. MediaStore API suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val

    projection = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … }
  23. MediaStore API suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val

    projection = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … } Files should no longer be accessed via direct paths Uri’s increase data security
  24. scoped storage not possible to disable requires runtime permissions target:

    android 10 target: android 11 target: a all other android versions android 6.0+ scoped storage enabled by default accessing files
  25. requires runtime permissions target: android 10 target: android 11 target:

    a all other android versions android 6.0+ scoped storage enabled by default scoped storage not possible to disable accessing files
  26. scoped storage not possible to disable requires runtime permissions AndroidManifest.xml

    target: android 10 target: android 11 target: a all other android versions <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" #/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" #/> android 6.0+ scoped storage enabled by default val permissions = arrayOf( Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) ActivityCompat.requestPermissions(this, permissions, PERMISSION_STORAGE) Activity.kt android 6.0+
  27. <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" #/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" #/> <application … android:requestLegacyExternalStorage="true"> scoped

    storage not possible to disable requires runtime permissions AndroidManifest.xml target: android 10 target: android 11 target: a all other android versions android 6.0+ scoped storage enabled by default to disable android 10
  28. scoped storage not possible to disable requires runtime permissions target:

    android 10 target: android 11 target: a all other android versions android 6.0+ scoped storage enabled by default build.gradle android { compileSdkVersion 'android-R' defaultConfig { targetSdkVersion 30 … android 11
  29. scoped storage not possible to disable requires runtime permissions target:

    android 10 target: android 11 target: a all other android versions android 6.0+ scoped storage enabled by default although it can be preserved build.gradle android { compileSdkVersion 'android-R' defaultConfig { targetSdkVersion 30 … <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"#/> <application … android:preserveLegacyExternalStorage="true"> AndroidManifest.xml android 11
  30. deprecated suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection

    = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … } deprecated no scoped storage
  31. scoped storage listing files suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO)

    { val projection = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA)) … }
  32. suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection =

    arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) … } scoped storage listing files
  33. suspend fun queryImages(): List<Media> { withContext(Dispatchers.IO) { val projection =

    arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_MODIFIED) getApplication<Application>().contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_MODIFIED} DESC" )#?.use { cursor #-> val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) … } scoped storage listing files scoped approved storage
  34. scoped storage media location Allows to retrieve image coordinates The

    information is stored in the image metadata Sensitive information It can be the user home location, for instance
  35. scoped storage no scoped storage val exifInterface = ExifInterface(image.path) val

    latLong = exifInterface.latLong Well, remember? No paths.
  36. val uri = MediaStore.setRequireOriginal(media[0].uri) contentResolver.openInputStream(uri).use { stream #-> ExifInterface(stream#!!).run {

    val coordinates = latLong#!!.toList() Log.d("TAG", "Coordinates = (${coordinates[0]}, ${coordinates[0]})") } } scoped storage media location
  37. E/AndroidRuntime: FATAL EXCEPTION: main Process: com.cmota.playground.storage, PID: 21205 java.lang.UnsupportedOperationException: Caller

    must hold ACCESS_MEDIA_LOCATION permission to access original logcat val uri = MediaStore.setRequireOriginal(media[0].uri) contentResolver.openInputStream(uri).use { stream #-> ExifInterface(stream#!!).run { val coordinates = latLong#!!.toList() Log.d("TAG", "Coordinates = (${coordinates[0]}, ${coordinates[0]})") } } scoped storage media location
  38. if (ActivityCompat.checkSelfPermission(baseContext, Manifest.permission.ACCESS_MEDIA_LOCATION) #== PackageManager.PERMISSION_GRANTED) { val uri = MediaStore.setRequireOriginal(media[0].uri)

    contentResolver.openInputStream(uri).use { stream #-> ExifInterface(stream#!!).run { val coordinates = latLong#!!.toList() Log.d("TAG", "Coordinates = (${coordinates[0]}, ${coordinates[0]})") } } } else { ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.ACCESS_MEDIA_LOCATION), PERMISSION_MEDIA_ACCESS) } <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" #/> scoped storage media location
  39. if (ActivityCompat.checkSelfPermission(baseContext, Manifest.permission.ACCESS_MEDIA_LOCATION) #== PackageManager.PERMISSION_GRANTED) { val uri = MediaStore.setRequireOriginal(media[0].uri)

    contentResolver.openInputStream(uri).use { stream #-> ExifInterface(stream#!!).run { val coordinates = latLong#!!.toList() Log.d("TAG", "Coordinates = (${coordinates[0]}, ${coordinates[0]})") } } } else { ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.ACCESS_MEDIA_LOCATION), PERMISSION_MEDIA_ACCESS) } <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" #/> scoped storage media location required on android 10+
  40. if (ActivityCompat.checkSelfPermission(baseContext, Manifest.permission.ACCESS_MEDIA_LOCATION) #== PackageManager.PERMISSION_GRANTED) { val uri = MediaStore.setRequireOriginal(media[0].uri)

    contentResolver.openInputStream(uri).use { stream #-> ExifInterface(stream#!!).run { val coordinates = latLong#!!.toList() Log.d("TAG", "Coordinates = (${coordinates[0]}, ${coordinates[0]})") } } } else { ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.ACCESS_MEDIA_LOCATION), PERMISSION_MEDIA_ACCESS) } <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" #/> scoped storage media location Coordinates = (51.9159379, 17.6344794) logcat scoped approved storage
  41. scoped storage creating a new file Created through the MediaStore

    API Can be stored either in PICTURES or DCIM folders
  42. scoped storage no scoped storage fun duplicate(media: Media) { viewModelScope.launch

    { val file = File(media.path) val target = File("${file.parent}/${file.nameWithoutExtension}-copy.${file.extension}") file.copyTo(target, true) sendScanFileBroadcast(this, target) } }
  43. scoped storage no scoped storage fun duplicate(media: Media) { viewModelScope.launch

    { val file = File(media.path) val target = File("${file.parent}/${file.nameWithoutExtension}-copy.${file.extension}") file.copyTo(target, true) sendScanFileBroadcast(this, target) } }
  44. scoped storage no scoped storage fun duplicate(media: Media) { viewModelScope.launch

    { val file = File(media.path) val target = File("${file.parent}/${file.nameWithoutExtension}-copy.${file.extension}") file.copyTo(target, true) sendScanFileBroadcast(this, target) } } fun sendScanFileBroadcast(context: Context, file: File) { val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) intent.data = Uri.fromFile(file) context.sendBroadcast(intent) }
  45. no scoped storage fun duplicate(media: Media) { viewModelScope.launch { val

    file = File(media.path) val target = File("${file.parent}/${file.nameWithoutExtension}-copy.${file.extension}") file.copyTo(target, true) sendScanFileBroadcast(this, target) } } fun sendScanFileBroadcast(context: Context, file: File) { val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) intent.data = Uri.fromFile(file) context.sendBroadcast(intent) } No update on MediaStore tables You could create a file almost anywhere on disk
  46. scoped storage creating new media suspend fun duplicateMedia(resolver: ContentResolver, media:

    Media, bitmap: Bitmap) { withContext(Dispatchers.IO) { val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val newMedia = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, “${media.name}-cp.${media.extension}") put(MediaStore.MediaColumns.MIME_TYPE, "image/png") put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis()) put(MediaStore.Images.Media.IS_PENDING, 1) } val newMediaUri = resolver.insert(collection, newMedia) resolver.openOutputStream(newMediaUri#!!, "w").use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } newMedia.clear() newMedia.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(newMediaUri, newMedia, null, null) } }
  47. scoped storage creating new media suspend fun duplicateMedia(resolver: ContentResolver, media:

    Media, bitmap: Bitmap) { withContext(Dispatchers.IO) { val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val newMedia = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, “${media.name}-cp.${media.extension}") put(MediaStore.MediaColumns.MIME_TYPE, "image/png") put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis()) put(MediaStore.Images.Media.IS_PENDING, 1) } val newMediaUri = resolver.insert(collection, newMedia) resolver.openOutputStream(newMediaUri#!!, "w").use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } newMedia.clear() newMedia.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(newMediaUri, newMedia, null, null) } }
  48. scoped storage creating new media suspend fun duplicateMedia(resolver: ContentResolver, media:

    Media, bitmap: Bitmap) { withContext(Dispatchers.IO) { val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val newMedia = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, “${media.name}-cp.${media.extension}") put(MediaStore.MediaColumns.MIME_TYPE, "image/png") put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis()) put(MediaStore.Images.Media.IS_PENDING, 1) } val newMediaUri = resolver.insert(collection, newMedia) resolver.openOutputStream(newMediaUri#!!, "w").use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } newMedia.clear() newMedia.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(newMediaUri, newMedia, null, null) } }
  49. scoped storage creating new media suspend fun duplicateMedia(resolver: ContentResolver, media:

    Media, bitmap: Bitmap) { withContext(Dispatchers.IO) { val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val newMedia = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, “${media.name}-cp.${media.extension}") put(MediaStore.MediaColumns.MIME_TYPE, "image/png") put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis()) put(MediaStore.Images.Media.IS_PENDING, 1) } val newMediaUri = resolver.insert(collection, newMedia) resolver.openOutputStream(newMediaUri#!!, "w").use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } newMedia.clear() newMedia.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(newMediaUri, newMedia, null, null) } }
  50. scoped storage creating new media suspend fun duplicateMedia(resolver: ContentResolver, media:

    Media, bitmap: Bitmap) { withContext(Dispatchers.IO) { val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val newMedia = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, “${media.name}-cp.${media.extension}") put(MediaStore.MediaColumns.MIME_TYPE, "image/png") put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis()) put(MediaStore.Images.Media.IS_PENDING, 1) } val newMediaUri = resolver.insert(collection, newMedia) resolver.openOutputStream(newMediaUri#!!, "w").use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } newMedia.clear() newMedia.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(newMediaUri, newMedia, null, null) } }
  51. scoped storage creating new media suspend fun duplicateMedia(resolver: ContentResolver, media:

    Media, bitmap: Bitmap) { withContext(Dispatchers.IO) { val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val newMedia = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, “${media.name}-cp.${media.extension}") put(MediaStore.MediaColumns.MIME_TYPE, "image/png") put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis()) put(MediaStore.Images.Media.IS_PENDING, 1) } val newMediaUri = resolver.insert(collection, newMedia) resolver.openOutputStream(newMediaUri#!!, "w").use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } newMedia.clear() newMedia.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(newMediaUri, newMedia, null, null) } }
  52. scoped storage creating new media suspend fun duplicateMedia(resolver: ContentResolver, media:

    Media, bitmap: Bitmap) { withContext(Dispatchers.IO) { val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val newMedia = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, “${media.name}-cp.${media.extension}") put(MediaStore.MediaColumns.MIME_TYPE, "image/png") put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis()) put(MediaStore.Images.Media.IS_PENDING, 1) } val newMediaUri = resolver.insert(collection, newMedia) resolver.openOutputStream(newMediaUri#!!, "w").use { bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) } newMedia.clear() newMedia.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(newMediaUri, newMedia, null, null) } } scoped approved storage
  53. scoped storage defining a location Location selected through system file

    picker The user can select where to save the file And change it’s name
  54. scoped storage specific location fun openLocationPicker(media: Media) { val intent

    = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_TITLE, media.name) type = "image#/*" } startActivityForResult(intent, PERMISSION_SAVE_ON_LOCATION) }
  55. scoped storage specific location fun openLocationPicker(media: Media) { val intent

    = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_TITLE, media.name) type = "image#/*" } startActivityForResult(intent, PERMISSION_SAVE_ON_LOCATION) }
  56. scoped storage specific location fun openLocationPicker(media: Media) { val intent

    = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_TITLE, media.name) type = "image#/*" } startActivityForResult(intent, PERMISSION_SAVE_ON_LOCATION) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (resultCode #== Activity.RESULT_OK) { when(requestCode) { PERMISSION_SAVE_ON_LOCATION #-> duplicateMedia(data#?.data#!!) … scoped approved storage
  57. scoped storage updating media The file was created by our

    app Request permission to modify third-party files
  58. scoped storage updating media fun convertToBW(media: Media) { viewModelScope.launch {

    saveImageBW(getApplication<Application>().contentResolver, media, paint) } catch (securityException: SecurityException) { if (hasSdkHigherThan(Build.VERSION_CODES.Q)) { val recoverableSecurityException = securityException as? RecoverableSecurityException #?: throw securityException _permissionNeededForUpdate.postValue( recoverableSecurityException.userAction.actionIntent.intentSender) } else throw securityException } } }
  59. scoped storage updating media fun convertToBW(media: Media) { viewModelScope.launch {

    saveImageBW(getApplication<Application>().contentResolver, media, paint) } catch (securityException: SecurityException) { if (hasSdkHigherThan(Build.VERSION_CODES.Q)) { val recoverableSecurityException = securityException as? RecoverableSecurityException #?: throw securityException _permissionNeededForUpdate.postValue( recoverableSecurityException.userAction.actionIntent.intentSender) } else throw securityException } } }
  60. scoped storage updating media fun convertToBW(media: Media) { viewModelScope.launch {

    saveImageBW(getApplication<Application>().contentResolver, media, paint) } catch (securityException: SecurityException) { if (hasSdkHigherThan(Build.VERSION_CODES.Q)) { val recoverableSecurityException = securityException as? RecoverableSecurityException #?: throw securityException _permissionNeededForUpdate.postValue( recoverableSecurityException.userAction.actionIntent.intentSender) } else throw securityException } } } scoped approved storage viewModel.permissionNeededForUpdate.observe(this, Observer { sender #-> startIntentSenderForResult(sender, PERMISSION_UPDATE, null, 0, 0, 0, null) })
  61. scoped storage deleting media Erase media from disk Data removed

    through the MediaStore API No need to additionally send notification broadcasts
  62. suspend fun delete(resolver: ContentResolver, item: Media): IntentSender? { withContext(Dispatchers.IO) {

    try { val uri = item.uri resolver.delete(uri, "${MediaStore.Images.Media._ID} = ?", arrayOf(item.id.toString())) } catch (securityException: SecurityException) { if (hasSdkHigherThan(Build.VERSION_CODES.P)) { val recoverableSecurityException = securityException as? RecoverableSecurityException #?: throw securityException return recoverableSecurityException.userAction.actionIntent.intentSender } else throw securityException } } return null } scoped storage scoped storage deleting media
  63. suspend fun delete(resolver: ContentResolver, item: Media): IntentSender? { withContext(Dispatchers.IO) {

    try { val uri = item.uri resolver.delete(uri, "${MediaStore.Images.Media._ID} = ?", arrayOf(item.id.toString())) } catch (securityException: SecurityException) { if (hasSdkHigherThan(Build.VERSION_CODES.P)) { val recoverableSecurityException = securityException as? RecoverableSecurityException #?: throw securityException return recoverableSecurityException.userAction.actionIntent.intentSender } else throw securityException } } return null } scoped storage scoped storage deleting media
  64. suspend fun delete(resolver: ContentResolver, item: Media): IntentSender? { withContext(Dispatchers.IO) {

    try { val uri = item.uri resolver.delete(uri, "${MediaStore.Images.Media._ID} = ?", arrayOf(item.id.toString())) } catch (securityException: SecurityException) { if (hasSdkHigherThan(Build.VERSION_CODES.P)) { val recoverableSecurityException = securityException as? RecoverableSecurityException #?: throw securityException return recoverableSecurityException.userAction.actionIntent.intentSender } else throw securityException } } return null } scoped storage scoped storage deleting media
  65. suspend fun delete(resolver: ContentResolver, item: Media): IntentSender? { withContext(Dispatchers.IO) {

    try { val uri = item.uri resolver.delete(uri, "${MediaStore.Images.Media._ID} = ?", arrayOf(item.id.toString())) } catch (securityException: SecurityException) { if (hasSdkHigherThan(Build.VERSION_CODES.P)) { val recoverableSecurityException = securityException as? RecoverableSecurityException #?: throw securityException return recoverableSecurityException.userAction.actionIntent.intentSender } else throw securityException } } return null } scoped storage scoped storage deleting media viewModel.permissionNeededForDelete.observe(this, Observer { sender #-> startIntentSenderForResult(sender, PERMISSION_DELETE, null, 0, 0, 0, null) }) scoped approved storage
  66. suspend fun delete(resolver: ContentResolver, item: Media): IntentSender? { withContext(Dispatchers.IO) {

    try { val uri = item.uri resolver.delete(uri, "${MediaStore.Images.Media._ID} = ?", arrayOf(item.id.toString())) } catch (securityException: SecurityException) { if (hasSdkHigherThan(Build.VERSION_CODES.P)) { val recoverableSecurityException = securityException as? RecoverableSecurityException #?: throw securityException return recoverableSecurityException.userAction.actionIntent.intentSender } else throw securityException } } return null } scoped storage scoped storage deleting media viewModel.permissionNeededForDelete.observe(this, Observer { sender #-> startIntentSenderForResult(sender, PERMISSION_DELETE, null, 0, 0, 0, null) }) What if we have multiple files to delete?
  67. scoped storage android 11 Changes on ACTION_OPEN_DOCUMENT and ACTION_OPEN_DOCUMENT_TREE No

    longer access to root folders of internal storage/sd card Android/data and Android/obb are also blocked Downloads/ blocked for ACTION_OPEN_DOCUMENT_TREE File paths can be used on native libraries Bulk operations Starred and trash categories
  68. scoped storage bulk operations private fun deleteMediaBulk(resolver: ContentResolver, items: List<Media>)

    { val uris = items.map { it.uri } _permissionNeededForDelete.postValue( MediaStore.createDeleteRequest(resolver, uris).intentSender) }
  69. scoped storage bulk operations private fun deleteMediaBulk(resolver: ContentResolver, items: List<Media>)

    { val uris = items.map { it.uri } _permissionNeededForDelete.postValue( MediaStore.createDeleteRequest(resolver, uris).intentSender) } viewModel.permissionNeededForDelete.observe(this, Observer { sender #-> startIntentSenderForResult(sender, PERMISSION_DELETE, null, 0, 0, 0, null) }) available on android 11+ only
  70. Allows to define specific media as favorite Which can be

    used to give them higher importance Showing them first, for instance scoped storage starred
  71. fun addToFavorites(items: List<Media>, state: Boolean): PendingIntent { val resolver =

    getApplication<Application>().contentResolver val uris = items.map { it.uri } return MediaStore.createFavoriteRequest(resolver, uris, state) } scoped storage starred
  72. val intent = viewModel.addToFavorites(media, true) startIntentSenderForResult(intent.intentSender, PERMISSION_FAVORITES, null, 0, 0,

    0) scoped storage starred fun addToFavorites(items: List<Media>, state: Boolean): PendingIntent { val resolver = getApplication<Application>().contentResolver val uris = items.map { it.uri } return MediaStore.createFavoriteRequest(resolver, uris, state) } available on android 11+ only
  73. It’s different from delete Files can be restored during a

    certain period of time It will be automatically deleted after 30 days Similar behaviour of your computer recycler bin scoped storage trash
  74. fun addToTrash(items: List<Media>, state: Boolean): PendingIntent { val resolver =

    getApplication<Application>().contentResolver val uris = items.map { it.uri } return MediaStore.createTrashRequest(resolver, uris, state) } scoped storage trash
  75. val intent = viewModel.addToTrash(media, true) startIntentSenderForResult(intent.intentSender, PERMISSION_TRASH, null, 0, 0,

    0) scoped storage trash fun addToTrash(items: List<Media>, state: Boolean): PendingIntent { val resolver = getApplication<Application>().contentResolver val uris = items.map { it.uri } return MediaStore.createTrashRequest(resolver, uris, state) } available on android 11+ only
  76. scoped storage exceptions File managers and backup apps Need to

    access the entire file system The user needs to manually grant this access Google Play needs to also approve this access Playground
  77. scoped storage migration How to migrate data? Remember: users cannot

    lose their apps data! Depends on your app and it’s use cases You can role out an app update targeting android 10 With requestLegacyStorage enable Migrate all files to the new directories before targeting android 11 You can use preserveLegacyStorage for app updates Rethink your app UX
  78. scoped storage TL;DR Scoped storage is mandatory when targeting Android

    11 It can be disabled on Android 10 via requestLegacyStorage Redesign your app to use the appropriate folders Don’t forget to migrating files You can still use preserveLegacyStorage if it’s an app update Use MediaStore API for accessing images, videos and audio Files are accessible via Uri’s and not file paths SAF (Storage Access Framework) should be used for other shareable content If your app didn’t created that file you’ll need to ask for user permission
  79. scoped storage useful links Storage updates in Android 11 developer.android.com/preview/privacy/storage

    Preparing for Scoped Storage raywenderlich.com/10217168-preparing-for-scoped-storage Storage access with Android 11 youtube.com/watch?v=RjyYCUW-9tY Android 11 Storage FAQ medium.com/androiddevelopers/android-11-storage-faq-78cefea52b7c Bringing modern storage to Viber’s users android-developers.googleblog.com/2020/07/bringing-modern-storage-to-vibers-users.html
  80. 10 P N O TBD L JB HC M KK

    ICS F G TBD TBD TBD TBD TBD TBD TBD TBD TBD TBD TBD TBD Scoped Storage 11 @cafonsomota github.com/cmota/ScopedStorage