Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

scoped storage #android

Slide 4

Slide 4 text

10:00 android this is typically the file explorer of a brand new phone

Slide 5

Slide 5 text

this is my file explorer, that I periodically clean android 10:00 10:00

Slide 6

Slide 6 text

10:00 10:00 #influencer android

Slide 7

Slide 7 text

10:00 10:00 what? android

Slide 8

Slide 8 text

10:00 10:00 another cache? android

Slide 9

Slide 9 text

10:00 10:00 no longer exists android

Slide 10

Slide 10 text

10:00 10:00 no idea android

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

10:00 10:00 challenge sounds fishy (especially with lower case) share your storage with the hashtag #nomorescatteredfiles

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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 …

Slide 15

Slide 15 text

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)

Slide 16

Slide 16 text

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 (

Slide 17

Slide 17 text

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 (

Slide 18

Slide 18 text

building a gallery #android

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

ANDROID GALLERY APP adapted from IKEA instructions manual

Slide 21

Slide 21 text

getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array?): List { if (files #== null) { return emptyList() } val media = mutableListOf() 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

Slide 22

Slide 22 text

getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array?): List { if (files #== null) { return emptyList() } val media = mutableListOf() 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

Slide 23

Slide 23 text

getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array?): List { if (files #== null) { return emptyList() } val media = mutableListOf() 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

Slide 24

Slide 24 text

getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array?): List { if (files #== null) { return emptyList() } val media = mutableListOf() 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

Slide 25

Slide 25 text

dark ages getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array?): List { if (files #== null) { return emptyList() } val media = mutableListOf() 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 }

Slide 26

Slide 26 text

getMedia(Environment.getExternalStorageDirectory().listFiles()) private fun getMedia(files: Array?): List { if (files #== null) { return emptyList() } val media = mutableListOf() 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

Slide 27

Slide 27 text

suspend fun queryImages(): List { 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().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

Slide 28

Slide 28 text

suspend fun queryImages(): List { 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().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

Slide 29

Slide 29 text

suspend fun queryImages(): List { 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().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

Slide 30

Slide 30 text

suspend fun queryImages(): List { 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().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”

Slide 31

Slide 31 text

suspend fun queryImages(): List { 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().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

Slide 32

Slide 32 text

deprecated suspend fun queryImages(): List { 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().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

Slide 33

Slide 33 text

MediaStore API suspend fun queryImages(): List { 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().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)) … }

Slide 34

Slide 34 text

MediaStore API suspend fun queryImages(): List { 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().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

Slide 35

Slide 35 text

building a gallery ready for scoped storage #scopedstorage

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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 val permissions = arrayOf( Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) ActivityCompat.requestPermissions(this, permissions, PERMISSION_STORAGE) Activity.kt android 6.0+

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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 … AndroidManifest.xml android 11

Slide 42

Slide 42 text

Using MediaStore API Filtering for media types Selecting which attributes we’re looking for listing files

Slide 43

Slide 43 text

deprecated suspend fun queryImages(): List { 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().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

Slide 44

Slide 44 text

scoped storage listing files suspend fun queryImages(): List { 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().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)) … }

Slide 45

Slide 45 text

suspend fun queryImages(): List { 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().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

Slide 46

Slide 46 text

suspend fun queryImages(): List { 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().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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

scoped storage no scoped storage val exifInterface = ExifInterface(image.path) val latLong = exifInterface.latLong Well, remember? No paths.

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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) } scoped storage media location

Slide 53

Slide 53 text

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) } scoped storage media location required on android 10+

Slide 54

Slide 54 text

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) } scoped storage media location Coordinates = (51.9159379, 17.6344794) logcat scoped approved storage

Slide 55

Slide 55 text

No content

Slide 56

Slide 56 text

scoped storage creating a new file Created through the MediaStore API Can be stored either in PICTURES or DCIM folders

Slide 57

Slide 57 text

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) } }

Slide 58

Slide 58 text

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) } }

Slide 59

Slide 59 text

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) }

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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) } }

Slide 62

Slide 62 text

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) } }

Slide 63

Slide 63 text

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) } }

Slide 64

Slide 64 text

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) } }

Slide 65

Slide 65 text

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) } }

Slide 66

Slide 66 text

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) } }

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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) }

Slide 70

Slide 70 text

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) }

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

scoped storage updating media The file was created by our app Request permission to modify third-party files

Slide 73

Slide 73 text

scoped storage updating media fun convertToBW(media: Media) { viewModelScope.launch { saveImageBW(getApplication().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 } } }

Slide 74

Slide 74 text

scoped storage updating media fun convertToBW(media: Media) { viewModelScope.launch { saveImageBW(getApplication().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 } } }

Slide 75

Slide 75 text

scoped storage updating media fun convertToBW(media: Media) { viewModelScope.launch { saveImageBW(getApplication().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) })

Slide 76

Slide 76 text

scoped storage deleting media Erase media from disk Data removed through the MediaStore API No need to additionally send notification broadcasts

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

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?

Slide 82

Slide 82 text

scoped storage scoped storage deleting media

Slide 83

Slide 83 text

new in Android 11 scoped storage #scopedstorage

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

scoped storage bulk operations Better UX One screen shows the media that’s going to be accessed

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

scoped storage bulk operations private fun deleteMediaBulk(resolver: ContentResolver, items: List) { 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

Slide 88

Slide 88 text

Allows to define specific media as favorite Which can be used to give them higher importance Showing them first, for instance scoped storage starred

Slide 89

Slide 89 text

fun addToFavorites(items: List, state: Boolean): PendingIntent { val resolver = getApplication().contentResolver val uris = items.map { it.uri } return MediaStore.createFavoriteRequest(resolver, uris, state) } scoped storage starred

Slide 90

Slide 90 text

val intent = viewModel.addToFavorites(media, true) startIntentSenderForResult(intent.intentSender, PERMISSION_FAVORITES, null, 0, 0, 0) scoped storage starred fun addToFavorites(items: List, state: Boolean): PendingIntent { val resolver = getApplication().contentResolver val uris = items.map { it.uri } return MediaStore.createFavoriteRequest(resolver, uris, state) } available on android 11+ only

Slide 91

Slide 91 text

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

Slide 92

Slide 92 text

fun addToTrash(items: List, state: Boolean): PendingIntent { val resolver = getApplication().contentResolver val uris = items.map { it.uri } return MediaStore.createTrashRequest(resolver, uris, state) } scoped storage trash

Slide 93

Slide 93 text

val intent = viewModel.addToTrash(media, true) startIntentSenderForResult(intent.intentSender, PERMISSION_TRASH, null, 0, 0, 0) scoped storage trash fun addToTrash(items: List, state: Boolean): PendingIntent { val resolver = getApplication().contentResolver val uris = items.map { it.uri } return MediaStore.createTrashRequest(resolver, uris, state) } available on android 11+ only

Slide 94

Slide 94 text

app exceptions scoped storage #scopedstorage

Slide 95

Slide 95 text

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

Slide 96

Slide 96 text

scoped storage exceptions fun openSettingsAllFilesAccess(activity: AppCompatActivity) { val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) activity.startActivity(intent) }

Slide 97

Slide 97 text

migrating files scoped storage #scopedstorage

Slide 98

Slide 98 text

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

Slide 99

Slide 99 text

final remarks scoped storage #scopedstorage

Slide 100

Slide 100 text

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

Slide 101

Slide 101 text

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

Slide 102

Slide 102 text

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