如何在 Android Q (Android 10) 上直接将文件下载到下载目录

Rub*_*era 35 android google-chrome android-10.0

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

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

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

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

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

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

到目前为止我尝试过的:

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

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

        StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
        i = sm.getPrimaryStorageVolume().createOpenDocumentTreeIntent();
    
    Run Code Online (Sandbox Code Playgroud)

    请求用户许可是多么丑陋的活动,不是吗?即使这不是谷歌浏览器所做的。

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

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

  • 添加 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)
Run Code Online (Sandbox Code Playgroud)

有一个 provider_paths.xml

<paths>
    <external-path name="external_files" path="."/>
</paths>
Run Code Online (Sandbox Code Playgroud)

并在 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>
Run Code Online (Sandbox Code Playgroud)

问题:

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

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

我的问题:

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

Rub*_*era 23

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>
      
      Run Code Online (Sandbox Code Playgroud)
    • 在您的清单中添加一个 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>
      
      Run Code Online (Sandbox Code Playgroud)
  2. 准备将文件下载到您的应用程序拥有的任何目录,例如 getFilesDir()、getExternalFilesDir()、getCacheDir() 或 getExternalCacheDir()。

    val privateDir = context.getFilesDir()
    
    Run Code Online (Sandbox Code Playgroud)
  3. 下载文件时考虑其进度(DIY):

    val downloadedFile = myFancyMethodToDownloadToAnyDir(url, privateDir, fileName)
    
    Run Code Online (Sandbox Code Playgroud)
  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()
            }
        }
    }
    
    Run Code Online (Sandbox Code Playgroud)
  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"
    
    Run Code Online (Sandbox Code Playgroud)

优点:

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

  • 还允许您在下载时了解其进度

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

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

    <uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />
    
    Run Code Online (Sandbox Code Playgroud)

缺点:

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

如果这种方法对您来说足够了,那么请尝试一下。


bea*_*eal 7

您可以使用 Android DownloadManager.Request. 在WRITE_EXTERNAL_STORAGEAndroid 9 之前它需要权限。从 Android 10/Q 及更高版本开始,它不需要任何权限(似乎它自己处理权限)。

如果您想在之后打开文件,则需要获得用户的许可(如果您只想在外部应用程序(例如 PDF 阅读器)中打开文件也是如此)。

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

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);
Run Code Online (Sandbox Code Playgroud)

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

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