繁体   English   中英

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

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

假设我正在开发一个聊天应用程序,它能够与其他人共享任何类型的文件(没有 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();

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

  • 或者再次使用 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)

有一个 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 到底是如何访问下载目录的?

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 而不是从中间文件复制。

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

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

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

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

我认为最好用 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 版本,请在评论中问我。 我很乐意并且很乐意为此提供 Kotlin 方法。

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

私人乐趣 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()
            }
        })
}

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM