fix(android): 下载改为应用内下载+系统分享sheet

- 移除 DownloadManager,改用 HttpURLConnection 下载到应用私有目录

- 通过 FileProvider 生成 content URI,调用 ACTION_SEND 弹出系统分享面板

- 下载请求携带 WebView Cookie,保证鉴权

- 版本号 1.0.40 (versionCode 42)
This commit is contained in:
JOJO 2026-06-24 16:51:20 +08:00
parent 626b7eaa52
commit 6a5ba2d95e
5 changed files with 112 additions and 80 deletions

View File

@ -1,5 +1,11 @@
# App 更新说明
# 1.0.40
- 修复 Android App 内文件下载后系统提示「下载失败」的问题:
- 放弃系统 DownloadManager改为应用内下载到私有目录
- 下载完成后弹出系统「分享文件」面板让用户选择保存到下载管理、QQ浏览器、微信等目标
- 仍保留 WebView Cookie下载接口可正常鉴权
# 1.0.39
- 修复 Android App 内图片/视频「从本地发送」无响应的问题:在唤起系统文件选择器前主动请求 `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` 权限,拒绝后支持再次请求并引导到系统设置
- 修复 Android App 内所有下载文件卡片、markdown 下载链接)提示「下载启动失败」的问题:

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.cyjai.agent"
minSdk = 24
targetSdk = 35
versionCode = 41
versionName = "1.0.39"
versionCode = 42
versionName = "1.0.40"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@ -18,6 +18,16 @@
android:theme="@style/Theme.AgentWebView"
android:usesCleartextTraffic="false">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -2,14 +2,13 @@ package com.cyjai.agent
import android.Manifest
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log
@ -32,6 +31,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowCompat
@ -39,6 +39,10 @@ import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLConnection
import java.util.Locale
class MainActivity : AppCompatActivity() {
@ -47,15 +51,12 @@ class MainActivity : AppCompatActivity() {
private var filePathCallback: ValueCallback<Array<Uri>>? = null
private var voiceBridge: VoiceBridge? = null
private var pendingFileChooserParams: WebChromeClient.FileChooserParams? = null
private var pendingDownloadUrl: String? = null
private var pendingDownloadFileName: String? = null
companion object {
private const val TAG = "MainActivity"
private const val REQUEST_RECORD_AUDIO = 300
private const val REQUEST_WRITE_STORAGE = 301
private const val REQUEST_READ_MEDIA = 302
private const val REQUEST_DOWNLOAD_STORAGE = 303
private const val HOME_URL = "https://agent.cyjai.com"
private const val WEB_ASSET_VERSION = "20260624_2"
}
@ -334,17 +335,6 @@ class MainActivity : AppCompatActivity() {
}
}
}
REQUEST_DOWNLOAD_STORAGE -> {
val url = pendingDownloadUrl
val name = pendingDownloadFileName
pendingDownloadUrl = null
pendingDownloadFileName = null
if (url != null && grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
startSystemDownload(url, name ?: suggestFileName(null, url))
} else {
Toast.makeText(this, "需要存储权限才能下载文件", Toast.LENGTH_LONG).show()
}
}
REQUEST_WRITE_STORAGE, REQUEST_RECORD_AUDIO -> {
// 启动时请求的权限,无需额外处理
}
@ -529,7 +519,8 @@ class MainActivity : AppCompatActivity() {
@JavascriptInterface
fun downloadFile(fileUrl: String?, fileName: String?) {
val url = fileUrl ?: return
val name = fileName ?: suggestFileName(null, url)
val name = fileName?.takeIf { it.isNotBlank() }
?: deriveFileName("", null, url)
runOnUiThread {
startSystemDownload(url, name)
}
@ -537,67 +528,97 @@ class MainActivity : AppCompatActivity() {
}
private fun startSystemDownload(rawUrl: String, fileName: String) {
try {
val absoluteUrl = resolveAbsoluteUrl(rawUrl)
if (absoluteUrl.isBlank()) {
Toast.makeText(this, "下载链接无效", Toast.LENGTH_LONG).show()
return
}
// Android 9 及以下需要 WRITE_EXTERNAL_STORAGE 权限才能写入公共下载目录
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED
) {
pendingDownloadUrl = absoluteUrl
pendingDownloadFileName = fileName
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_DOWNLOAD_STORAGE
)
return
}
val request = buildDownloadRequest(absoluteUrl, fileName)
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
downloadManager.enqueue(request)
Toast.makeText(this, "已开始下载:$fileName", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.e(TAG, "下载失败: ${e.message}", e)
Toast.makeText(this, "下载启动失败,请重试", Toast.LENGTH_LONG).show()
// 兜底:尝试用浏览器打开
// 不再使用系统 DownloadManager国产 ROM 上 enqueue 后经常静默失败)。
// 改为应用内下载到私有目录,然后通过系统分享 sheet 让用户选择保存位置。
lifecycleScope.launch(Dispatchers.IO) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolveAbsoluteUrl(rawUrl)))
startActivity(intent)
} catch (_: Exception) {}
val absoluteUrl = resolveAbsoluteUrl(rawUrl)
if (absoluteUrl.isBlank()) {
withContext(Dispatchers.Main) {
Toast.makeText(this@MainActivity, "下载链接无效", Toast.LENGTH_LONG).show()
}
return@launch
}
withContext(Dispatchers.Main) {
Toast.makeText(this@MainActivity, "正在下载:$fileName", Toast.LENGTH_SHORT).show()
}
val downloadedFile = downloadFileToAppDir(absoluteUrl, fileName)
withContext(Dispatchers.Main) {
shareDownloadedFile(downloadedFile)
}
} catch (e: Exception) {
Log.e(TAG, "下载失败: ${e.message}", e)
withContext(Dispatchers.Main) {
Toast.makeText(this@MainActivity, "下载失败:${e.message}", Toast.LENGTH_LONG).show()
// 兜底:尝试用浏览器打开
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolveAbsoluteUrl(rawUrl)))
startActivity(intent)
} catch (_: Exception) {}
}
}
}
}
private fun buildDownloadRequest(url: String, fileName: String): DownloadManager.Request {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(fileName)
.setDescription("正在下载...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
.setAllowedOverRoaming(true)
.setAllowedOverMetered(true)
private fun downloadFileToAppDir(urlString: String, preferredName: String): File {
val dir = File(filesDir, "downloads").apply { mkdirs() }
val connection = URL(urlString).openConnection() as HttpURLConnection
connection.connectTimeout = 30000
connection.readTimeout = 30000
connection.instanceFollowRedirects = true
connection.setRequestProperty("Cookie", CookieManager.getInstance().getCookie(urlString) ?: "")
connection.connect()
// 携带 WebView 中已登录的会话 Cookie避免下载接口因未登录而 401
val cookie = CookieManager.getInstance().getCookie(url)
if (!cookie.isNullOrBlank()) {
request.addRequestHeader("Cookie", cookie)
val finalUrl = connection.url.toString()
val contentDisposition = connection.getHeaderField("Content-Disposition")
val fileName = deriveFileName(preferredName, contentDisposition, finalUrl)
val file = File(dir, fileName)
connection.inputStream.use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
connection.disconnect()
return file
}
return try {
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
request
private fun shareDownloadedFile(file: File) {
try {
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
val mimeType = URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream"
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TITLE, file.name)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "分享文件").apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(chooser)
} catch (e: ActivityNotFoundException) {
Toast.makeText(this, "没有可用的文件分享应用", Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Log.w(TAG, "无法指定公共下载目录,使用 DownloadManager 默认位置: ${e.message}")
request
Log.e(TAG, "分享文件失败: ${e.message}", e)
Toast.makeText(this, "分享文件失败:${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun deriveFileName(preferredName: String, contentDisposition: String?, url: String): String {
contentDisposition?.let {
val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
regex.find(it)?.groupValues?.get(1)?.trim()?.takeIf { name -> name.isNotBlank() }?.let { return it }
}
if (preferredName.isNotBlank() && preferredName.contains(".")) return preferredName
Uri.parse(url).lastPathSegment?.takeIf { it.isNotBlank() && it.contains(".") }?.let { return it }
Uri.parse(url).getQueryParameter("path")?.split("/")?.lastOrNull()?.takeIf { it.isNotBlank() }?.let { return it }
return "download_${System.currentTimeMillis()}"
}
private fun resolveAbsoluteUrl(rawUrl: String): String {
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl
val base = webView.url?.takeIf { it.startsWith("http://") || it.startsWith("https://") } ?: HOME_URL
@ -608,17 +629,6 @@ class MainActivity : AppCompatActivity() {
}
}
private fun suggestFileName(contentDisposition: String?, url: String): String {
contentDisposition?.let {
val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
regex.find(it)?.groupValues?.get(1)?.trim()?.takeIf { name -> name.isNotBlank() }?.let { return it }
}
val uri = Uri.parse(url)
uri.lastPathSegment?.takeIf { it.isNotBlank() && it.contains(".") }?.let { return it }
uri.getQueryParameter("path")?.split("/")?.lastOrNull()?.takeIf { it.isNotBlank() }?.let { return it }
return "download_${System.currentTimeMillis()}"
}
inner class ThemeBridge {
@JavascriptInterface
fun onThemeChanged(theme: String?) {

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 应用内部下载目录,用于通过系统分享 sheet 导出文件 -->
<files-path name="downloads" path="downloads/" />
<cache-path name="cache" path="." />
</paths>