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:
Leave A Comment