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