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

    View full-size slide

  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

    View full-size slide

  3. scoped storage
    #android

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  6. 10:00
    10:00
    #influencer
    android

    View full-size slide

  7. 10:00
    10:00
    what?
    android

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  10. 10:00
    10:00
    no idea
    android

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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)

    View full-size slide

  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 (Yes Nothing
    Other files SAF None
    Through the
    system file
    picker
    Nothing

    View full-size slide

  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 (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

    View full-size slide

  18. building a gallery
    #android

    View full-size slide

  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

    View full-size slide

  20. ANDROID GALLERY APP
    adapted from IKEA instructions manual

    View full-size slide

  21. 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

    View full-size slide

  22. 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

    View full-size slide

  23. 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

    View full-size slide

  24. 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

    View full-size slide

  25. 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

    }


    View full-size slide

  26. 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

    View full-size slide

  27. 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

    View full-size slide

  28. 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

    View full-size slide

  29. 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

    View full-size slide

  30. 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”

    View full-size slide

  31. 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

    View full-size slide

  32. 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

    View full-size slide

  33. 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))



    }


    View full-size slide

  34. 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

    View full-size slide

  35. building a gallery
    ready for scoped
    storage
    #scopedstorage

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  38. 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+

    View full-size slide








  39. 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

    View full-size slide

  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

    View full-size slide

  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




    android:maxSdkVersion="28"#/>




    android:preserveLegacyExternalStorage="true">
    AndroidManifest.xml
    android 11

    View full-size slide

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

    View full-size slide

  43. 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

    View full-size slide

  44. 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))



    }

    View full-size slide

  45. 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

    View full-size slide

  46. 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

    View full-size slide

  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

    View full-size slide

  48. val exifInterface = ExifInterface(image.path)

    val latLong = exifInterface.latLong

    scoped storage
    no scoped storage

    View full-size slide

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

    val latLong = exifInterface.latLong


    Well, remember? No paths.

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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)

    }



    scoped storage
    media location

    View full-size slide

  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)

    }



    scoped storage
    media location
    required on
    android 10+

    View full-size slide

  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)

    }



    scoped storage
    media location
    Coordinates = (51.9159379, 17.6344794)
    logcat
    scoped
    approved
    storage

    View full-size slide

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

    View full-size slide

  56. 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)

    }

    }

    View full-size slide

  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)

    }

    }

    View full-size slide

  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)

    }

    }

    fun sendScanFileBroadcast(context: Context, file: File) {

    val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)

    intent.data = Uri.fromFile(file)

    context.sendBroadcast(intent)

    }

    View full-size slide

  59. 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

    View full-size slide

  60. 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)

    }

    }

    View full-size slide

  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)

    }

    }

    View full-size slide

  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)

    }

    }

    View full-size slide

  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)

    }

    }

    View full-size slide

  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)

    }

    }

    View full-size slide

  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)

    }

    }

    View full-size slide

  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)

    }

    }

    scoped
    approved
    storage

    View full-size slide

  67. 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

    View full-size slide

  68. 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)

    }

    View full-size slide

  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)

    }

    View full-size slide

  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)

    }

    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

    View full-size slide

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

    View full-size slide

  72. 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

    }

    }

    }

    View full-size slide

  73. 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

    }

    }

    }

    View full-size slide

  74. 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)

    })

    View full-size slide

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

    View full-size slide

  76. 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

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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
    viewModel.permissionNeededForDelete.observe(this, Observer { sender #->

    startIntentSenderForResult(sender, PERMISSION_DELETE, null, 0, 0, 0, null)

    })

    scoped
    approved
    storage

    View full-size slide

  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)

    })


    What if we have multiple files to delete?

    View full-size slide

  81. scoped storage
    scoped storage
    deleting media

    View full-size slide

  82. new in Android 11
    scoped storage
    #scopedstorage

    View full-size slide

  83. 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

    View full-size slide

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

    View full-size slide

  85. scoped storage
    bulk operations
    private fun deleteMediaBulk(resolver: ContentResolver, items: List) {

    val uris = items.map { it.uri }

    _permissionNeededForDelete.postValue(

    MediaStore.createDeleteRequest(resolver, uris).intentSender)

    }

    View full-size slide

  86. 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

    View full-size slide

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

    View full-size slide

  88. 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

    View full-size slide

  89. 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

    View full-size slide

  90. 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

    View full-size slide

  91. 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

    View full-size slide

  92. 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

    View full-size slide

  93. app exceptions
    scoped storage
    #scopedstorage

    View full-size slide

  94. 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

    View full-size slide

  95. scoped storage
    exceptions


    fun openSettingsAllFilesAccess(activity: AppCompatActivity) {

    val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)

    activity.startActivity(intent)

    }

    View full-size slide

  96. migrating files
    scoped storage
    #scopedstorage

    View full-size slide

  97. 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

    View full-size slide

  98. final remarks
    scoped storage
    #scopedstorage

    View full-size slide

  99. 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

    View full-size slide

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

    View full-size slide

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

    View full-size slide