Procházet zdrojové kódy

Merge branch 'feature/download' into develop

cc12458 před 1 týdnem
rodič
revize
81442ce3fe

+ 3 - 0
app/src/main/AndroidManifest.xml

@@ -2,6 +2,9 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
           xmlns:tools="http://schemas.android.com/tools">
 
+  <!-- Android 13+:允许系统下载等通知显示在通知栏(需运行时授权) -->
+  <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
   <application
       android:allowBackup="true"
       android:dataExtractionRules="@xml/data_extraction_rules"

+ 2 - 0
library/browser/src/main/AndroidManifest.xml

@@ -3,6 +3,8 @@
 
   <uses-permission android:name="android.permission.INTERNET" />
   <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+  <!-- DownloadManager 写入公共 Download(API 28 及以下) -->
+  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
 
   <uses-feature android:name="android.hardware.camera" android:required="false" />
   <uses-permission android:name="android.permission.CAMERA" />

+ 12 - 0
library/browser/src/main/assets/browser/bridge.js

@@ -37,6 +37,18 @@ class Bridge extends EventTarget {
     return promise;
   }
 
+  static download({ signal, ...payload } = { signal: null }) {
+    const { promise, ...resolvers } = Promise.withResolvers();
+
+    signal?.addEventListener('abort', () => {
+      this.getInstance().#postMessage('download:stop', payload, resolvers);
+      resolvers.reject({message: '取消下载'})
+    }, { once: true, signal });
+
+    this.getInstance().#postMessage('download:start', payload, resolvers);
+    return promise;
+  }
+
   dispatch(message) {
     try {
       const { type, callbackId, payload }  = JSON.parse(message);

+ 11 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/download/DownloadFileResult.kt

@@ -0,0 +1,11 @@
+package com.hzliuzhi.applet.browser.download
+
+/**
+ * 下载完成后的可打开文件信息(如 `content://`),供 Bridge 回传前端。
+ */
+data class DownloadFileResult(
+  /** 本地可打开 URI 字符串(如 FileProvider content URI)。 */
+  val url: String,
+  val name: String,
+  val type: String?,
+)

+ 98 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/download/DownloadRequest.kt

@@ -0,0 +1,98 @@
+package com.hzliuzhi.applet.browser.download
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+
+sealed class DownloadRequest {
+  abstract val id: String
+  abstract val contentDisposition: String?
+  abstract val contentType: String?
+  abstract val contentLength: Long?
+
+  data class Url(
+    val url: String,
+    val force: Boolean = false,
+    val headers: Map<String, String> = emptyMap(),
+    override val contentDisposition: String? = null,
+    override val contentType: String? = null,
+    override val contentLength: Long? = null,
+    override val id: String = url,
+  ) : DownloadRequest()
+
+  data class Blob(
+    override val id: String,
+    val data: String,
+    val index: Int = 0,
+    val done: Boolean? = null,
+    override val contentDisposition: String? = null,
+    override val contentType: String? = null,
+    override val contentLength: Long? = null,
+  ) : DownloadRequest()
+
+  companion object {
+    fun fromListener(
+      url: String?,
+      userAgent: String?,
+      contentDisposition: String?,
+      mimetype: String?,
+      contentLength: Long,
+      cookie: String? = null,
+    ): Url {
+      require(!url.isNullOrBlank()) { "下载参数 url 不能为空" }
+      return Url(
+        url = url,
+        contentDisposition = contentDisposition,
+        contentType = mimetype,
+        contentLength = contentLength,
+        headers = buildMap {
+          cookie?.takeIf { it.isNotBlank() }?.let { put("Cookie", it) }
+          userAgent?.takeIf { it.isNotBlank() }?.let { put("User-Agent", it) }
+        },
+      )
+    }
+
+    fun fromPayload(params: JsonElement?): DownloadRequest {
+      require(params != null && !params.isJsonNull) { "下载参数不能为 null" }
+      require(params.isJsonObject) { "下载参数须为 JSON 对象" }
+      return params.asJsonObject.let {
+        it.parseUrlOrNull() ?: it.parseBlobOrNull()
+      } ?: error("无法识别下载类型:Url 须含 url;Blob 须含 id 与 data")
+    }
+
+    private fun JsonObject.parseUrlOrNull(): Url? = stringOrBlank("url")?.let { url ->
+      Url(
+        url = url,
+        force = booleanOrDefault("force", false),
+        headers = headersMap(),
+        contentDisposition = dispositionOrFilename(),
+        contentType = contentTypeOrMimetype(),
+        contentLength = contentLengthOrFileSize(),
+      )
+    }
+
+    private fun JsonObject.parseBlobOrNull(): Blob? {
+      val id = stringOrBlank("id") ?: return null
+      val data = stringOrNull("data") ?: return null
+      return Blob(
+        id = id,
+        data = data,
+        index = intOrDefault("index", 0),
+        done = booleanOrNull("done"),
+        contentDisposition = dispositionOrFilename(),
+        contentType = contentTypeOrMimetype(),
+        contentLength = contentLengthOrFileSize(),
+      )
+    }
+
+    private fun JsonObject.dispositionOrFilename(): String? = stringOrBlank("contentDisposition") ?: stringOrBlank("fileName")?.let(::attachmentContentDisposition)
+
+    private fun JsonObject.contentTypeOrMimetype(): String? = stringOrBlank("contentType") ?: stringOrBlank("fileType")
+
+    private fun JsonObject.contentLengthOrFileSize(): Long? = longOrNull("contentLength") ?: longOrNull("fileSize")
+  }
+}
+
+
+
+
+

+ 47 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/download/DownloadSession.kt

@@ -0,0 +1,47 @@
+package com.hzliuzhi.applet.browser.download
+
+import android.net.Uri
+import android.util.Log
+import android.webkit.MimeTypeMap
+import androidx.core.net.toUri
+import androidx.webkit.URLUtilCompat.getFilenameFromContentDisposition
+
+data class DownloadSession(
+  val id: String,
+  val name: String,
+  val type: String,
+  val size: Long = 0L,
+  val bytes: Long = 0L,
+  val uri: String? = null,
+) {
+  companion object {
+    fun fromRequest(request: DownloadRequest): DownloadSession = request.run {
+      val name = when (this) {
+        is DownloadRequest.Url -> guessFileName(url, contentDisposition)
+        is DownloadRequest.Blob -> requireNotNull(contentDisposition?.let(::getFilenameFromContentDisposition)?.takeIf { it.isNotBlank() }) { "下载文件名不能为空" }
+      }.trim().let(Uri::decode).trim().let(::sanitizeFileName).ifBlank { "download_file" }
+
+      Log.d("LOG--name", name)
+
+      DownloadSession(
+        id = request.id,
+        name = name,
+        type = getMimeTypeFromExtension(name) ?: contentType?.trim().orEmpty(),
+        size = contentLength?.takeIf { it >= 0L } ?: 0L
+      )
+    }
+
+    /** 避免展示名或落盘名含路径分隔符(guess / Content-Disposition 可能带回路径片段)。 */
+    private fun sanitizeFileName(name: String): String = name.replace('/', '_').replace('\\', '_')
+  }
+}
+
+internal fun guessFileName(url: String, contentDisposition: String?): String {
+  return contentDisposition?.let { getFilenameFromContentDisposition(it) } ?: url.toUri().lastPathSegment ?: "download_file"
+}
+
+internal fun getMimeTypeFromExtension(filename: String) = filename.lastIndexOf('.').takeIf { it > -1 }?.let { lastDotIndex ->
+  MimeTypeMap.getSingleton().getMimeTypeFromExtension(
+    filename.substring(lastDotIndex + 1)
+  )
+}

+ 10 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/download/DownloadStatus.kt

@@ -0,0 +1,10 @@
+package com.hzliuzhi.applet.browser.download
+
+sealed class DownloadStatus {
+  abstract val session: DownloadSession
+
+  data class Started(override val session: DownloadSession) : DownloadStatus()
+  data class Progress(override val session: DownloadSession) : DownloadStatus()
+  data class Completed(override val session: DownloadSession) : DownloadStatus()
+  data class Failed(override val session: DownloadSession, val message: String) : DownloadStatus()
+}

+ 102 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/download/analysis.kt

@@ -0,0 +1,102 @@
+package com.hzliuzhi.applet.browser.download
+
+import com.google.gson.JsonObject
+
+internal fun JsonObject.stringOrNull(key: String): String? {
+  val e = get(key) ?: return null
+  if (e.isJsonNull) return null
+  if (!e.isJsonPrimitive) return null
+  return e.asString
+}
+
+internal fun JsonObject.stringOrBlank(key: String): String? = stringOrNull(key)?.trim()?.takeIf { it.isNotEmpty() }
+
+internal fun JsonObject.booleanOrDefault(key: String, default: Boolean): Boolean {
+  val e = get(key) ?: return default
+  if (e.isJsonNull) return default
+  if (!e.isJsonPrimitive) return default
+  val p = e.asJsonPrimitive
+  return when {
+    p.isBoolean -> p.asBoolean
+    p.isString -> p.asString.equals("true", ignoreCase = true) || p.asString == "1"
+    p.isNumber -> p.asInt != 0
+    else -> default
+  }
+}
+
+internal fun JsonObject.booleanOrNull(key: String): Boolean? {
+  val e = get(key) ?: return null
+  if (e.isJsonNull) return null
+  if (!e.isJsonPrimitive) return null
+  val p = e.asJsonPrimitive
+  return when {
+    p.isBoolean -> p.asBoolean
+    p.isString -> when (p.asString.lowercase()) {
+      "true", "1" -> true
+      "false", "0" -> false
+      else -> null
+    }
+
+    p.isNumber -> p.asInt != 0
+    else -> null
+  }
+}
+
+internal fun JsonObject.intOrDefault(key: String, default: Int): Int {
+  val e = get(key) ?: return default
+  if (e.isJsonNull) return default
+  if (!e.isJsonPrimitive) return default
+  val p = e.asJsonPrimitive
+  return when {
+    p.isNumber -> p.asInt
+    p.isString -> p.asString.trim().toIntOrNull() ?: default
+    else -> default
+  }
+}
+
+internal fun JsonObject.longOrNull(key: String): Long? {
+  val e = get(key) ?: return null
+  if (e.isJsonNull) return null
+  if (!e.isJsonPrimitive) return null
+  val p = e.asJsonPrimitive
+  return when {
+    p.isNumber -> p.asLong
+    p.isString -> p.asString.trim().takeIf { it.isNotEmpty() }?.toLongOrNull()
+    else -> null
+  }
+}
+
+internal fun JsonObject.headersMap(): Map<String, String> {
+  val h = get("headers") ?: return emptyMap()
+  if (!h.isJsonObject) return emptyMap()
+  return buildMap {
+    for ((k, v) in h.asJsonObject.entrySet()) {
+      if (!v.isJsonNull && v.isJsonPrimitive) {
+        val prim = v.asJsonPrimitive
+        if (prim.isString) put(k, prim.asString)
+      }
+    }
+  }
+}
+
+
+/**
+ * 从 `data:image/png;base64,…` 等前缀解析 MIME;无有效类型时返回 `null`。
+ * 供 [DownloadRequest.Blob] 在 [DownloadRequest.Blob.contentType] 为空时补全。
+ */
+internal fun blobDataMimeFromPrefix(raw: String): String? {
+  val trimmed = raw.trim()
+  if (!trimmed.startsWith("data:", ignoreCase = true)) return null
+  val comma = trimmed.indexOf(',')
+  if (comma < 0) return null
+  val meta = trimmed.substring("data:".length, comma).trim()
+  if (meta.isEmpty()) return null
+  val primary = meta.substringBefore(';').trim()
+  if (primary.isEmpty() || primary.equals("base64", ignoreCase = true)) return null
+  return primary
+}
+
+internal fun attachmentContentDisposition(filename: String): String {
+  val escaped = filename.replace("\\", "\\\\").replace("\"", "\\\"")
+  return "attachment; filename=\"$escaped\""
+}

+ 39 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/download/onPermissionRequest.kt

@@ -0,0 +1,39 @@
+package com.hzliuzhi.applet.browser.download
+
+import android.app.Activity
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import com.hzliuzhi.applet.browser.webview.findActivityOrNull
+
+
+private const val REQUEST_CODE_POST_NOTIFICATIONS = 0x29A4
+
+/** [android.Manifest.permission.POST_NOTIFICATIONS] 仅在 API 33+ 存在;minSdk 26 下用字面量避免 Lint NewApi。 */
+private const val PERMISSION_POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"
+
+/**
+ * 若 [requestPermission] 非 null 则只执行它并结束;否则在 API 33+ 下从 [context] 解析 [Activity],
+ * 未授权时 [ActivityCompat.requestPermissions] 请求通知权限(仅 API 33+,见 [PERMISSION_POST_NOTIFICATIONS] 常量)。
+ */
+fun onPermissionRequest(
+  context: Context?,
+  requestPermission: (() -> Unit)? = null,
+) {
+  requestPermission?.also { it() }?.let { return@onPermissionRequest }
+  Build.VERSION.SDK_INT.takeIf { it >= Build.VERSION_CODES.TIRAMISU }?.run {
+    context.findActivityOrNull()
+      ?.takeIf { activity ->
+        ContextCompat.checkSelfPermission(activity, PERMISSION_POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+      }
+      ?.let { activity ->
+        ActivityCompat.requestPermissions(
+          activity,
+          arrayOf(PERMISSION_POST_NOTIFICATIONS),
+          REQUEST_CODE_POST_NOTIFICATIONS,
+        )
+      }
+  }
+}

+ 49 - 12
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebView.kt

@@ -1,14 +1,22 @@
 package com.hzliuzhi.applet.browser.webview
 
 import android.annotation.SuppressLint
+import android.app.Activity
 import android.content.Context
+import android.content.ContextWrapper
 import android.webkit.WebView
 import android.widget.FrameLayout
 import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.SnackbarHost
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.viewinterop.AndroidView
 import kotlinx.coroutines.CoroutineScope
 
@@ -18,6 +26,7 @@ fun WebView(
   layoutParams: FrameLayout.LayoutParams,
   modifier: Modifier = Modifier,
   controller: WebViewController = rememberWebViewController(),
+  downloader: WebViewDownloader = rememberWebViewDownloader(controller),
   onCreated: (WebView) -> Unit = {},
   onDispose: (WebView) -> Unit = {},
   viewClient: WrapperWebViewClient = remember { WrapperWebViewClient() },
@@ -34,21 +43,49 @@ fun WebView(
 
   WebViewPermission(controller)
   WebViewFileChooser(controller)
+  WebViewFileDownload(downloader = downloader, controller = controller)
 
-  AndroidView(
-    factory = { context ->
-      (factory?.invoke(context) ?: WebView(context)).apply {
-        this.layoutParams = layoutParams
-        this.webViewClient = viewClient
-        this.webChromeClient = chromeClient
-      }.also(onCreated)
-    },
-    onRelease = { onDispose(it) },
-    modifier = modifier,
-  )
+
+  Box(modifier = modifier) {
+    AndroidView(
+      factory = { context ->
+        (factory?.invoke(context) ?: WebView(context)).apply {
+          this.layoutParams = layoutParams
+          this.webViewClient = viewClient
+          this.webChromeClient = chromeClient
+          this.setDownloadListener(downloader)
+        }.also(onCreated)
+      },
+      onRelease = {
+        onDispose(it)
+      },
+      modifier = Modifier.fillMaxSize(),
+    )
+    SnackbarHost(
+      hostState = controller.snackbarHostState,
+      modifier = Modifier
+        .align(Alignment.BottomCenter)
+        .padding(bottom = 16.dp),
+    )
+  }
 }
 
 @Composable
 fun rememberWebViewController(coroutineScope: CoroutineScope = rememberCoroutineScope()) = remember(coroutineScope) {
   WebViewController(coroutineScope = coroutineScope)
-}
+}
+
+@Composable
+fun rememberWebViewDownloader(
+  controller: WebViewController,
+  coroutineScope: CoroutineScope = rememberCoroutineScope(),
+  requestPermission: (() -> Unit)? = null,
+) = remember(controller, coroutineScope, requestPermission) {
+  WebViewDownloader(coroutineScope = coroutineScope, controller = controller, requestPermission = requestPermission)
+}
+
+internal tailrec fun Context?.findActivityOrNull(): Activity? = when (this) {
+  is Activity -> this
+  is ContextWrapper -> baseContext.findActivityOrNull()
+  else -> null
+}

+ 26 - 2
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewController.kt

@@ -2,6 +2,9 @@ package com.hzliuzhi.applet.browser.webview
 
 import android.webkit.PermissionRequest
 import android.webkit.WebView
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
@@ -9,10 +12,10 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.launch
 
-
 class WebViewController(
   private val coroutineScope: CoroutineScope,
 ) {
+  val snackbarHostState = SnackbarHostState()
   val proxy = WebProxy()
   val navigator = WebViewNavigator(coroutineScope) { url -> proxy.loadUrl(url) }
   val bridge = WebViewBridge(coroutineScope)
@@ -46,5 +49,26 @@ class WebViewController(
   fun unconnected(webview: WebView) {
     proxy.allStop()
   }
-}
 
+  fun showSnackbar(
+    message: String,
+    actionLabel: String? = null,
+    withDismissAction: Boolean = false,
+    duration: SnackbarDuration = SnackbarDuration.Short,
+    onAction: (() -> Unit)? = null,
+    onDismiss: (() -> Unit)? = null,
+  ) {
+    coroutineScope.launch {
+      val result = snackbarHostState.showSnackbar(
+        message = message,
+        actionLabel = actionLabel,
+        withDismissAction = withDismissAction,
+        duration = duration,
+      )
+      when (result) {
+        SnackbarResult.ActionPerformed -> onAction?.invoke()
+        SnackbarResult.Dismissed -> onDismiss?.invoke()
+      }
+    }
+  }
+}

+ 284 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewDownloader.kt

@@ -0,0 +1,284 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.app.DownloadManager
+import android.content.Context
+import android.net.Uri
+import android.os.Environment
+import android.webkit.CookieManager
+import android.webkit.DownloadListener
+import com.hzliuzhi.applet.browser.download.DownloadRequest
+import com.hzliuzhi.applet.browser.download.DownloadSession
+import com.hzliuzhi.applet.browser.download.DownloadStatus
+import com.hzliuzhi.applet.browser.download.onPermissionRequest
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+import java.io.File
+import java.io.FileOutputStream
+import java.util.Base64
+import java.util.concurrent.ConcurrentHashMap
+import androidx.core.content.FileProvider
+import androidx.core.net.toUri
+
+class WebViewDownloader(
+  private val coroutineScope: CoroutineScope,
+  private val controller: WebViewController,
+  private val requestPermission: (() -> Unit)? = null,
+) : DownloadListener {
+
+  private val sessions = ConcurrentHashMap<String, DownloadSession>()
+  private val blobStreams = ConcurrentHashMap<String, FileOutputStream>()
+  private val blobFiles = ConcurrentHashMap<String, File>()
+  private val urlDownloadIds = ConcurrentHashMap<String, Long>()
+  private val urlJobs = ConcurrentHashMap<String, Job>()
+
+  private val _statusFlow = MutableSharedFlow<DownloadStatus>(extraBufferCapacity = 16)
+  val statusFlow: SharedFlow<DownloadStatus> = _statusFlow.asSharedFlow()
+
+  private val context: Context? get() = controller.webview?.context?.applicationContext
+
+  override fun onDownloadStart(
+    url: String?,
+    userAgent: String?,
+    contentDisposition: String?,
+    mimetype: String?,
+    contentLength: Long,
+  ) {
+    runCatching {
+      DownloadRequest.fromListener(
+        url = url,
+        userAgent = userAgent,
+        contentDisposition = contentDisposition,
+        mimetype = mimetype,
+        contentLength = contentLength,
+        cookie = CookieManager.getInstance().getCookie(url),
+      )
+    }.fold(
+      onSuccess = { start(it, null) },
+      onFailure = { e -> controller.showSnackbar(e.message ?: "当前地址无法下载") }
+    )
+  }
+
+  fun start(request: DownloadRequest, callback: ((Result<DownloadSession>) -> Unit)?) {
+    when (request) {
+      is DownloadRequest.Url -> startUrlDownload(request, callback)
+      is DownloadRequest.Blob -> handleBlobChunk(request, callback)
+    }
+  }
+
+  fun stop(request: DownloadRequest, callback: ((Result<DownloadSession>) -> Unit)?) {
+    val sessionId = request.id
+    val session = sessions.remove(sessionId)
+
+    when (request) {
+      is DownloadRequest.Url -> {
+        urlJobs.remove(sessionId)?.cancel()
+        urlDownloadIds.remove(sessionId)?.let { downloadId ->
+          val dm = context?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
+          dm?.remove(downloadId)
+        }
+      }
+      is DownloadRequest.Blob -> {
+        blobStreams.remove(sessionId)?.runCatching { close() }
+        blobFiles.remove(sessionId)?.runCatching { delete() }
+      }
+    }
+
+    if (session != null) {
+      _statusFlow.tryEmit(DownloadStatus.Failed(session, "用户取消"))
+      callback?.invoke(Result.success(session))
+    } else {
+      callback?.invoke(Result.failure(IllegalStateException("未找到下载会话: $sessionId")))
+    }
+  }
+
+  // ---------- URL download via DownloadManager ----------
+
+  private fun startUrlDownload(
+    request: DownloadRequest.Url,
+    callback: ((Result<DownloadSession>) -> Unit)?,
+  ) {
+    if (!request.force && sessions.containsKey(request.id)) {
+      callback?.invoke(Result.failure(IllegalStateException("该下载已在进行中")))
+      return
+    }
+
+    val ctx = context ?: run {
+      callback?.invoke(Result.failure(IllegalStateException("Context 不可用")))
+      return
+    }
+
+    val session = DownloadSession.fromRequest(request)
+    sessions[session.id] = session
+
+    onPermissionRequest(ctx, requestPermission)
+
+    val dm = ctx.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+    val dmRequest = DownloadManager.Request(request.url.toUri()).apply {
+      request.headers.forEach { (key, value) -> addRequestHeader(key, value) }
+      setTitle(session.name)
+      setDescription(session.type)
+      setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+      setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, session.name)
+    }
+
+    val downloadId = dm.enqueue(dmRequest)
+    urlDownloadIds[session.id] = downloadId
+    _statusFlow.tryEmit(DownloadStatus.Started(session))
+
+    val job = coroutineScope.launch(Dispatchers.IO) {
+      pollDownloadProgress(dm, downloadId, session, callback)
+    }
+    urlJobs[session.id] = job
+  }
+
+  private suspend fun pollDownloadProgress(
+    dm: DownloadManager,
+    downloadId: Long,
+    initialSession: DownloadSession,
+    callback: ((Result<DownloadSession>) -> Unit)?,
+  ) {
+    var current = initialSession
+    val query = DownloadManager.Query().setFilterById(downloadId)
+
+    while (true) {
+      delay(POLL_INTERVAL_MS)
+
+      dm.query(query)?.use { cursor ->
+        if (!cursor.moveToFirst()) return@use
+
+        val statusIdx = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)
+        val bytesIdx = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
+        val totalIdx = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
+        val localUriIdx = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)
+        val reasonIdx = cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)
+
+        val status = cursor.getInt(statusIdx)
+        val downloaded = cursor.getLong(bytesIdx)
+        val total = cursor.getLong(totalIdx)
+
+        val last = current.bytes
+
+        current = current.copy(
+          bytes = downloaded,
+          size = if (total > 0) total else current.size,
+        )
+        sessions[current.id] = current
+
+        when (status) {
+          DownloadManager.STATUS_RUNNING, DownloadManager.STATUS_PENDING -> {
+            if (last != current.bytes) _statusFlow.tryEmit(DownloadStatus.Progress(current))
+          }
+
+          DownloadManager.STATUS_SUCCESSFUL -> {
+            val localUri = cursor.getString(localUriIdx)
+            current = current.copy(uri = localUri)
+            sessions.remove(current.id)
+            urlDownloadIds.remove(current.id)
+            urlJobs.remove(current.id)
+            _statusFlow.tryEmit(DownloadStatus.Progress(current))
+            _statusFlow.tryEmit(DownloadStatus.Completed(current))
+            callback?.invoke(Result.success(current))
+            return
+          }
+
+          DownloadManager.STATUS_FAILED -> {
+            val reason = cursor.getInt(reasonIdx)
+            val msg = "下载失败 (reason=$reason)"
+            sessions.remove(current.id)
+            urlDownloadIds.remove(current.id)
+            urlJobs.remove(current.id)
+            _statusFlow.tryEmit(DownloadStatus.Progress(current))
+            _statusFlow.tryEmit(DownloadStatus.Failed(current, msg))
+            callback?.invoke(Result.failure(RuntimeException(msg)))
+            return
+          }
+        }
+      }
+    }
+  }
+
+  // ---------- Blob chunked download ----------
+
+  private fun handleBlobChunk(
+    request: DownloadRequest.Blob,
+    callback: ((Result<DownloadSession>) -> Unit)?,
+  ) {
+    coroutineScope.launch(Dispatchers.IO) {
+      runCatching {
+        val isFirstChunk = request.index == 0
+        val session = if (isFirstChunk) {
+          DownloadSession.fromRequest(request).also {
+            sessions[it.id] = it
+            _statusFlow.tryEmit(DownloadStatus.Started(it))
+          }
+        } else {
+          sessions[request.id] ?: error("未找到下载会话: ${request.id} (index=${request.index})")
+        }
+
+        val ctx = context ?: error("Context 不可用")
+
+        if (isFirstChunk) {
+          val downloadDir = ctx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
+            ?: error("无法访问下载目录")
+          downloadDir.mkdirs()
+          val file = File(downloadDir, session.name)
+          blobFiles[request.id] = file
+          blobStreams[request.id] = FileOutputStream(file, false)
+        }
+
+        val stream = blobStreams[request.id]
+          ?: error("写入流未初始化 (index=${request.index})")
+
+        val base64Data = request.data.let { raw ->
+          val commaIdx = raw.indexOf(',')
+          if (commaIdx >= 0) raw.substring(commaIdx + 1) else raw
+        }
+        val bytes = Base64.getDecoder().decode(base64Data)
+        stream.write(bytes)
+        stream.flush()
+
+        val updated = session.copy(bytes = session.bytes + bytes.size)
+        sessions[request.id] = updated
+
+        val isDone = request.done == true || (updated.size > 0L && updated.bytes >= updated.size)
+        if (isDone) {
+          stream.close()
+          blobStreams.remove(request.id)
+          val file = blobFiles.remove(request.id)
+          val finalSession = updated.copy(
+            uri = file?.let { f ->
+              FileProvider.getUriForFile(ctx, "${ctx.packageName}.file.provider", f).toString()
+            },
+          )
+          sessions.remove(request.id)
+          _statusFlow.tryEmit(DownloadStatus.Completed(finalSession))
+          finalSession
+        } else {
+          _statusFlow.tryEmit(DownloadStatus.Progress(updated))
+          updated
+        }
+      }.fold(
+        onSuccess = { session -> callback?.invoke(Result.success(session)) },
+        onFailure = { e ->
+          val session = sessions.remove(request.id)
+          if (session != null) {
+            _statusFlow.tryEmit(DownloadStatus.Failed(session, e.message ?: "写入失败"))
+          }
+          blobStreams.remove(request.id)?.runCatching { close() }
+          blobFiles.remove(request.id)?.runCatching { delete() }
+          callback?.invoke(Result.failure(e))
+        }
+      )
+    }
+  }
+
+  companion object {
+    private const val POLL_INTERVAL_MS = 500L
+  }
+}

+ 22 - 11
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewFileChooser.kt

@@ -1,6 +1,7 @@
 package com.hzliuzhi.applet.browser.webview
 
 import android.content.Context
+import android.content.pm.PackageManager
 import android.net.Uri
 import android.os.Environment
 import android.webkit.ValueCallback
@@ -19,16 +20,13 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
 import androidx.core.content.FileProvider
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.isGranted
-import com.google.accompanist.permissions.rememberPermissionState
 import java.io.File
 import java.text.SimpleDateFormat
 import java.util.Date
 import java.util.Locale
 
-@OptIn(ExperimentalPermissionsApi::class)
 @Composable
 fun WebViewFileChooser(controller: WebViewController) {
   val context = LocalContext.current.applicationContext
@@ -37,7 +35,7 @@ fun WebViewFileChooser(controller: WebViewController) {
   var showDialog by remember { mutableStateOf(false) }
   var temporaryUri by remember { mutableStateOf<Uri?>(null) }
 
-  var file: File? = null
+  var file by remember { mutableStateOf<File?>(null) }
 
 
   fun callback(values: Array<Uri>?) {
@@ -49,10 +47,9 @@ fun WebViewFileChooser(controller: WebViewController) {
   fun callback(value: Uri?) = callback(if (value != null) arrayOf(value) else emptyArray())
 
   fun callback() {
+    val staleCaptureFile = file
     callback(emptyArray())
-    // 清理未用的临时文件
-    file?.also { deleteTempFileByUri(it) }
-    file = null
+    staleCaptureFile?.also { deleteTempFileByUri(it) }
     showDialog = false
   }
 
@@ -87,13 +84,27 @@ fun WebViewFileChooser(controller: WebViewController) {
     callback(uris.takeIf { it.isNotEmpty() }?.toTypedArray())
   }
 
-  val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
+  val cameraPermissionLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.RequestPermission()
+  ) { granted ->
+    if (granted) {
+      file?.also { deleteTempFileByUri(it) }
+      temporaryUri = createImageUri(context) { file = it }.also { capturePictureLauncher.launch(it) }
+    } else {
+      callback()
+    }
+  }
+
   fun capturePicture() {
     file?.also { deleteTempFileByUri(it) }
-    if (cameraPermissionState.status.isGranted) {
+    val hasCameraPermission = ContextCompat.checkSelfPermission(
+      context,
+      android.Manifest.permission.CAMERA,
+    ) == PackageManager.PERMISSION_GRANTED
+    if (hasCameraPermission) {
       temporaryUri = createImageUri(context) { file = it }.also { capturePictureLauncher.launch(it) }
     } else {
-      cameraPermissionState.launchPermissionRequest()
+      cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
     }
     showDialog = false
   }

+ 116 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewFileDownload.kt

@@ -0,0 +1,116 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import androidx.core.content.FileProvider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.core.net.toUri
+import com.google.gson.JsonElement
+import com.hzliuzhi.applet.browser.download.DownloadRequest
+import com.hzliuzhi.applet.browser.download.DownloadSession
+import com.hzliuzhi.applet.browser.download.DownloadStatus
+import com.hzliuzhi.applet.core.shared.Payload
+import com.hzliuzhi.applet.core.shared.SharedFlowHub
+import com.hzliuzhi.applet.core.shared.SharedFlowHub.cast
+import kotlinx.coroutines.flow.filter
+import java.io.File
+
+@Composable
+fun WebViewFileDownload(downloader: WebViewDownloader, controller: WebViewController) {
+  LaunchedEffect(Unit) {
+    SharedFlowHub.events.filter { event -> event.type == "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:download:start" }.collect { event ->
+      event.cast<JsonElement, JsonElement>()?.also { (_, payload, callback) ->
+        runCatching {
+          DownloadRequest.fromPayload(payload)
+        }.onSuccess { request ->
+          downloader.start(request = request) { result ->
+            result.fold(
+              onSuccess = { session ->
+                Payload.data(session).toJson()?.also { callback?.invoke(it) }
+              },
+              onFailure = { e ->
+                Payload.error<DownloadSession>(message = e.message ?: "下载失败").toJson()?.also { callback?.invoke(it) }
+              }
+            )
+          }
+        }.onFailure { e -> Payload.error<JsonElement>(message = e.message ?: "参数解析失败").toJson()?.also { callback?.invoke(it) } }
+      }
+    }
+  }
+
+  LaunchedEffect(Unit) {
+    SharedFlowHub.events.filter { event -> event.type == "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:download:stop" }.collect { event ->
+      event.cast<JsonElement, JsonElement>()?.also { (_, payload, callback) ->
+        runCatching {
+          DownloadRequest.fromPayload(payload)
+        }.onSuccess { request ->
+          downloader.stop(request = request) { result ->
+            result.fold(
+              onSuccess = { session ->
+                Payload.data(session).toJson()?.also { callback?.invoke(it) }
+              },
+              onFailure = { e ->
+                Payload.error<DownloadSession>(message = e.message ?: "停止下载失败").toJson()?.also { callback?.invoke(it) }
+              }
+            )
+          }
+        }.onFailure { e -> Payload.error<JsonElement>(message = e.message ?: "参数解析失败").toJson()?.also { callback?.invoke(it) } }
+      }
+    }
+  }
+
+  LaunchedEffect(Unit) {
+    downloader.statusFlow.collect { status ->
+      when (status) {
+        is DownloadStatus.Started -> controller.showSnackbar("开始下载: ${status.session.name}")
+        is DownloadStatus.Completed -> controller.showSnackbar("下载完成: ${status.session.name}", actionLabel = "打开", onAction = {
+          val activity = controller.webview?.context.findActivityOrNull()
+          val appCtx = activity?.applicationContext
+          if (appCtx == null) {
+            controller.showSnackbar("当前页面不可用,无法打开文件")
+            return@showSnackbar
+          }
+          if (!appCtx.openDownloadedContent(status.session.uri?.toUri(), status.session.type)) {
+            controller.showSnackbar("无法打开该文件,请确认已安装可查看此格式的应用")
+          }
+        })
+        is DownloadStatus.Failed -> controller.showSnackbar("下载失败: ${status.message}")
+        is DownloadStatus.Progress -> { }
+      }
+    }
+  }
+}
+
+
+/** @return 是否已成功发起查看文件的 Intent */
+internal fun Context.openDownloadedContent(uri: Uri?, mimeType: String?): Boolean {
+  if (uri == null) return false
+  // API 24+:禁止在 Intent 中直接使用 file://,需改为 FileProvider 的 content:// 并授予读权限
+  val contentUri = when (uri.scheme?.lowercase()) {
+    "file" -> {
+      val path = uri.path ?: return false
+      runCatching {
+        FileProvider.getUriForFile(this, "${packageName}.file.provider", File(path))
+      }.getOrElse {
+        Log.w("WebViewFileDownload", "无法为下载文件生成 content URI: ${it.message}")
+        return false
+      }
+    }
+    else -> uri
+  }
+  val intent = Intent(Intent.ACTION_VIEW).apply {
+    setDataAndType(contentUri, mimeType ?: "*/*")
+    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+  }
+  return runCatching {
+    startActivity(intent)
+    true
+  }.getOrElse {
+    Log.w("WebViewFileDownload", "打开下载文件失败: ${it.message}")
+    false
+  }
+}

+ 4 - 0
library/browser/src/main/res/xml/file_paths.xml

@@ -1,4 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <paths>
   <external-files-path name="images" path="Pictures" />
+  <!-- Blob 等写入 getExternalFilesDir(Download) 后的 FileProvider 根 -->
+  <external-files-path name="app_downloads" path="Download" />
+  <!-- 与 [Environment.DIRECTORY_DOWNLOADS] 公共目录一致,供 DownloadManager 落盘后分享 -->
+  <external-path name="public_downloads" path="Download" />
 </paths>