fix(android): 下载改为应用内下载+系统分享sheet
- 移除 DownloadManager,改用 HttpURLConnection 下载到应用私有目录 - 通过 FileProvider 生成 content URI,调用 ACTION_SEND 弹出系统分享面板 - 下载请求携带 WebView Cookie,保证鉴权 - 版本号 1.0.40 (versionCode 42)
This commit is contained in:
parent
626b7eaa52
commit
6a5ba2d95e
@ -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 下载链接)提示「下载启动失败」的问题:
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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?) {
|
||||
|
||||
6
android-webview-app/app/src/main/res/xml/file_paths.xml
Normal file
6
android-webview-app/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user