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 @@ + + + + + +