diff --git a/android-webview-app/APP_CHANGELOG.md b/android-webview-app/APP_CHANGELOG.md
index e68340a..ecb2cf8 100644
--- a/android-webview-app/APP_CHANGELOG.md
+++ b/android-webview-app/APP_CHANGELOG.md
@@ -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 下载链接)提示「下载启动失败」的问题:
diff --git a/android-webview-app/app/build.gradle.kts b/android-webview-app/app/build.gradle.kts
index c7763d9..fa3ecb8 100644
--- a/android-webview-app/app/build.gradle.kts
+++ b/android-webview-app/app/build.gradle.kts
@@ -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"
}
diff --git a/android-webview-app/app/src/main/AndroidManifest.xml b/android-webview-app/app/src/main/AndroidManifest.xml
index 573f09c..4835d3a 100644
--- a/android-webview-app/app/src/main/AndroidManifest.xml
+++ b/android-webview-app/app/src/main/AndroidManifest.xml
@@ -18,6 +18,16 @@
android:theme="@style/Theme.AgentWebView"
android:usesCleartextTraffic="false">
+
+
+
+
>? = 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?) {
diff --git a/android-webview-app/app/src/main/res/xml/file_paths.xml b/android-webview-app/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..d9c45e8
--- /dev/null
+++ b/android-webview-app/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+