AndroidQ(Android10)如何直接下载文件到Download目录

How to directly download a file to Download directory on Android Q (Android 10)

假设我正在开发一个能够与他人共享任何类型文件(没有 mime 类型限制)的聊天应用程序:例如图像、视频、文档,还有压缩文件,例如 zip、rar、apk 甚至例如,不太常见的文件类型,例如 photoshop 或 autocad 文件。

在 Android 9 或更低版本中,我直接将这些文件下载到下载目录,但在 Android 10 中,如果不向用户显示 Intent 询问下载位置,那现在是不可能的...

不可能?但为什么 Google Chrome 或其他浏览器能够做到这一点?他们实际上仍然在 Android 10.

中不询问用户就将文件下载到下载目录

我首先分析了 Whatsapp,看看他们是如何实现的,但他们使用了 AndroidManifest 上的 requestLegacyExternalStorage 属性。但后来我分析了 Chrome 并且它在不使用 requestLegacyExternalStorage 的情况下以 Android 10 为目标。这怎么可能?

我已经谷歌搜索了几天 应用程序如何可以直接将文件下载到 Android 10 (Q) 上的下载目录,而无需询问用户去哪里以与 Chrome 相同的方式放置它。

我已经阅读了 android 开发人员文档、关于 Whosebug 的大量问题、Internet 上的博客文章和 Google 群组,但我仍然没有找到一种方法来继续做同样的事情就像 Android 9 中那样,甚至没有一个能让我满意的解决方案。

到目前为止我尝试过的:

我的临时解决方案:

我找到了一种解决方法,可以在不添加 requestLegacyExternalStorage="false".

的情况下随时随地下载

它包括使用以下方法从 File 对象获取 Uri:

val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadDir, fileName)
val authority = "${context.packageName}.provider"
val accessibleUri = FileProvider.getUriForFile(context, authority, file)

有一个provider_paths.xml

<paths>
    <external-path name="external_files" path="."/>
</paths>

并将其设置为 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>

问题:

它使用 getExternalStoragePublicDirectory 方法,该方法自 Android Q 起已弃用,极有可能在 Android 11 上被删除。您可能认为您可以手动创建自己的路径,如您所知真实路径 (/storage/emulated/0/Download/) 并继续创建文件对象,但是如果 Google 决定更改 Android 11 上的下载目录路径怎么办?

恐怕这不是长久之计,所以

我的问题:

如何在不使用已弃用的方法的情况下实现此目的? 还有一个额外的问题 Google Chrome 到底如何获得对下载目录的访问权限?

可以使用AndroidDownloadManager.Request。 它需要 WRITE_EXTERNAL_STORAGE 权限,直到 Android 9。从 Android 10/ Q 及更高版本开始,它不需要任何权限(似乎它自己处理权限)。

如果您想之后打开文件,则需要用户的许可(如果您只想在外部应用程序中打开它(例如 PDF-Reader)。

您可以像这样使用下载管理器:

DownloadManager.Request request = new DownloadManager.Request(<download uri>);
request.addRequestHeader("Accept", "application/pdf");
    
// Save the file in the "Downloads" folder of SDCARD
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS,filename);

DownloadManager downloadManager = (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE);
downloadManager.enqueue(request);

参考资料如下: https://developer.android.com/reference/kotlin/android/app/DownloadManager.Request?hl=en#setDestinationInExternalPublicDir(kotlin.String,%20kotlin.String)

https://developer.android.com/training/data-storage

10个多月过去了,还没有一个令我满意的答复。所以我会回答我自己的问题。

正如@CommonsWare 在评论中指出的那样,“获取 MediaStore.Downloads.INTERNAL_CONTENT_URI 或 MediaStore.Downloads.EXTERNAL_CONTENT_URI 并使用 Context.getContentResolver.insert() 保存文件” 应该是解决方案。我仔细检查了一下,发现这是真的,我说它不起作用是错误的。但是...

我发现使用 ContentResolver 很棘手,而且我无法使其正常工作。我会用它提出一个单独的问题,但我一直在调查并找到了一个令人满意的解决方案。

我的解决方案:

基本上你必须下载到你的应用程序拥有的任何目录,然后复制到下载文件夹。

  1. 配置您的应用:

    • provider_paths.xml添加到xml资源文件夹

      <paths xmlns:android="http://schemas.android.com/apk/res/android">
          <external-path name="external_files" path="."/>
      </paths>
      
    • 在您的清单中添加一个 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>
      
  2. 准备将文件下载到您的应用拥有的任何目录,例如 getFilesDir()、getExternalFilesDir()、getCacheDir() 或 getExternalCacheDir()。

    val privateDir = context.getFilesDir()
    
  3. 根据进度下载文件(DIY):

    val downloadedFile = myFancyMethodToDownloadToAnyDir(url, privateDir, fileName)
    
  4. 下载后,您可以根据需要对文件进行任何威胁。

  5. 将其复制到下载文件夹:

    //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()
            }
        }
    }
    
  6. 进入下载文件夹后,您可以像这样从应用程序打开文件:

    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"
    

优点:

  • 下载的文件在应用程序卸载后仍然存在

  • 还可以让你在下载的时候知道进度

  • 移动后您仍然可以从您的应用程序打开它们,因为文件仍然属于您的应用程序。

  • write_external_storage Q+不需要Android权限,只是为了这个目的:

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

缺点:

  • 在清除您的应用程序数据或卸载并重新安装后,您将无法访问一次下载的文件(除非您请求许可,否则它们不再属于您的应用程序)
  • 设备必须有更多空闲 space 才能将每个文件从其原始目录复制到其最终目的地。这对于大文件特别重要。尽管如果您有权访问原始 inputStream,您可以直接写入 downloadedUri 而不是从中间文件复制。

如果这种方法对你来说足够了,那就试试吧。

我认为最好将答案写在 java 中,因为那里存在许多必须更新的遗留代码。我也不知道你想存储什么样的文件,所以在这里,我举了一个位图的例子。如果您想保存任何其他类型的文件,您只需为其创建一个 OutputStream,对于它的其余部分,您可以按照我在下面编写的代码进行操作。现在,在这里,这个函数只会处理 Android 10+ 的保存,从而处理注释。我希望您具备如何在较低 android 版本

中保存文件的先决条件知识
@RequiresApi(api = Build.VERSION_CODES.Q)
public void saveBitmapToDownloads(Bitmap bitmap) {
    ContentValues contentValues = new ContentValues();

    // Enter the name of the file here. Note the extension isn't necessary
    contentValues.put(MediaStore.Downloads.DISPLAY_NAME, "Test.jpg");
    // Here we define the file type. Do check the MIME_TYPE for your file. For jpegs it is "image/jpeg"
    contentValues.put(MediaStore.Downloads.MIME_TYPE, "image/jpeg");
    contentValues.put(MediaStore.Downloads.IS_PENDING, true);

    Uri uri = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
    Uri itemUri = getContentResolver().insert(uri, contentValues);

    if (itemUri != null) {
        try {
            OutputStream outputStream = getContentResolver().openOutputStream(itemUri);
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
            outputStream.close();
            contentValues.put(MediaStore.Images.Media.IS_PENDING, false);
            getContentResolver().update(itemUri, contentValues, null, null);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

现在,如果您想创建一个子目录,即 Downloads 文件夹中的一个文件夹,您可以将其添加到 contentValues

contentValues.put(MediaStore.Downloads.RELATIVE_PATH, "Download/" + "Folder Name");

这将创建一个名为“文件夹名称”的文件夹,并在该文件夹中存储“Test.jpg”。

另请注意,这不需要清单中的任何权限,这意味着它也不需要运行时权限。如果你想要 Kotlin 版本,请在评论中问我。我很乐意并乐意提供相同的 Kotlin 方法。

当您想将来自 ResponseBody 的文档保存在下载文件夹中时,请使用此

private fun saveFileToStorage(
    body: ResponseBody,
    filename: String,
    consignmentId: String,
    context: Context
) {

    val contentResolver = context.contentResolver
    var inputStream: InputStream? = null
    var outputStream: OutputStream? = null
    var fileZizeDownloaded: Long = 0


    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        val values = ContentValues().apply {
            put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
            put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf")
            put(
                MediaStore.MediaColumns.RELATIVE_PATH, 
                Environment.DIRECTORY_DOWNLOADS
            )
        }

        val uri = 
           contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, 
            values)
        try {
            var pfd: ParcelFileDescriptor?=null
            try {
                pfd = 
                uri?.let { contentResolver.openFileDescriptor(it, "w")}!!
                val buf = ByteArray(4 * 1024)
                inputStream = body.byteStream()
                outputStream = FileOutputStream(pfd.fileDescriptor)
                var len: Int

                while (true) {
                    val read = inputStream.read(buf)
                    if (read == -1) {
                        break
                    }
                    outputStream.write(buf, 0, read)
                    fileZizeDownloaded += read.toLong()
                }

                outputStream.flush()
              } catch (e: java.lang.Exception) {
                e.printStackTrace()
            } finally {
                inputStream?.close()
                outputStream?.close()
                pfd?.close()
            }
            values.clear()
            values.put(MediaStore.Video.Media.IS_PENDING, 0)
            if (uri != null) {
                context.contentResolver.update(uri, values, null, null)
                outputStream = context.contentResolver.openOutputStream(uri)
            }
            if (outputStream == null) {
                throw IOException("Failed to get output stream.")
            }
            
        } catch (e: IOException) {
            if (uri != null) {
                context.contentResolver.delete(uri, null, null)  
            } 
        } finally {
            outputStream?.close()
        }
    } else {
        val directory_path =
                 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
        val file = File(directory_path)
        if (!file.exists()) {
            file.mkdirs()
        }
        val targetPdf = directory_path + filename
        val filePath = File(targetPdf)

        try {
            val fileReader = ByteArray(4 *1024)
            inputStream = body.byteStream()
            outputStream = FileOutputStream(filePath)
            while (true) {
                val read = inputStream.read(fileReader)
                if (read == -1) {
                    break
                }
                outputStream.write(fileReader, 0, read)
                fileZizeDownloaded += read.toLong()

            }
            outputStream.flush()
            
        } catch (e: IOException) {
            e.printStackTrace()
           
        } finally {
            inputStream?.close()
            outputStream?.close()
        }
    }
}

恐怕您的解决方案不是“直接下载到下载目录”,实现您可以使用此代码并享受!此代码使用 RxJava 进行网络调用,您可以使用任何适合您的方式,这适用于所有 Android 版本:

import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.reactivex.Observable
import io.reactivex.ObservableEmitter
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.ResponseBody
import java.io.*
import java.net.HttpURLConnection
import java.util.concurrent.TimeUnit
class FileDownloader(
         private val context: Context,
         private val url: String,
         private val fileName: String
         ) {
    
        private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
            .connectTimeout(60, TimeUnit.SECONDS)
            .readTimeout(60, TimeUnit.SECONDS)
            .build()
    
        private val errorMessage = "File couldn't be downloaded"
        private val bufferLengthBytes: Int = 1024 * 4
    
        fun download(): Observable<Int> {
            return Observable.create<Int> { emitter ->
    
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // To Download File for Android 10 and above
                    val content = ContentValues().apply {
                        put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
                        put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
                    }
                    val uri = context.contentResolver.insert(
                        MediaStore.Downloads.EXTERNAL_CONTENT_URI,
                        content
                    )
                    uri?.apply {
                        val responseBody = getResponseBody(url)
                        if (responseBody != null
                        ) {
                            responseBody.byteStream().use { inputStream ->
                                context.contentResolver.openOutputStream(uri)?.use { fileOutStream ->
                                    writeOutStream(
                                        inStream = inputStream,
                                        outStream = fileOutStream,
                                        contentLength = responseBody.contentLength(),
                                        emitter = emitter
                                    )
                                }
                                emitter.onComplete()
                            }
                        } else {
                            emitter.onError(Throwable(errorMessage))
                        }
                    }
                }
                    else { // For Android versions below than 10
                        val directory = File(
                            Environment.getExternalStoragePublicDirectory(
                                Environment.DIRECTORY_DOWNLOADS).absolutePath
                        ).apply {
                            if (!exists()) {
                                mkdir()
                            }
                        }
    
                    val file = File(directory, fileName)
                    val responseBody = getResponseBody(url)
    
                    if (responseBody != null) {
                        responseBody.byteStream().use { inputStream ->
                            file.outputStream().use { fileOutStream ->
                                writeOutStream(
                                    inStream = inputStream,
                                    outStream = fileOutStream,
                                    contentLength = responseBody.contentLength(),
                                    emitter = emitter
                                )
                            }
                            emitter.onComplete()
                        }
    
                    } else {
                        emitter.onError(Throwable(errorMessage))
                    }
                }
            }
        }
    
        private fun getResponseBody(url: String): ResponseBody? {
            val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute()
    
            return if (response.code >= HttpURLConnection.HTTP_OK &&
                response.code < HttpURLConnection.HTTP_MULT_CHOICE &&
                response.body != null
            )
                response.body
            else
                null
        }
    
        private fun writeOutStream(
            inStream: InputStream,
            outStream: OutputStream,
            contentLength: Long,
            emitter: ObservableEmitter<Int>) {
                var bytesCopied = 0
                val buffer = ByteArray(bufferLengthBytes)
                var bytes = inStream.read(buffer)
                while (bytes >= 0) {
                    outStream.write(buffer, 0, bytes)
                    bytesCopied += bytes
                    bytes = inStream.read(buffer)
    //                emitter.onNext(
                        ((bytesCopied * 100) / contentLength).toInt()
    //                )
                }
        outStream.flush()
        outStream.close()
        inStream.close()
        }
    }

在主叫方你可以这样做:

private fun downloadFileFromUrl(context: Context, url: String, fileName: String) {
    FileDownloader(
        context = context,
        url = url,
        fileName = fileName
    ).download()
        .throttleFirst(2, TimeUnit.SECONDS)
        .toFlowable(BackpressureStrategy.LATEST)
        .subscribeOn(Schedulers.io())
        .observeOn(mainThread())
        .subscribe({
            // onNext: Downloading in progress
            Timber.i( "File downloaded $it%")
        }, { error ->
            // onError: Download Error
            requireContext()?.apply {
                Toast.makeText(this, error.message, Toast.LENGTH_SHORT).show()
            }
        }, {
            // onComplete: Download Complete
            requireContext()?.apply {
                Toast.makeText(this, "File downloaded!", Toast.LENGTH_SHORT).show()
            }
        })
}