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

View File

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

View File

@ -18,6 +18,16 @@
android:theme="@style/Theme.AgentWebView" android:theme="@style/Theme.AgentWebView"
android:usesCleartextTraffic="false"> 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -2,14 +2,13 @@ package com.cyjai.agent
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.DownloadManager import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
@ -32,6 +31,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -39,6 +39,10 @@ import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLConnection
import java.util.Locale import java.util.Locale
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -47,15 +51,12 @@ class MainActivity : AppCompatActivity() {
private var filePathCallback: ValueCallback<Array<Uri>>? = null private var filePathCallback: ValueCallback<Array<Uri>>? = null
private var voiceBridge: VoiceBridge? = null private var voiceBridge: VoiceBridge? = null
private var pendingFileChooserParams: WebChromeClient.FileChooserParams? = null private var pendingFileChooserParams: WebChromeClient.FileChooserParams? = null
private var pendingDownloadUrl: String? = null
private var pendingDownloadFileName: String? = null
companion object { companion object {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
private const val REQUEST_RECORD_AUDIO = 300 private const val REQUEST_RECORD_AUDIO = 300
private const val REQUEST_WRITE_STORAGE = 301 private const val REQUEST_WRITE_STORAGE = 301
private const val REQUEST_READ_MEDIA = 302 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 HOME_URL = "https://agent.cyjai.com"
private const val WEB_ASSET_VERSION = "20260624_2" 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 -> { REQUEST_WRITE_STORAGE, REQUEST_RECORD_AUDIO -> {
// 启动时请求的权限,无需额外处理 // 启动时请求的权限,无需额外处理
} }
@ -529,7 +519,8 @@ class MainActivity : AppCompatActivity() {
@JavascriptInterface @JavascriptInterface
fun downloadFile(fileUrl: String?, fileName: String?) { fun downloadFile(fileUrl: String?, fileName: String?) {
val url = fileUrl ?: return val url = fileUrl ?: return
val name = fileName ?: suggestFileName(null, url) val name = fileName?.takeIf { it.isNotBlank() }
?: deriveFileName("", null, url)
runOnUiThread { runOnUiThread {
startSystemDownload(url, name) startSystemDownload(url, name)
} }
@ -537,67 +528,97 @@ class MainActivity : AppCompatActivity() {
} }
private fun startSystemDownload(rawUrl: String, fileName: String) { private fun startSystemDownload(rawUrl: String, fileName: String) {
try { // 不再使用系统 DownloadManager国产 ROM 上 enqueue 后经常静默失败)。
val absoluteUrl = resolveAbsoluteUrl(rawUrl) // 改为应用内下载到私有目录,然后通过系统分享 sheet 让用户选择保存位置。
if (absoluteUrl.isBlank()) { lifecycleScope.launch(Dispatchers.IO) {
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()
// 兜底:尝试用浏览器打开
try { try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolveAbsoluteUrl(rawUrl))) val absoluteUrl = resolveAbsoluteUrl(rawUrl)
startActivity(intent) if (absoluteUrl.isBlank()) {
} catch (_: Exception) {} 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 { private fun downloadFileToAppDir(urlString: String, preferredName: String): File {
val request = DownloadManager.Request(Uri.parse(url)) val dir = File(filesDir, "downloads").apply { mkdirs() }
.setTitle(fileName) val connection = URL(urlString).openConnection() as HttpURLConnection
.setDescription("正在下载...") connection.connectTimeout = 30000
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) connection.readTimeout = 30000
.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE) connection.instanceFollowRedirects = true
.setAllowedOverRoaming(true) connection.setRequestProperty("Cookie", CookieManager.getInstance().getCookie(urlString) ?: "")
.setAllowedOverMetered(true) connection.connect()
// 携带 WebView 中已登录的会话 Cookie避免下载接口因未登录而 401 val finalUrl = connection.url.toString()
val cookie = CookieManager.getInstance().getCookie(url) val contentDisposition = connection.getHeaderField("Content-Disposition")
if (!cookie.isNullOrBlank()) { val fileName = deriveFileName(preferredName, contentDisposition, finalUrl)
request.addRequestHeader("Cookie", cookie)
val file = File(dir, fileName)
connection.inputStream.use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
} }
connection.disconnect()
return file
}
return try { private fun shareDownloadedFile(file: File) {
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName) try {
request 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) { } catch (e: Exception) {
Log.w(TAG, "无法指定公共下载目录,使用 DownloadManager 默认位置: ${e.message}") Log.e(TAG, "分享文件失败: ${e.message}", e)
request 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 { private fun resolveAbsoluteUrl(rawUrl: String): String {
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl
val base = webView.url?.takeIf { it.startsWith("http://") || it.startsWith("https://") } ?: HOME_URL 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 { inner class ThemeBridge {
@JavascriptInterface @JavascriptInterface
fun onThemeChanged(theme: String?) { 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>