TL;DR

 

Starting from Android 10, Android Started to make some new changes on how developers can deal with files and storage. for example, now you can’t read files from External Storage directly, where every app should store his data in a ‘container-like’ way where every app cannot access other apps’ files.

At the same time they provided some APIs where you can save some files into ‘shared storage’ like files, images,video,etc.. which we will see later in this blog post.

Developers were not ready yet to adopt these changes in their apps (when Android 10 was released) and for that Android allowed developers to opt-out from this option if your app was targeting Android 10 and above. 

*For sure this option is not available anymore if your app is targeting Android 11 and above.

enough talking, let’s take a look on how we currently save files on Android and how we can adopt Scoped storage.

Writing files in Internal Storage

This considered as ‘Private’ which user cannot access it even with using a File Explorer, unless the user has Root permissions. Let’s create a text file that contains a password and we will write it to this Storage

 

val password = "pwd"
val file = File(filesDir,"my_secret_pwd.txt")
file.writeText(password)

If we ran the app, We can see that the file has been created successfully

* It’s worth mentioning that these files will be deleted when the user uninstalls the app.

 

Writing files in External Storage

This storage usually would be in this path: ‘sdcard/Android/data/your.package.name which also will be delete when the user uninstalls the app.

val password = "pwd"
val file = File(getExternalFilesDir(null),"my_secret_pwd.txt")
file.writeText(password)

 

Writing files in External Storage (Legacy)

Now let’s try to write the same file in External Storage (the Shared one), we will test it against Android 10 ‘API 29’ and we’ll see what will happen.

And as we know we have to request Runtime permissions first, so we’ve used a lambda from a library that makes it easier for us

private fun writeTextToFile() {
     askPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE){
         val password = "pwd"
         val file = File(Environment.getExternalStorageDirectory(), "my_secret_pwd.txt")
         file.writeText(password)
     }
 }

It’s worth mentioning that `Environment.getExternalStorageDirectory()` is deprecated

Let’s try to run the app

 

As we can see it throws an exception that permission is denied even though the user has given permissions already, but since we’re targeting SDK 29, the system will prevent any direct write to this storage 

We can use a ‘hack’ to use it, but this can ONLY work if you’re target SDK 29.

go to Android Manifest and add this code

android:requestLegacyExternalStorage="true"

 

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.devlomi.scopedstoragesample">

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:requestLegacyExternalStorage="true"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.ScopedStorageSample">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

if we try to run the app again it would work normally

NOTE: ALL above applies for reading files as well

So what’s the solution if we need to save files to a Storage the persist even if the user uninstalls the app AND works while targeting SDK 30+, which most app are.

Writing files in Scoped Storage

Android started to provide us with a way to start writing files to Shared storage using ‘MediaStore’ APIs to the following directories:

  • Downloads: to store any kind of files like text, apk, pdf, doc, etc..
  • DCIM, Pictures, Movies: for Videos & Images
  • Music, Notifications, Ringtones: for Music files

Now we need to go to Android Manifest and change the write permission to the following:

<uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />

in this way we told the app that you can use the WRITE_EXTERNAL_STORAGE permission ONLY while using API 28 or below, since Writing permissions are no longer required in Scoped Storage on API 29+.

Let’s try to create a text file using MediaStore API

fun writeTextToFile() {
       val password = "pwd"
       val fileName = "my_secret_pwd.txt"

       if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

           val downloadsCollection =
               MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

           val contentValues = ContentValues().apply {
               put(MediaStore.Downloads.DISPLAY_NAME, fileName)
           }

           contentResolver.insert(downloadsCollection, contentValues)?.also { uri ->
               contentResolver.openOutputStream(uri)?.use { outputStream ->
                   outputStream.write(password.toByteArray())
               }
           }
       }

   }

 

Let’s break it down a bit, first we check if the Android version is 10 or above

then we created a collection ‘the Download collection’ which we will save the file into

then we create content value which defines the file name, MIME type, Location and other stuff you can explore it by yourself.

In our case we only added a file name, which is sufficient for our case.

Lastly we called the ‘insert’ function which takes these data and pass it over to the system to create the file. at the same time this function returns a ‘Uri’. 

You can consider this URI as a unique ID, where every item has its own URI stored in the Phone Database.

Then we opened an output stream to start actually writing this file to the system, and we converted the string to byte array and write it to the device using the output stream.

Let’s run the app

as we can see the file was created successfully!

What if we needed to keep our files organized inside a folder for example? eg. ‘Downloads/MyFolder/file.txt It’s pretty simple, all we have to do is passing a RelativePath while creating the ContentValue Object

fun writeTextToFile() {
    val password = "pwd"
    val fileName = "my_secret_pwd.txt"

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

        val downloadsCollection =
            MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

        val contentValues = ContentValues().apply {
            put(MediaStore.Downloads.DISPLAY_NAME, fileName)
            put(
                MediaStore.Downloads.RELATIVE_PATH,
                "${Environment.DIRECTORY_DOWNLOADS}/Devlomi/"
            )
        }

        contentResolver.insert(downloadsCollection, contentValues)?.also { uri ->
            contentResolver.openOutputStream(uri)?.use { outputStream ->
                outputStream.write(password.toByteArray())
            }
        }
    }

}

 

Let’s run the app

And as we can see the file was saved under Download/Devlomi folder as we specified.

What about Images or Videos?  Let’s try to take an image from the Gallery and save it into our folder

val contract = ActivityResultContracts.StartActivityForResult()
 val launcher = registerForActivityResult(contract){result ->
     if (result.resultCode == RESULT_OK){
         result.data?.data?.let { imageUri -> 
             saveImage(imageUri)
         }
     }
 }

 val photoPickerIntent = Intent(Intent.ACTION_PICK)
 photoPickerIntent.type = "image/*"
 launcher.launch(photoPickerIntent)

We created a contract to get an image from the gallery which return back a Uri if the picking process was completed successfully

now let’s create the method saveImage()

private fun saveImage(imageUri: Uri) {


    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

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

        val contentValues = ContentValues().apply {
           put(MediaStore.Images.Media.DISPLAY_NAME, "Wallpaper.jpg") 
        }

        contentResolver.insert(picturesCollection, contentValues)?.also { uri ->
            contentResolver.openInputStream(imageUri)?.let {
                it?.use { inputStream ->

                }
            }
        }
    }
}

as we can see, it’s almost the same as saving files in Downloads, the main difference here is the Collection.

now before writing the image file, since we don’t have a file, rather a Uri, we have to get this image Uri and read the file and write it to the desired location.

private fun saveImage(imageUri: Uri) {


    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

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

        val contentValues = ContentValues().apply {
            put(MediaStore.Downloads.DISPLAY_NAME, "Wallpaper.jpg")

        }

        contentResolver.insert(picturesCollection, contentValues)?.also { uri ->
            contentResolver.openInputStream(imageUri)?.let {
                it?.use { inputStream ->
                    contentResolver.openOutputStream(uri)?.use { outputStream ->
                        inputStream.copyTo(outputStream)
                    }
                }
            }

        }
    }
}

as we can see here we used openInputStream(imageUri) to start reading from image Uri, then created an outputStream to start writing this image to our new desired location using copyTo()method

Let’s run the app and we will see the Image was saved in Pictures folder

 

We can also add a relative path if we need like the following

private fun saveImage(imageUri: Uri) {


       if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

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

           val contentValues = ContentValues().apply {
               put(MediaStore.Images.Media.DISPLAY_NAME, "Wallpaper.jpg")
               put(
                   MediaStore.Images.Media.RELATIVE_PATH,
                   "${Environment.DIRECTORY_PICTURES}/Devlomi/"
               )
           }

           contentResolver.insert(picturesCollection, contentValues)?.also { uri ->
               contentResolver.openInputStream(imageUri)?.let {
                   it?.use { inputStream ->
                       contentResolver.openOutputStream(uri)?.use { outputStream ->
                           inputStream.copyTo(outputStream)
                       }
                   }
               }

           }
       }
   }

 

We can apply the same thing on Videos as well

val contract = ActivityResultContracts.StartActivityForResult()
  val launcher = registerForActivityResult(contract) { result ->
      if (result.resultCode == RESULT_OK) {
          result.data?.data?.let { videoUri ->
              saveVideo(videoUri)
          }
      }
  }

  val photoPickerIntent = Intent(Intent.ACTION_PICK)
  photoPickerIntent.type = "video/*"
  launcher.launch(photoPickerIntent)
private fun saveVideo(videoUri: Uri) {


    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

        val videosCollection =
            MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

        val contentValues = ContentValues().apply {
            put(MediaStore.Video.Media.DISPLAY_NAME, "Video.mp4")
        }

        contentResolver.insert(videosCollection, contentValues)?.also { uri ->
            contentResolver.openInputStream(videoUri)?.let {
                it?.use { inputStream ->
                    contentResolver.openOutputStream(uri)?.use { outputStream ->
                        inputStream.copyTo(outputStream)
                    }
                }
            }

        }
    }
}

Reading Files from External Storage (Scoped Storage)

We’ll use a simple example, viewing images from the phone and display it inside RecyclerView, and when the user clicks on an item it will show a dialog to confirm the deletion of that image. unlike Writing files, we have to add reading files Permissions on ALL API Levels, so let’s do that

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

We’ll create a Data class which contains the Item info in RecyclerView like ID and URI. you can also add other data like image name,width,height,etc..

Let’s setup our ListAdapter

data class GalleryImage(val id: Long, val contentUri: Uri) {

    companion object {
        val diffCallback = object : DiffUtil.ItemCallback<GalleryImage>() {
            override fun areItemsTheSame(oldItem: GalleryImage, newItem: GalleryImage): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: GalleryImage, newItem: GalleryImage): Boolean {
                return oldItem == newItem
            }

        }
    }
}
class ImagesAdapter(private val clickListener: (image: GalleryImage) -> Unit) :
    ListAdapter<GalleryImage, ImagesAdapter.ImageHolder>(GalleryImage.diffCallback) {


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageHolder {
        val itemView =
            LayoutInflater.from(parent.context).inflate(R.layout.item_image, parent, false)
        return ImageHolder(itemView)
    }

    override fun onBindViewHolder(holder: ImageHolder, position: Int) {
        holder.bind(getItem(position))
    }

    inner class ImageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        init {
            itemView.setOnClickListener {
                clickListener.invoke(getItem(adapterPosition))
            }
        }
        private val imageView = itemView.findViewById<ImageView>(R.id.image_view)

        fun bind(image: GalleryImage) {
            imageView.setImageURI(image.contentUri)
        }

    }
}
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/image_view"
        android:layout_width="100dp"
        android:layout_height="100dp" />
</FrameLayout>
recyclerView = findViewById(R.id.recycler_view)

    adapter = ImagesAdapter {
        //OnClick
    }
    
    recyclerView.layoutManager = GridLayoutManager(this, 3)
    recyclerView.adapter = adapter

I didn’t spend much time on the grid design, so you’d better to improve it 🙂

First let’s check for permissions

private fun checkPermissionAndLoadPhotos(){
     askPermission(Manifest.permission.READ_EXTERNAL_STORAGE){
      
     }
 }

Let’s create a method that will be responsible for fetching images, and since it may take a long time it’s better to offload this to a separate thread. and for that we will use Kotlin Coroutines

private suspend fun loadImages(): List<GalleryImage> {
       return withContext(IO) {
           val collection =
               MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)


           val projection = arrayOf(
               MediaStore.Images.Media._ID
           )
           val photos = mutableListOf<GalleryImage>()
           contentResolver.query(
               collection,
               projection,
               null,
               null,
               "${MediaStore.Images.Media.DISPLAY_NAME} DESC"
           )?.use { cursor ->
               val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
               while (cursor.moveToNext()) {
                   val id = cursor.getLong(idColumn)

                   val contentUri = ContentUris.withAppendedId(
                       MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                       id
                   )
                   photos.add(GalleryImage(id, contentUri))
               }
               photos.toList()
           } ?: listOf()
       }
   }

first, we created ImagesCollection then we defined a projection which is responsible for getting image information such as ID, Name, Width, Height, etc.. also we can define the order if we want to, in our case we’re ordering the data by name in descending order.

then we execute the query which returns a cursor that contains all the found items.

finally we can loop over the cursor and getting ID and ContentUri and adding them into the list. let’s call this method after checking permissions

private fun checkPermissionAndLoadPhotos(){
      askPermission(Manifest.permission.READ_EXTERNAL_STORAGE){
          lifecycleScope.launch {
              val images = loadImages()
              withContext(Main) {
                  adapter.submitList(images)
              }

          }
      }
  }

and don’t forget to update the adapter using submitList()

run the app

 

Deleting Images from Scoped Storage

let’s add a functionality where a user can delete an image by clicking on it after showing a dialog

adapter = ImagesAdapter {
         //OnClick
         AlertDialog.Builder(this).apply {
             title = "Delete"
             setMessage("Are you sure you want to delete this image?")
             setNegativeButton("Cancel", null)
             setPositiveButton("Delete") { _, _ ->

            //TODO DELETE IMAGE

             }
             show()
         }
     }

now we will delete the image once the user confirms

adapter = ImagesAdapter {
         //OnClick
         AlertDialog.Builder(this).apply {
             title = "Delete"
             setMessage("Are you sure you want to delete this image?")
             setNegativeButton("Cancel", null)
             setPositiveButton("Delete") { _, _ ->
                 contentResolver.delete(it.contentUri,null,null)
             }
             show()
         }
     }

if we tried to run the app against Android SDK 30 it will throw an exception

 

that’s because on Android SDK 29+ it’s required to ask the user for an extra permission to delete an image.

for that we need to ask the user for his permission using an Intent

 private lateinit var deleteIntentLauncher: ActivityResultLauncher<IntentSenderRequest>
deleteIntentLauncher =
         registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
             if (it.resultCode == RESULT_OK) {
                 if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {

                     it.data?.data?.let { uri ->
                         contentResolver.delete(uri, null, null)
                         Toast.makeText(this, "Image Deleted.", Toast.LENGTH_SHORT).show()
                     }


                 }
             }
         }

lastly we will start the intent and wait for the result from the user

adapter = ImagesAdapter {
         //OnClick
         AlertDialog.Builder(this).apply {
             title = "Delete"
             setMessage("Are you sure you want to delete this image?")
             setNegativeButton("Cancel", null)
             setPositiveButton("Delete") { _, _ ->

                 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                     val intentSender =
                         MediaStore.createDeleteRequest(
                             contentResolver,
                             listOf(it.contentUri)
                         ).intentSender

                     deleteIntentLauncher.launch(
                         IntentSenderRequest.Builder(intentSender).build()
                     )
                 }

             }
             show()
         }
     }

run the app

as you can see after clicking on an image, it will show a dialog that asks the user for deleting this image

after confirming the image will be deleted, but the data in recyclerView will not be updated.

to fix that we can call loadImages once again or we can delete the item from the list and call submitList() once again.

let’s make some improvements on our code to make it compatible with different Android versions we’ll make a new method for deleting the image by its Uri

private fun deleteImage(imageUri: Uri) {
        try {
            contentResolver.delete(imageUri, null, null)
        } catch (e: SecurityException) {
            val intentSender = when {
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
                    MediaStore.createDeleteRequest(contentResolver, listOf(imageUri)).intentSender
                }
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
                    val recoverableSecurityException = e as? RecoverableSecurityException
                    recoverableSecurityException?.userAction?.actionIntent?.intentSender
                }
                else -> null
            }
            intentSender?.let { sender ->
                deleteIntentLauncher.launch(
                    IntentSenderRequest.Builder(sender).build()
                )
            }
        }

}

by this we can ensure our code works on all Android versions. First we try to delete the image using contentResolver.delete(uri)if it throws an exception, we will get the intent launcher depending on Android version and lastly run this intent.

Writing files in External Storage without using Scoped Storage or MediaStore

Sometimes we need to save a file in External Storage in a specific location without saving it in Downloads, for example a Backup folder, and for that we can use a special Intent that opens File Explorer, and then the user can choose whatever location he wants in the phone and Android will give you permissions ONLY for this file and returns a URI which you can start writing files to.

It’s worth mentioning that this does NOT require READ or WRITE External Storage permissions.

First we will create the Intent Launcher using CreateDocument() contract that launches the File Explorer picker

   private lateinit var createFileLauncher: ActivityResultLauncher<String>

createFileLauncher =
           registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
              
           }

then we can launch this Intent, we can also predefine the file name that will shows when the file explorer picker shows.

this file name will be changeable of course by the user, so you cannot guarantee that the file name will be saved as you defined in your code.

createFileLauncher.launch("my-pwd.txt")

If the ActivityResult was OK, it will return a Uri which we can use it in writing data into it using OutputStream

createFileLauncher =
         registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
             val password = "pwd"
             contentResolver.openOutputStream(uri)?.use { outputStream ->
                 outputStream.write(password.toByteArray())
             }
         }

     createFileLauncher.launch("my-pwd.txt")

Reading files in External Storage without using Scoped Storage or MediaStore

We can use the same concept as before, but this time we’ll use OpenDocument()contract, and as CreateDocument()

this does NOT require READ or WRITE External Storage Permissions.

Let’s create the intent Launcher

   private lateinit var openFileLauncher: ActivityResultLauncher<Array<String>>


openFileLauncher =
           registerForActivityResult(ActivityResultContracts.OpenDocument()) {uri ->
            

           }
openFileLauncher.launch(arrayOf("text/plain"))

 

then we’ll launch it and pass it the MIME type, in our case we need to read a Text File and for that we’ve added text/plain but you can pass your own MIME type and pass multiple mime types in array as well eg. image/jpeg ,video/mp4,etc..

you can also pass */* which shows ALL types of files.

 

openFileLauncher =
          registerForActivityResult(ActivityResultContracts.OpenDocument()) {uri ->
              contentResolver.openInputStream(uri)?.use { inputStream ->
                  val bytes = inputStream.readBytes()
                  val pwd = String(bytes)
                  Toast.makeText(this, "Password is: $pwd", Toast.LENGTH_SHORT).show()
              }

          }
      openFileLauncher.launch(arrayOf("text/plain"))

Finally we can read the file contents and show it in a Toast for example.

 

It’s worth noting that in case you’re developing an app where you need a Full access type of permissions and bypassing scoped storage restrictions, such as File explorer type of apps you can use the special permission `MANAGE_EXTERNAL_STORAGE`and this gives you full access and ignores Android 10+ restrictions, so you can use it using the Files API just as before Android 10. however your app may get rejected while reviewing by Google Play Team if they don’t find a clear reason for requesting this permissions.

 

Useful resources:

 1,2,3