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

Scoping Scoped Storage Extended

cmota
September 26, 2020

Scoping Scoped Storage Extended

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

September 26, 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 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-extended
    MATERIALS
    @cafonsomota
    github.com/cmota/ScopedStorage

    View Slide

  3. scoped storage
    #android

    View Slide

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

    View Slide

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

    View Slide

  6. 10:00
    10:00
    #influencer
    android

    View Slide

  7. 10:00
    10:00
    what?
    android

    View Slide

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

    View Slide

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

    View Slide

  10. 10:00
    10:00
    no idea
    android

    View Slide

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

    View Slide

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

    View Slide

  13. android

    View Slide

  14. 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 Slide

  15. 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: privacy
    7
    file provider

    View Slide

  16. timeline: 2020
    you are here
    Dec
    target min API level 29+
    for app updates
    Sep
    Aug
    Jul
    target min API level 29+
    for new apps
    Oct Nov
    developers can start submitting
    their apps for review for
    background location access
    Jun
    May

    View Slide

  17. timeline: 2021?
    you are going to be here
    Dec
    target min API level 30+
    for app updates
    Sep
    Aug?
    Jul
    target min API level 30+
    for new apps
    Oct Nov?
    Jun
    May

    View Slide

  18. 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 Slide

  19. 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
    No other app
    can access*
    Data removed
    Media MediaStore API
    READ_EXTERNAL_STORAGE
    WRITE_EXTERNAL_STORAGE (Yes Nothing
    Other files SAF None
    Through the
    system file
    picker
    Nothing

    View Slide

  20. 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 requirements
    Asking for permissions is always suspicious for the user

    View Slide

  21. building a gallery
    #android

    View Slide

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

  23. ANDROID GALLERY APP
    adapted from IKEA instructions manual

    View 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

    }

    dark ages

    View Slide

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

    }

    dark ages

    View Slide

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

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

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

    View 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
    you can select which
    columns you want to
    access

    View Slide

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

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

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

  35. 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 Slide

  36. 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 Slide

  37. 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 Slide

  38. building a gallery
    ready for scoped
    storage
    #scopedstorage

    View Slide

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

  40. 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 Slide

  41. 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 Slide








  42. 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 Slide

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



    defaultConfig {

    targetSdkVersion 30



    android 11

    View Slide

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



    defaultConfig {

    targetSdkVersion 30




    android:maxSdkVersion="28"#/>




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

    View Slide

  45. permissions
    Allow Playground to access
    Allow Playground to access

    View Slide

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

    View Slide

  47. 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 Slide

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



    }

    no scoped storage

    View Slide

  49. 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 Slide

  50. 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 Slide

  51. 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 Slide

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

    What about listing videos?

    View Slide

  53. suspend fun queryImages(): List {

    withContext(Dispatchers.IO) {

    val projection = arrayOf(

    MediaStore.Video.Media._ID,

    MediaStore.Video.Media.DISPLAY_NAME,

    MediaStore.Video.Media.SIZE,

    MediaStore.Video.Media.DATE_MODIFIED)

    getApplication().contentResolver.query(

    MediaStore.Video.Media.EXTERNAL_CONTENT_URI,

    projection,

    null,

    null,

    "${MediaStore.Video.Media.DATE_MODIFIED} DESC"

    )#?.use { cursor #->

    val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID))

    val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)



    }

    scoped storage
    listing files scoped
    approved
    storage

    View Slide

  54. suspend fun queryImages(): List {

    withContext(Dispatchers.IO) {

    val projection = arrayOf(

    MediaStore.Video.Media._ID,

    MediaStore.Video.Media.DISPLAY_NAME,

    MediaStore.Video.Media.SIZE,

    MediaStore.Video.Media.DATE_MODIFIED)

    getApplication().contentResolver.query(

    MediaStore.Video.Media.EXTERNAL_CONTENT_URI,

    projection,

    null,

    null,

    "${MediaStore.Video.Media.DATE_MODIFIED} DESC"

    )#?.use { cursor #->

    val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID))

    val uri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)



    }

    scoped storage
    listing files scoped
    approved
    storage

    View Slide

  55. 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 Slide

  56. val exifInterface = ExifInterface(image.path)

    val latLong = exifInterface.latLong

    scoped storage
    no scoped storage

    View Slide

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

    val latLong = exifInterface.latLong


    Well, remember? No paths.

    View Slide

  58. 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 Slide

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

  60. scoped storage
    media location
    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]})")

    }

    }

    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

    Media location is really sensitive information
    Malware apps might get access to the user’s home location, work, etc.
    Additional permissions are required for this operation

    View Slide

  61. required on
    android 10+
    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 Slide

  62. required on
    android 10+


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

    }

    }

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

    View Slide

  63. View Slide

  64. scoped storage
    media location


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

    }

    }

    Coordinates = (51.9159379, 17.6344794)
    logcat

    What if we run this code on an Android 11 device?

    View Slide

  65. 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 Slide

  66. 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]})")

    }

    }



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

    View Slide

  67. scoped storage
    media location
    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]})")

    }

    }



    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

    For third-party files you need to request user permission
    Security above everything
    We’re accessing sensitive information

    View Slide

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

  69. 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
    approved
    storage
    scoped storage
    media location

    View Slide

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

    View Slide

  71. 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 Slide

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

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

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

  75. 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 Slide

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

  77. 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 Slide

  78. 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 Slide

  79. 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 Slide

  80. 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 Slide

  81. 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 Slide

  82. 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 storage
    creating new media

    What if we want to save this file on a different location?

    View Slide

  83. suspend fun duplicateMedia(resolver: ContentResolver, media: Media, bitmap: Bitmap) {

    withContext(Dispatchers.IO) {

    val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

    val dirDest = File(Environment.DIRECTORY_PICTURES, context.getString(R.string.app_name))

    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.MediaColumns.RELATIVE_PATH, "$dirDest${File.separator}")

    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)

    }


    scoped storage
    creating new media

    View Slide

  84. 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 dirDest = File(Environment.DIRECTORY_PICTURES, context.getString(R.string.app_name))

    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.MediaColumns.RELATIVE_PATH, "$dirDest${File.separator}")

    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)

    }


    scoped
    approved
    storage
    or DIRECTORY_DCIM

    View Slide

  85. 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 Slide

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

  87. 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 Slide

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

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

    View Slide

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

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

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

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

    View Slide

  94. suspend fun delete(media: Media) {

    withContext(Dispatchers.IO) {

    File(media.path).delete()

    sendScanFileBroadcast(context, target)

    }

    }

    scoped storage
    no scoped storage

    View Slide

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

    val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)

    intent.data = Uri.fromFile(file)

    context.sendBroadcast(intent)

    }

    scoped storage
    no scoped storage
    suspend fun delete(media: Media) {

    withContext(Dispatchers.IO) {

    File(media.path).delete()

    sendScanFileBroadcast(context, target)

    }

    }

    View Slide

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

    val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)

    intent.data = Uri.fromFile(file)

    context.sendBroadcast(intent)

    }

    suspend fun delete(media: Media) {

    withContext(Dispatchers.IO) {

    File(media.path).delete()

    sendScanFileBroadcast(context, target)

    }

    }

    scoped storage
    no scoped storage

    Manually need to notify other apps that you’ve deleted a file
    Might end up with references on MediaStore table from files that no longer exist

    View Slide

  97. scoped storage
    no scoped storage
    suspend fun delete(media: Media) {

    withContext(Dispatchers.IO) {

    File(media.uri.path).delete()

    sendScanFileBroadcast(context, target)

    }

    }

    View Slide

  98. logcat
    E/AndroidRuntime: FATAL EXCEPTION: main

    Process: com.cmota.playground.storage, PID: 8407

    kotlin.io.NoSuchFileException: /external/images/media/74: The source file doesn't exist.

    scoped storage
    no scoped storage
    suspend fun delete(media: Media) {

    withContext(Dispatchers.IO) {

    File(media.uri.path).delete()

    sendScanFileBroadcast(context, target)

    }

    }

    View Slide

  99. suspend fun delete(media: Media) {

    withContext(Dispatchers.IO) {

    File(media.uri.path).delete()

    sendScanFileBroadcast(context, target)

    }

    }

    scoped storage
    no scoped storage
    logcat
    E/AndroidRuntime: FATAL EXCEPTION: main

    Process: com.cmota.playground.storage, PID: 8407

    kotlin.io.NoSuchFileException: /external/images/media/74: The source file doesn't exist.


    Uri is not a direct access to a file
    Even if you retrieve the path from Uri it won’t be a exact path on disk
    You can only delete files your app creates
    Other app files require additional user permission

    View Slide

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

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

  102. 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 Slide

  103. 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 Slide

  104. 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 Slide

  105. scoped storage
    scoped storage
    deleting media

    View Slide

  106. new in Android 11
    scoped storage
    #scopedstorage

    View Slide

  107. 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 Slide

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

    View Slide

  109. 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 Slide

  110. 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 Slide

  111. 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 Slide

  112. 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 Slide

  113. 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 Slide

  114. 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 Slide

  115. 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 Slide

  116. 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 Slide

  117. app exceptions
    scoped storage
    #scopedstorage

    View Slide

  118. 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 Slide

  119. scoped storage
    exceptions


    fun openSettingsAllFilesAccess(activity: AppCompatActivity) {

    val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)

    activity.startActivity(intent)

    }

    scoped
    approved
    storage

    View Slide

  120. scoped storage
    limitations
    App’s internal and external files are still unavailable
    No detailed information about disk usage in-app
    No longer possible to clear all apps cache

    View Slide

  121. fun openNativeFileExplorer(activity: AppCompatActivity) {

    val intent = Intent(Settings.ACTION_MANAGE_STORAGE)

    activity.startActivity(intent)

    }

    scoped storage
    disk usage scoped
    approved
    storage

    View Slide

  122. fun clearAppsCacheFiles(activity: AppCompatActivity) {

    val intent = Intent(Settings.ACTION_CLEAR_APP_CACHE)

    activity.startActivity(intent)

    }

    scoped storage
    clear cache scoped
    approved
    storage

    View Slide

  123. migrating files
    scoped storage
    #scopedstorage

    View Slide

  124. 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 Slide

  125. final remarks
    scoped storage
    #scopedstorage

    View Slide

  126. 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 Slide

  127. 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 Slide

  128. 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 Slide