Scoping Scoped Storage

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.

D8a3623b157508fecdae1f8e756f362f?s=128

cmota

July 08, 2020
Tweet

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. scoped storage #android

  4. 10:00 android this is typically the file explorer of a

    brand new phone
  5. this is my file explorer, that I periodically clean android

    10:00 10:00
  6. 10:00 10:00 #influencer android

  7. 10:00 10:00 what? android

  8. 10:00 10:00 another cache? android

  9. 10:00 10:00 no longer exists android

  10. 10:00 10:00 no idea android

  11. 10:00 10:00 sounds fishy (especially with lower case) android

  12. 10:00 10:00 challenge sounds fishy (especially with lower case) share

    your storage with the hashtag #nomorescatteredfiles
  13. 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
  14. 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 …
  15. 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)
  16. 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
  17. 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
  18. building a gallery #android

  19. 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
  20. ANDROID GALLERY APP adapted from IKEA instructions manual

  21. 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
  22. 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
  23. 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
  24. 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
  25. 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 }
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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”
  31. 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
  32. 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
  33. 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)) … }
  34. 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
  35. building a gallery ready for scoped storage #scopedstorage

  36. 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
  37. 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
  38. 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+
  39. <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
  40. 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
  41. 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
  42. Using MediaStore API Filtering for media types Selecting which attributes

    we’re looking for listing files
  43. 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
  44. 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)) … }
  45. 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
  46. 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
  47. 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
  48. val exifInterface = ExifInterface(image.path) val latLong = exifInterface.latLong scoped storage

    no scoped storage
  49. scoped storage no scoped storage val exifInterface = ExifInterface(image.path) val

    latLong = exifInterface.latLong Well, remember? No paths.
  50. 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
  51. 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
  52. 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
  53. 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+
  54. 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
  55. None
  56. scoped storage creating a new file Created through the MediaStore

    API Can be stored either in PICTURES or DCIM folders
  57. 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) } }
  58. 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) } }
  59. 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) }
  60. 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
  61. 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) } }
  62. 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) } }
  63. 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) } }
  64. 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) } }
  65. 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) } }
  66. 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) } }
  67. 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
  68. 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
  69. 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) }
  70. 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) }
  71. 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
  72. scoped storage updating media The file was created by our

    app Request permission to modify third-party files
  73. 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 } } }
  74. 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 } } }
  75. 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) })
  76. scoped storage deleting media Erase media from disk Data removed

    through the MediaStore API No need to additionally send notification broadcasts
  77. 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
  78. 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
  79. 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
  80. 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
  81. 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?
  82. scoped storage scoped storage deleting media

  83. new in Android 11 scoped storage #scopedstorage

  84. 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
  85. scoped storage bulk operations Better UX One screen shows the

    media that’s going to be accessed
  86. 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) }
  87. 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
  88. Allows to define specific media as favorite Which can be

    used to give them higher importance Showing them first, for instance scoped storage starred
  89. 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
  90. 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
  91. 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
  92. 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
  93. 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
  94. app exceptions scoped storage #scopedstorage

  95. 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
  96. scoped storage exceptions <uses-permission android:name=“android.permission.MANAGE_EXTERNAL_STORAGE" #/> fun openSettingsAllFilesAccess(activity: AppCompatActivity) {

    val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) activity.startActivity(intent) }
  97. migrating files scoped storage #scopedstorage

  98. 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
  99. final remarks scoped storage #scopedstorage

  100. 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
  101. 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
  102. 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