Lets say I'm developing a chat app that is able to share with others ANY kind of files (no mimetype restriction): like images, videos, documents, but also compressed files like zip, rar, apk or even less frequent types of files like photoshop or autocad files, for example.
In Android 9 or lower I directly download those files to Download directory, but that's now impossible in Android 10 without showing an Intent to the user to ask where to download them...
Impossible? but then why Google Chrome or other browsers are able to do that? They in fact still download files to Download directory without asking user in Android 10.
I first analyzed Whatsapp to see how they achieve it but they make use of requestLegacyExternalStorage attribute on AndroidManifest. But then I analyzed Chrome and it targets Android 10 without using requestLegacyExternalStorage. How is that possible?
I have been googling for some days already how apps can download a file directly to Download directory on Android 10 (Q) without having to ask user where to place it, the same way Chrome does.
I have read android for developers documentation, lots of questions on Stackoverflow, blog posts over the Internet and Google Groups but still I haven't found a way to keep doing exactly the same as in Android 9 nor even a solution that plenty satisfies me.
What I've tried so far:
Open SAF with an ACTION_CREATE_DOCUMENT Intent to ask for permission but apparently there's no way to open it silently. An Activity is always opened to ask user where to place the file. But am I supposed to open this Intent on every file? My app can download chat files automatically being on background. Not a feasible solution.
Get grant access using SAF at the beginning of the app with an uri pointing to any directory for download contents:
StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
i = sm.getPrimaryStorageVolume().createOpenDocumentTreeIntent();
What an ugly activity to ask user for permission, isn't it? Even though this is NOT what Google Chrome does.
Or again by using ACTION_CREATE_DOCUMENT, save the Uri that I get in onActivityResult() and use grantPermission() and getContentResolver().takePersistableUriPermission(). But this does not create a directory but a file.
I've also tried to get MediaStore.Downloads.INTERNAL_CONTENT_URI or MediaStore.Downloads.EXTERNAL_CONTENT_URI and save a file by using Context.getContentResolver.insert(), but what a coincidence although they are annotated as @NonNull they in fact return... NULL
Adding requestLegacyExternalStorage="false" as an attribute of Application label of my AndroidManifest.xml. But this is just a patch for developers in order to gain time until they make changes and adapt their code. Besides still this is not what Google Chrome does.
getFilesDir() and getExternalFilesDir() and getExternalFilesDirs() are still available but files stored on those directories are deleted when my app is uninstalled. Users expect to keep their files when uninstalling my app. Again not a feasible solution for me.
My temporary solution:
I've found a workaround that makes it possible to download wherever you want without adding requestLegacyExternalStorage="false".
It consists on obtaining an Uri from a File object by using:
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadDir, fileName)
val authority = "${context.packageName}.provider"
val accessibleUri = FileProvider.getUriForFile(context, authority, file)
Having a provider_paths.xml
<paths>
<external-path name="external_files" path="."/>
</paths>
And setting it on AndroidManifest.xml:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
The problem:
It make use of getExternalStoragePublicDirectory method which is deprecated as of Android Q and extremmely likely will be removed on Android 11. You could think that you can make your own path manually as you know the real path (/storage/emulated/0/Download/) and keep creating a File object, but what if Google decices to change Download directory path on Android 11?
I'm afraid this is not a long term solution, so
My question:
How can I achieve this without using a deprecated method? And a bonus question How the hell Google Chrome accomplish getting access to Download directory?
More than 10 months have passed and yet not a satisfying answer for me have been made. So I'll answer my own question.
As @CommonsWare states in a comment, "get MediaStore.Downloads.INTERNAL_CONTENT_URI or MediaStore.Downloads.EXTERNAL_CONTENT_URI and save a file by using Context.getContentResolver.insert()" is supposed to be the solution. I double checked and found out this is true and I was wrong saying it doesn't work. But...
I found it tricky to use ContentResolver and I was unable to make it work properly. I'll make a separate question with it but I kept investigating and found a somehow satisfying solution.
MY SOLUTION:
Basically you have to download to any directory owned by your app and then copy to Downloads folder.
Configure your app:
Add provider_paths.xml to xml resource folder
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external_files" path="."/>
</paths>
In your manifest add a FileProvider:
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
Prepare to download files to any directory your app owns, such as getFilesDir(), getExternalFilesDir(), getCacheDir() or getExternalCacheDir().
val privateDir = context.getFilesDir()
Download file taking its progress into account (DIY):
val downloadedFile = myFancyMethodToDownloadToAnyDir(url, privateDir, fileName)
Once downloaded you can make any threatment to the file if you'd like to.
Copy it to Downloads folder:
//This will be used only on android P-
private val DOWNLOAD_DIR = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val finalUri : Uri? = copyFileToDownloads(context, downloadedFile)
fun copyFileToDownloads(context: Context, downloadedFile: File): Uri? {
val resolver = context.contentResolver
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, getName(downloadedFile))
put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(downloadedFile))
put(MediaStore.MediaColumns.SIZE, getFileSize(downloadedFile))
}
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
} else {
val authority = "${context.packageName}.provider"
val destinyFile = File(DOWNLOAD_DIR, getName(downloadedFile))
FileProvider.getUriForFile(context, authority, destinyFile)
}?.also { downloadedUri ->
resolver.openOutputStream(downloadedUri).use { outputStream ->
val brr = ByteArray(1024)
var len: Int
val bufferedInputStream = BufferedInputStream(FileInputStream(downloadedFile.absoluteFile))
while ((bufferedInputStream.read(brr, 0, brr.size).also { len = it }) != -1) {
outputStream?.write(brr, 0, len)
}
outputStream?.flush()
bufferedInputStream.close()
}
}
}
Once in download folder you can open file from app like this:
val authority = "${context.packageName}.provider"
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(finalUri, getMimeTypeForUri(finalUri))
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP) {
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
} else {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
try {
context.startActivity(Intent.createChooser(intent, chooseAppToOpenWith))
} catch (e: Exception) {
Toast.makeText(context, "Error opening file", Toast.LENGTH_LONG).show()
}
//Kitkat or above
fun getMimeTypeForUri(context: Context, finalUri: Uri) : String =
DocumentFile.fromSingleUri(context, finalUri)?.type ?: "application/octet-stream"
//Just in case this is for Android 4.3 or below
fun getMimeTypeForFile(finalFile: File) : String =
DocumentFile.fromFile(it)?.type ?: "application/octet-stream"
Pros:
Downloaded files survives to app uninstallation
Also allows you to know its progress while downloading
You still can open them from your app once moved, as the file still belongs to your app.
write_external_storage permission is not required for Android Q+, just for this purpose:
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
Cons:
If this approach is enough for you then give it a try.