【问题标题】:How to directly download a file to Download directory on Android Q (Android 10)如何在Android Q(Android 10)上直接下载文件到下载目录
【发布时间】:2020-03-24 22:53:06
【问题描述】:

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

在 Android 9 或更低版本中,我直接将这些文件下载到下载目录,但现在在 Android 10 中,如果不向用户显示 Intent 以询问在哪里下载它们...

不可能?但是为什么谷歌浏览器或其他浏览器能够做到这一点呢?实际上,他们仍然在 Android 10 中无需询问用户即可将文件下载到下载目录。

我首先分析了 Whatsapp 以了解他们是如何实现的,但他们利用了 AndroidManifest 上的 requestLegacyExternalStorage 属性。但后来我分析了 Chrome,它针对 Android 10 而不使用 requestLegacyExternalStorage。这怎么可能?

我已经在谷歌上搜索了几天应用程序如何将文件直接下载到 Android 10 (Q) 上的下载目录,而无需询问用户将文件放在哪里,就像 Chrome 一样。

我已经阅读了面向开发人员的 android 文档、关于 Stackoverflow 的大量问题、互联网上的博客文章和 Google 群组,但我仍然没有找到一种方法来保持与 Android 9 中完全相同的方式,甚至没有找到足够多的解决方案满足我。

到目前为止我所做的尝试:

  • 使用 ACTION_CREATE_DOCUMENT 意图打开 SAF 以请求许可,但显然无法静默打开它。始终打开一个 Activity 以询问用户将文件放在哪里。但是我应该在每个文件上打开这个 Intent 吗?我的应用程序可以在后台自动下载聊天文件。不是一个可行的解决方案。

  • 在应用的开头使用 SAF 获取授权访问权限,其中 uri 指向下载内容的任何目录:

        StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
        i = sm.getPrimaryStorageVolume().createOpenDocumentTreeIntent();
    

    请求用户许可是多么丑陋的行为,不是吗?尽管这不是谷歌浏览器所做的。

  • 或者再次使用 ACTION_CREATE_DOCUMENT,保存我在 onActivityResult() 中获得的 Uri,并使用 grantPermission() 和 getContentResolver().takePersistableUriPermission()。但这不会创建目录而是创建文件。

  • 我也尝试过获取 MediaStore.Downloads.INTERNAL_CONTENT_URI 或 MediaStore.Downloads.EXTERNAL_CONTENT_URI 并使用 Context.getContentResolver.insert() 保存文件,但是尽管它们被注释为 @NonNull,但真是巧合实际上返回... NULL

  • 添加 requestLegacyExternalStorage="false" 作为我的 AndroidManifest.xml 的应用程序标签的属性。但这只是开发人员的一个补丁,以便在他们进行更改和调整代码之前获得时间。此外,这不是谷歌浏览器所做的。

  • getFilesDir() 和 getExternalFilesDir() 和 getExternalFilesDirs() 仍然可用,但卸载我的应用程序时,存储在这些目录中的文件会被删除。用户希望在卸载我的应用程序时保留他们的文件。对我来说再次不是一个可行的解决方案。

我的临时解决方案:

我找到了一种解决方法,无需添加 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/) 并继续创建 File 对象,但如果 Google 决定更改 Android 11 上的下载目录路径怎么办?

恐怕这不是一个长期的解决方案,所以

我的问题:

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

【问题讨论】:

  • 这里的弃用说明中有一些替代方案:developer.android.com/reference/android/os/…
  • but that's now impossible in Android 10 without showing an Intent to the user to ask where to download them... 不,一点也不。您仍然可以使用 getFilesDir() 和 getExternalFilesDir() 和 getExternalFilesDirs()。
  • 在你的列表中我想念 ACTION_OPEN_DOCUMENT_TREE。
  • how apps can download a file directly to Download directory on Android 10 (Q) without having to ask user where to place it,。未经测试:DownloadManager 不会默认下载到该文件夹​​吗?
  • "获取 MediaStore.Downloads.INTERNAL_CONTENT_URI 或 MediaStore.Downloads.EXTERNAL_CONTENT_URI 并使用 Context.getContentResolver.insert() 保存文件"应该是解决方案。您可以考虑使用 minimal reproducible example 提出一个单独的 Stack Overflow 问题,以展示您是如何尝试的。

标签: android google-chrome android-10.0


【解决方案1】:

您可以使用安卓DownloadManager.Request。 在 Android 9 之前,它需要WRITE_EXTERNAL_STORAGE 权限。从 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

【讨论】:

  • 这可能是其他任何人的解决方案,但不适用于我的情况,因为它不允许我在下载时知道它的进度。我想为大文件显示一个进度条。
  • 您可以按照此处所述获得进度:stackoverflow.com/questions/7824835/…
  • 不适用于本地文件!! java.lang.IllegalArgumentException:只能下载 HTTP/HTTPS URI:file:///storage/emulated/0/Android/...
  • 我只能为我的项目发言,但截至 2021 年初,Android 的下载管理器在某些设备上出现问题从不下载文件或花一整天时间来实际开始下载。只是为任何想走这条路的人提供一些信息。
【解决方案2】:

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"
    

优点:

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

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

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

  • Android Q+ 不需要 write_external_storage 权限,仅用于此目的:

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

缺点:

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

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

【讨论】:

  • 我认为您将文件复制到下载文件夹的解决方案很好,但需要put(MediaStore.MediaColumns.IS_PENDING, 1) 才能使ContentResolver 成功插入contentValues
  • 恐怕没必要。我在生产中使用这种方法并且已经工作了将近一年。如果您不从拥有的目录复制,而直接写入下载目录,则可能需要 IS_PENDING。我很想看到使用 IS_PENDING 的答案,因为我无法使其工作。
  • 在我的情况下它是需要的,我将一些文件下载到应用程序数据文件夹并将其复制到下载文件夹中,没有 IS_PENDING 结果Urinull,但结果Uri有效。
  • 就像你一样,我花了几个小时找出如何将文件写入下载文件夹,而无需明确询问用户位置,就像其他谷歌应用程序一样。谷歌让支持所有安卓版本变得如此复杂,这是一种耻辱。无论如何,您的解决方案非常适合我,谢谢分享。你为我节省了很多时间。
【解决方案3】:

我认为最好用 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();
        }
    }
}

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

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

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

另外请注意,这不需要清单中的任何权限,这意味着它也不需要运行时权限。如果您想要 Kotlin 版本,请在 cmets 中询问我。我很乐意为此提供 Kotlin 方法。

【讨论】:

【解决方案4】:

当您想将 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()
        }
    }
}

【讨论】:

  • 如何获取文件路径并打开文件?
【解决方案5】:

恐怕您的解决方案不是“直接下载到下载目录”,以实现您可以使用此代码并享受!此代码使用 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()
            }
        })
}

【讨论】:

  • 我们如何获取路径并使用它来显示文件?
  • @jlively val directory = File( Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOWNLOADS).absolutePath ) 我使用了下载文件夹的路径
  • 嘿,谢谢!那是安卓9及以下,10及以上呢?
  • @jlively 适用于 Android 10,您可以通过以下代码获取路径: 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) val directory = File(uri.path)
猜你喜欢
  • 1970-01-01
  • 2014-08-27
  • 1970-01-01
  • 2019-01-20
  • 1970-01-01
  • 1970-01-01
  • 2011-12-19
  • 2021-12-11
  • 2021-05-28
相关资源
最近更新 更多