Key Points for Adapting to Android 10: Scoped Storage

Key Points for Adapting to Android 10: Scoped Storage

This article is a supplementary extension of “First Line of Code, 3rd Edition”. The keywords to access this article are hidden in Chapter 9 of the book. Although this article was written a while ago, I thought that since the new book has just been released, it would be unlikely for anyone to read it so quickly, so I planned to delay the release for a few more days. However, I was surprised to receive an increasing number of keyword messages in the public account backend over the past few days, and I have to admire: you all read too fast! Key Points for Adapting to Android 10: Scoped Storage Alright, it seems I can’t delay any longer, so today I’m going to share some original content./ Introduction /It has been about half a year since the official release of the Android 10 system. Have your applications been adapted to it?Among the many behavioral changes in Android 10, one point that deserves our attention is the scoped storage. This new feature directly overturns the long-standing usage of external storage space, and as a result, many apps will face significant upgrades to their code modules.However, there is not much official information about scoped storage, and many people do not understand how to use it. Additionally, it does not belong to the existing knowledge framework of “First Line of Code”. Although I considered including an explanation of this part in the 3rd edition, after much thought, I decided to explain it in a separate article, which can also be seen as an extension of the content of “First Line of Code, 3rd Edition”.This article provides a comprehensive analysis of scoped storage, and I believe that after reading it, you will be able to easily complete the adaptation and upgrade of Android 10 scoped storage./ Understanding Scoped Storage /Android has long supported the function of external storage space, commonly known as SD card storage. This feature is widely used, and almost all apps like to create their own dedicated directories in the root directory of the SD card to store various files and data.So what are the benefits of doing this? I thought about it and came up with two points. First, files stored on the SD card do not count towards the application’s occupied space, meaning that even if you store 1GB of files on the SD card, the occupied space displayed in the application settings may still only be a few tens of KB. Second, files stored on the SD card will remain even if the application is uninstalled, which helps to achieve some features that require data to be permanently retained.However, are these “benefits” really benefits? Perhaps for developers, this is a benefit, but for users, the aforementioned benefits are akin to some rogue behavior. This is because it can make the user’s SD card space messy, and even if I uninstall a program that I no longer use, the junk files it generated may still remain on my phone.Additionally, files stored on the SD card are public files, and all applications have the right to access them freely, which poses a significant challenge to data security.To address these issues, Google introduced the scoped storage feature in Android 10.

So what exactly is scoped storage? In simple terms, it is a significant restriction on the use of the SD card by the Android system. Starting from Android 10, each application can only read and create files in its own associated directory in external storage, and the code to obtain this associated directory is: context.getExternalFilesDir(). The path corresponding to the associated directory is roughly as follows:

/storage/emulated/0/Android/data/<package_name>/files

By storing data in this directory, you can fully use the previous methods to read and write files without any changes or adaptations.However, at the same time, the two “benefits” mentioned earlier will no longer exist.Files in this directory will count towards the application’s occupied space and will be deleted along with the application when it is uninstalled.

Some friends may ask, what if I need to access other directories? For example, reading images from the phone’s photo album or adding an image to the phone’s photo album. To address this, the Android system has classified file types, allowing images, audio, and video files to be accessed via the MediaStore API, while other types of files need to be accessed using the system’s file picker.Additionally, images, audio, or video files contributed to the media library by our application will automatically have read and write permissions without needing to request READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE permissions. However, if you want to read images, audio, or video files contributed to the media library by other applications, you must request READ_EXTERNAL_STORAGE permission. The WRITE_EXTERNAL_STORAGE permission will be deprecated in future Android versions.Alright, that’s enough theory about scoped storage for now. I believe you have a basic understanding of it, so let’s start with some practical operations./ Do I really need to upgrade? /Many friends will be concerned about this issue because whenever adapting and upgrading requires changing a lot of code, most people’s first thought is to avoid upgrading if possible or to delay it. However, regarding the scoped storage feature, congratulations, for now, you can indeed avoid upgrading.Currently, the requirements for adapting to scoped storage in Android 10 are not very strict, as the traditional usage of external storage space has been so widespread. If your project’s targetSdkVersion is below 29, your project can still run successfully on Android 10 devices without any adaptation for scoped storage.If your targetSdkVersion is already set to 29, that’s okay. If you still don’t want to adapt to scoped storage, just add the following configuration to your AndroidManifest.xml:

<manifest ... >
  <application android:requestLegacyExternalStorage="true" ...>
    ...
  </application>
</manifest>

This configuration indicates that even on Android 10, the previous legacy external storage usage is still allowed to run the program, so you don’t need to modify any code.Of course, this is just a temporary measure, and in future Android system versions, this configuration may become invalid at any time (currently, it has been confirmed that this configuration will not be invalid in at least Android 11 preview version).Therefore, it is still very necessary for us to learn how to adapt to scoped storage now.Additionally, all examples demonstrated in this article can be found in the corresponding source code in the ScopedStorageDemo open-source library.

The open-source library address is:

https://github.com/guolindev/ScopedStorageDemo

/ Getting Images from the Photo Album /First, let’s learn how to get images from the phone’s photo album in scoped storage. Note that although I am using images as an example in this article, the method for obtaining audio and video is basically the same.Unlike in the past, where we could directly obtain the absolute path of images in the album, in scoped storage, we can only obtain the image’s Uri using the MediaStore API. The sample code is as follows:

val cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, "${MediaStore.MediaColumns.DATE_ADDED} desc")
if (cursor != null) {
    while (cursor.moveToNext()) {
        val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
        val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
        println("image uri is $uri")
    }
    cursor.close()
}

In the above code, we first use ContentResolver to obtain the IDs of all images in the album, and then use ContentUris to assemble the ID into a complete Uri object.The Uri format of an image is roughly as follows:

content://media/external/images/media/321

Some friends may ask, after obtaining the Uri, how do I display this image?There are many ways to do this, such as using Glide to load the image, which supports passing a Uri object as the image path:

Glide.with(context).load(uri).into(imageView)

If you are not using Glide or any other image loading framework and want to directly parse a Uri object into an image without using third-party libraries, you can use the following code:

val fd = contentResolver.openFileDescriptor(uri, "r")
if (fd != null) {
    val bitmap = BitmapFactory.decodeFileDescriptor(fd.fileDescriptor)
    fd.close()
    imageView.setImageBitmap(bitmap)
}

In the above code, we call the ContentResolver’s openFileDescriptor() method and pass the Uri object to open the file descriptor, and then call BitmapFactory’s decodeFileDescriptor() method to parse the file descriptor into a Bitmap object.Demo effect:Key Points for Adapting to Android 10: Scoped StorageNow we have mastered the method of obtaining images from the photo album, and this method is applicable across all Android system versions.Next, let’s learn how to add an image to the photo album./ Adding an Image to the Photo Album /Adding an image to the phone’s photo album is relatively more complex because the handling methods differ between system versions.We will still learn through a code example, as shown below:

fun addBitmapToAlbum(bitmap: Bitmap, displayName: String, mimeType: String, compressFormat: Bitmap.CompressFormat) {
    val values = ContentValues()
    values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
    values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
    } else {
        values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")
    }
    val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
    if (uri != null) {
        val outputStream = contentResolver.openOutputStream(uri)
        if (outputStream != null) {
            bitmap.compress(compressFormat, 100, outputStream)
            outputStream.close()
        }
    }
}

This code demonstrates how to add a Bitmap object to the phone’s photo album. Let me briefly explain.To add an image to the photo album, we need to construct a ContentValues object and add three important pieces of data to this object. One is DISPLAY_NAME, which is the name displayed for the image, another is MIME_TYPE, which is the mime type of the image. The last is the storage path of the image, but the handling of this value differs between Android 10 and earlier system versions. Android 10 introduces a RELATIVE_PATH constant, indicating the relative path for file storage, with optional values such as DIRECTORY_DCIM, DIRECTORY_PICTURES, DIRECTORY_MOVIES, DIRECTORY_MUSIC, etc., representing directories for albums, pictures, movies, music, etc. In earlier system versions, there was no RELATIVE_PATH, so we had to use the DATA constant (deprecated in Android 10) and assemble an absolute path for file storage.After obtaining the ContentValues object, we can call the ContentResolver’s insert() method to get the Uri for inserting the image. However, merely obtaining the Uri is not enough; we also need to write data to the image corresponding to that Uri. We call the ContentResolver’s openOutputStream() method to get the file’s output stream and then write the Bitmap object to that output stream.The above code can achieve storing a Bitmap object in the phone’s photo album. Some friends may ask, what if the image I want to store is not a Bitmap object, but an image from the internet or an image in the current application’s associated directory? Actually, the methods are similar because whether it is an image from the internet or an image in the associated directory, we can obtain its input stream. We just need to continuously read the data from the input stream and write it to the output stream corresponding to the album image. The sample code is as follows:

fun writeInputStreamToAlbum(inputStream: InputStream, displayName: String, mimeType: String) {
    val values = ContentValues()
    values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
    values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
    } else {
        values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")
    }
    val bis = BufferedInputStream(inputStream)
    val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
    if (uri != null) {
        val outputStream = contentResolver.openOutputStream(uri)
        if (outputStream != null) {
            val bos = BufferedOutputStream(outputStream)
            val buffer = ByteArray(1024)
            var bytes = bis.read(buffer)
            while (bytes >= 0) {
                bos.write(buffer, 0, bytes)
                bos.flush()
                bytes = bis.read(buffer)
            }
            bos.close()
        }
    }
    bis.close()
}

In this code, we have just rewritten the parts for the input and output streams, while the other parts are completely consistent with the previous code for storing Bitmap, which I believe is easy to understand.Demo effect:Key Points for Adapting to Android 10: Scoped StorageNow we have resolved the issues of reading and storing images in the photo album. Next, let’s explore another common requirement: how to download files to the Download directory./ Downloading Files to the Download Directory /Performing file download operations is a very common scenario, such as downloading PDF, DOC files, or APK installation packages, etc. In the past, we usually downloaded these files to the Download directory, which is a dedicated directory for storing downloaded files. However, starting from Android 10, we can no longer access external storage space using absolute paths, so the file download functionality will also be affected.So how do we solve this? There are mainly two ways.The first and simplest way is to change the download directory. Download the file to the application’s associated directory, so you can let the program work normally on Android 10 without modifying any code. However, using this method, you need to know that the downloaded file will count towards the application’s occupied space, and if the application is uninstalled, the file will also be deleted. Additionally, files stored in the associated directory can only be accessed by the current application, and other programs do not have read permissions.If these restrictions do not meet your needs, then you can only use the second method, which is to adapt the code for Android 10 and still download the file to the Download directory.In fact, downloading files to the Download directory is quite similar to adding an image to the photo album. Android 10 has introduced a new Downloads collection in MediaStore specifically for executing file download operations. However, since the implementation of the download functionality varies from project to project, and some projects have very complex download implementations, how to integrate the following sample code into your project is something you need to think about.

fun downloadFile(fileUrl: String, fileName: String) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        Toast.makeText(this, "You must use device running Android 10 or higher", Toast.LENGTH_SHORT).show()
        return
    }
    thread {
        try {
            val url = URL(fileUrl)
            val connection = url.openConnection() as HttpURLConnection
            connection.requestMethod = "GET"
            connection.connectTimeout = 8000
            connection.readTimeout = 8000
            val inputStream = connection.inputStream
            val bis = BufferedInputStream(inputStream)
            val values = ContentValues()
            values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
            values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
            val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
            if (uri != null) {
                val outputStream = contentResolver.openOutputStream(uri)
                if (outputStream != null) {
                    val bos = BufferedOutputStream(outputStream)
                    val buffer = ByteArray(1024)
                    var bytes = bis.read(buffer)
                    while (bytes >= 0) {
                        bos.write(buffer, 0, bytes)
                        bos.flush()
                        bytes = bis.read(buffer)
                    }
                    bos.close()
                }
            }
            bis.close()
        } catch(e: Exception) {
            e.printStackTrace()
        }
    }
}

The above code is generally easy to understand, mainly adding some HTTP request code and changing MediaStore.Images.Media to MediaStore.Downloads. The other parts are almost unchanged, so I won’t elaborate further.Note that the above code can only run on Android 10 or higher, as MediaStore.Downloads is an API introduced in Android 10. For Android 9 and below, please continue to use the previous code for file downloads.Demo effect:Key Points for Adapting to Android 10: Scoped Storage/ Using the File Picker /If we want to read non-image, audio, or video files from the SD card, such as opening a PDF file, we can no longer use the MediaStore API but must use the file picker.However, we cannot write our own file browser to select files as before; we must use the built-in file picker in the phone’s system. The sample code is as follows:

const val PICK_FILE = 1

private fun pickFile() {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
    intent.addCategory(Intent.CATEGORY_OPENABLE)
    intent.type = "*/*"
    startActivityForResult(intent, PICK_FILE)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    when (requestCode) {
        PICK_FILE -> {
            if (resultCode == Activity.RESULT_OK && data != null) {
                val uri = data.data
                if (uri != null) {
                    val inputStream = contentResolver.openInputStream(uri)
                    // Perform file reading operation
                }
            }
        }
    }
}

In the pickFile() method, we start the system’s file picker using an Intent. Note that the action and category of the Intent are fixed. The type attribute can be used to filter file types, for example, specifying image/* will only display image files, while writing */* will display all types of files.Note that the type attribute must be specified; otherwise, it will cause a crash.Then in the onActivityResult() method, we can obtain the Uri of the file selected by the user, and then use ContentResolver to open the file input stream for reading.Demo effect:Key Points for Adapting to Android 10: Scoped Storage/ What if the Third-Party SDK Does Not Support It? /After reading this article, I believe you have a good grasp of the usage and adaptation of Android 10 scoped storage. However, in actual development work, we may still face a very troublesome issue: my own code can certainly be adapted, but what if the third-party SDK used in the project does not support scoped storage?This situation does exist. For example, the Qiniu Cloud SDK I previously used required you to pass an absolute path of a file for its file upload functionality, and it does not support passing a Uri object. You may encounter similar issues.Since we do not have permission to modify third-party SDKs, the simplest and most direct way is to wait for the third-party SDK provider to update this functionality. In the meantime, we should not set the targetSdkVersion to 29, or we can configure the requestLegacyExternalStorage attribute in the AndroidManifest file.However, if you do not want to use this temporary measure, there is actually a very good way to solve this problem: we can write a file copy function ourselves to copy the file corresponding to the Uri object to the application’s associated directory, and then pass the absolute path of this file in the associated directory to the third-party SDK, allowing for perfect adaptation. The sample code for this functionality is as follows:

fun copyUriToExternalFilesDir(uri: Uri, fileName: String) {
    val inputStream = contentResolver.openInputStream(uri)
    val tempDir = getExternalFilesDir("temp")
    if (inputStream != null && tempDir != null) {
        val file = File("$tempDir/$fileName")
        val fos = FileOutputStream(file)
        val bis = BufferedInputStream(inputStream)
        val bos = BufferedOutputStream(fos)
        val byteArray = ByteArray(1024)
        var bytes = bis.read(byteArray)
        while (bytes > 0) {
            bos.write(byteArray, 0, bytes)
            bos.flush()
            bytes = bis.read(byteArray)
        }
        bos.close()
        fos.close()
    }
}

Alright, that’s all for the important knowledge points about Android 10 scoped storage. I believe you can fully master it now.In the next article, we will continue to learn about Android 10 adaptation, discussing the dark theme feature. Stay tuned.Note: All examples demonstrated in this article can be found in the corresponding source code in the ScopedStorageDemo open-source library.

The open-source library address is:

https://github.com/guolindev/ScopedStorageDemo

My new book, “First Line of Code, 3rd Edition” has been published, covering Kotlin, Jetpack, MVVM, and all the knowledge points you care about.Order directly through the JD Mini Program:

Welcome to follow my public account

for learning technology or submissions

Key Points for Adapting to Android 10: Scoped Storage

Key Points for Adapting to Android 10: Scoped Storage

Long press the image above to scan the QR code to follow

Leave a Comment