diff --git a/README.md b/README.md
index d1b37b9..2e286d1 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoVie
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`` 走系统文件选择器(工作区上传、备份导入)
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
+- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
- 返回键:优先回退页面历史,无历史时退出应用
- 加载失败重试页、JS 原生对话框桥接
@@ -64,6 +65,7 @@ CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD`
- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
- 工作区单文件/多文件上传、选择器取消、配置备份导入。
- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
+- 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。
- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
- 断网重试与无效证书拒绝;确认没有绕过 TLS 校验。
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 3cf0722..f134f99 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -75,5 +75,6 @@ flutter {
}
dependencies {
+ testImplementation("junit:junit:4.13.2")
implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 20bc1db..2478a6a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,6 +1,7 @@
+
) {
+ private val context = context.applicationContext
+ private val tag = UUID.randomUUID().toString()
+ private val progress = DownloadProgress(headers)
+
+ @Synchronized fun update(bytes: Int = 0) {
+ if (progress.finished) return
+ progress.add(bytes)
+ if (!progress.shouldUpdate(SystemClock.elapsedRealtime())) return
+ post(null)
+ }
+
+ @Synchronized fun finish(status: String) {
+ if (progress.finish()) post(status)
+ }
+
+ private fun post(status: String?) {
+ runCatching {
+ val manager = context.getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(NotificationChannel(CHANNEL, "下载", NotificationManager.IMPORTANCE_LOW).apply {
+ lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ setShowBadge(false)
+ })
+ val percent = progress.percent
+ val size = Formatter.formatShortFileSize(context, progress.written)
+ val text = status ?: (if (percent == null) "正在下载 · 已写入 $size" else "正在下载 · $percent% · 已写入 $size")
+ val notification = Notification.Builder(context, CHANNEL)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentTitle(name).setContentText(text)
+ .setVisibility(Notification.VISIBILITY_PRIVATE)
+ .setOnlyAlertOnce(true).setOngoing(status == null)
+ .setProgress(if (status == null) 100 else 0, if (status == null) percent ?: 0 else 0, status == null && percent == null)
+ .build()
+ manager.notify(tag, 0, notification)
+ }.onFailure {
+ if (status != null) runCatching {
+ context.getSystemService(NotificationManager::class.java).cancel(tag, 0)
+ }
+ }
+ }
+
+ companion object {
+ private const val CHANNEL = "downloads"
+ }
+}
diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/DownloadProgress.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/DownloadProgress.kt
new file mode 100644
index 0000000..dac0ca9
--- /dev/null
+++ b/android/app/src/main/kotlin/ren/misaka/dshapp/DownloadProgress.kt
@@ -0,0 +1,30 @@
+package ren.misaka.dshapp
+
+internal class DownloadProgress(headers: Map) {
+ val total: Long? = run {
+ fun header(name: String) = headers.entries.firstOrNull { it.key.equals(name, true) }?.value
+ if (header("content-encoding").let { it.isNullOrBlank() || it.equals("identity", true) }) {
+ header("content-length")?.trim()?.toLongOrNull()?.takeIf { it > 0 }
+ } else null
+ }
+ var written = 0L
+ private set
+ private var lastUpdate: Long? = null
+ var finished = false
+ private set
+ val percent: Int? get() = total?.takeIf { written <= it }?.let {
+ (written.toDouble() / it * 100).toInt().coerceIn(0, 100)
+ }
+
+ fun add(bytes: Int) { written += bytes }
+ fun shouldUpdate(now: Long): Boolean {
+ if (finished || lastUpdate?.let { now - it < 500 } == true) return false
+ lastUpdate = now
+ return true
+ }
+ fun finish(): Boolean {
+ if (finished) return false
+ finished = true
+ return true
+ }
+}
diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt
index bad78d5..952520b 100644
--- a/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt
+++ b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt
@@ -46,6 +46,7 @@ internal class GeckoPlatformView(
private val prompts = mutableMapOf Unit>()
private val streams = java.util.Collections.synchronizedSet(mutableSetOf())
private val io = Executors.newSingleThreadExecutor()
+ private val downloads = java.util.Collections.synchronizedSet(mutableSetOf())
@Volatile private var disposed = false
private var loaded = false
private var opened = false
@@ -118,6 +119,7 @@ internal class GeckoPlatformView(
loadFailed = true
view.releaseSession()
activity.cancelDocuments(this)
+ cancelDownloads()
prompts.values.toList().forEach { it() }
emit("onLoadError", "网页进程已停止,请重试")
}
@@ -239,6 +241,7 @@ internal class GeckoPlatformView(
if (body == null) { emit("onDownloadError", "下载响应没有内容"); return }
if (disposed) { runCatching { body.close() }; return }
streams.add(body)
+ var notification: DownloadNotifications? = null
try {
val uri = Uri.parse(response.uri)
val disposition = response.headers.entries.firstOrNull { it.key.equals("content-disposition", true) }?.value
@@ -252,29 +255,67 @@ internal class GeckoPlatformView(
.map { if (it.isISOControl() || it in "\\/:*?\"<>|") '_' else it }.joinToString("")
.take(120).ifBlank { "download" }
val mime = response.headers.entries.firstOrNull { it.key.equals("content-type", true) }?.value?.substringBefore(';') ?: "application/octet-stream"
+ val download = DownloadNotifications(activity.applicationContext, name, response.headers)
+ notification = download
+ downloads.add(download)
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE)
.setType(mime).putExtra(Intent.EXTRA_TITLE, name)
activity.launchDocument(this, intent) { code, data ->
val destination = data?.data
if (code != Activity.RESULT_OK || destination == null || disposed) {
+ download.finish("下载已取消")
+ downloads.remove(download)
closeBody(body)
} else {
- io.execute {
+ activity.withDownloadNotifications(this) { continueDownload ->
try {
- check(!disposed) { "下载已取消" }
- activity.contentResolver.openOutputStream(destination, "w").use { output ->
- checkNotNull(output) { "无法打开目标文件" }
- body.use { it.copyTo(output, 64 * 1024) }
+ check(continueDownload && !disposed && streams.contains(body)) { "下载已取消" }
+ io.execute {
+ try {
+ check(!disposed && streams.contains(body)) { "下载已取消" }
+ download.update()
+ activity.contentResolver.openOutputStream(destination, "w").use { output ->
+ checkNotNull(output) { "无法打开目标文件" }
+ body.use { input ->
+ val buffer = ByteArray(64 * 1024)
+ while (true) {
+ check(!disposed && streams.contains(body)) { "下载已取消" }
+ val count = input.read(buffer)
+ if (count < 0) break
+ output.write(buffer, 0, count)
+ download.update(count)
+ }
+ }
+ }
+ download.finish(if (disposed || !streams.contains(body)) "下载已取消" else "下载完成")
+ activity.runOnUiThread { if (!disposed) Toast.makeText(activity, "下载已保存", Toast.LENGTH_SHORT).show() }
+ } catch (error: Exception) {
+ download.finish(if (disposed || !streams.contains(body)) "下载已取消" else "下载失败")
+ runCatching { DocumentsContract.deleteDocument(activity.contentResolver, destination) }
+ emit("onDownloadError", "保存下载失败:" + error.message)
+ } finally { downloads.remove(download); closeBody(body) }
}
- activity.runOnUiThread { if (!disposed) Toast.makeText(activity, "下载已保存", Toast.LENGTH_SHORT).show() }
} catch (error: Exception) {
+ download.finish(if (!continueDownload || disposed || !streams.contains(body)) "下载已取消" else "下载失败")
+ downloads.remove(download)
+ closeBody(body)
runCatching { DocumentsContract.deleteDocument(activity.contentResolver, destination) }
emit("onDownloadError", "保存下载失败:" + error.message)
- } finally { closeBody(body) }
+ }
}
}
}
- } catch (error: Exception) { closeBody(body); emit("onDownloadError", "无法保存下载:" + error.message) }
+ } catch (error: Exception) {
+ notification?.let { it.finish("下载失败"); downloads.remove(it) }
+ closeBody(body)
+ emit("onDownloadError", "无法保存下载:" + error.message)
+ }
+ }
+
+ private fun cancelDownloads() {
+ synchronized(downloads) { downloads.toList() }.forEach { it.finish("下载已取消") }
+ downloads.clear()
+ synchronized(streams) { streams.toList() }.forEach { closeBody(it) }
}
private fun closeBody(body: InputStream) { streams.remove(body); runCatching { body.close() } }
@@ -287,7 +328,7 @@ internal class GeckoPlatformView(
channel.setMethodCallHandler(null)
activity.cancelDocuments(this)
prompts.values.toList().forEach { it() }
- synchronized(streams) { streams.toList() }.forEach { closeBody(it) }
+ cancelDownloads()
io.shutdown()
if (opened) {
session.setActive(false)
diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt
index 5819e07..09d1cbf 100644
--- a/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt
+++ b/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt
@@ -1,6 +1,10 @@
package ren.misaka.dshapp
import android.content.Intent
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.widget.Toast
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -8,6 +12,10 @@ class MainActivity : FlutterActivity() {
internal val browserViews = mutableSetOf()
private val requests = mutableMapOf Unit>>()
private var nextRequest = 2000
+ private val documentQueue = ArrayDeque>()
+ private var activeDocument: Int? = null
+ private val permissionWaiters = mutableMapOf Unit>>()
+ private var permissionPending = false
internal var browserActive = false
private set
@@ -22,24 +30,73 @@ class MainActivity : FlutterActivity() {
val code = nextRequest++
check(code <= 65535) { "Too many document requests" }
requests[code] = owner to callback
- try {
- startActivityForResult(intent, code)
- } catch (error: Exception) {
- requests.remove(code)
- throw error
+ documentQueue.addLast(code to intent)
+ drainDialogs()
+ }
+
+ internal fun withDownloadNotifications(owner: GeckoPlatformView, callback: (Boolean) -> Unit) {
+ permissionWaiters.getOrPut(owner) { mutableListOf() }.add(callback)
+ drainDialogs()
+ }
+
+ private fun drainDialogs() {
+ if (activeDocument != null || permissionPending || isFinishing || isDestroyed) return
+ if (permissionWaiters.isNotEmpty()) {
+ val preferences = getSharedPreferences("download_notifications", MODE_PRIVATE)
+ if (Build.VERSION.SDK_INT >= 33 &&
+ checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED &&
+ !preferences.getBoolean("requested", false)) {
+ preferences.edit().putBoolean("requested", true).apply()
+ permissionPending = true
+ try {
+ requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1900)
+ return
+ } catch (_: Exception) {
+ permissionPending = false
+ notificationDenied()
+ }
+ }
+ val callbacks = permissionWaiters.values.flatMap { it.toList() }
+ permissionWaiters.clear()
+ callbacks.forEach { it(true) }
+ }
+ while (documentQueue.isNotEmpty()) {
+ val (code, intent) = documentQueue.removeFirst()
+ if (!requests.containsKey(code)) continue
+ activeDocument = code
+ try { startActivityForResult(intent, code); return } catch (_: Exception) {
+ activeDocument = null
+ requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
+ }
+ }
+ }
+
+ private fun notificationDenied() {
+ Toast.makeText(applicationContext, "未允许下载通知,下载仍会保存到所选位置", Toast.LENGTH_LONG).show()
+ }
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode == 1900) {
+ permissionPending = false
+ if (grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) notificationDenied()
+ drainDialogs()
}
}
internal fun cancelDocuments(owner: GeckoPlatformView) {
+ permissionWaiters.remove(owner)?.forEach { it(false) }
requests.entries.filter { it.value.first === owner }.map { it.key }.forEach { code ->
requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ if (activeDocument == requestCode) activeDocument = null
val request = requests.remove(requestCode)
if (request != null) request.second(resultCode, data)
else super.onActivityResult(requestCode, resultCode, data)
+ drainDialogs()
}
override fun onResume() {
diff --git a/android/app/src/test/kotlin/ren/misaka/dshapp/DownloadProgressTest.kt b/android/app/src/test/kotlin/ren/misaka/dshapp/DownloadProgressTest.kt
new file mode 100644
index 0000000..e32b628
--- /dev/null
+++ b/android/app/src/test/kotlin/ren/misaka/dshapp/DownloadProgressTest.kt
@@ -0,0 +1,55 @@
+package ren.misaka.dshapp
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class DownloadProgressTest {
+ @Test fun knownLengthUsesWrittenBytes() {
+ val progress = DownloadProgress(mapOf("Content-Length" to "1000"))
+ assertEquals(0, progress.percent)
+ progress.add(250)
+ assertEquals(25, progress.percent)
+ assertEquals(250L, progress.written)
+ progress.add(750)
+ assertEquals(100, progress.percent)
+ progress.add(1)
+ assertNull(progress.percent)
+ }
+
+ @Test fun invalidOrMissingLengthsAreIndeterminate() {
+ for (length in listOf(null, "", "0", "-1", "abc", "9223372036854775808")) {
+ val progress = DownloadProgress(length?.let { mapOf("content-length" to it) } ?: emptyMap())
+ progress.add(1024)
+ assertNull(progress.total)
+ assertNull(progress.percent)
+ assertEquals(1024L, progress.written)
+ }
+ }
+
+ @Test fun encodedResponsesIgnoreWireLength() {
+ for (encoding in listOf("gzip", "br", "gzip, br")) {
+ assertNull(DownloadProgress(mapOf("content-length" to "100", "Content-Encoding" to encoding)).total)
+ }
+ assertEquals(100L, DownloadProgress(mapOf("content-length" to "100", "Content-Encoding" to "IDENTITY")).total)
+ }
+
+ @Test fun progressIsThrottledButTerminalStateIsImmediateAndFinal() {
+ val progress = DownloadProgress(emptyMap())
+ assertTrue(progress.shouldUpdate(0))
+ assertFalse(progress.shouldUpdate(499))
+ assertTrue(progress.shouldUpdate(500))
+ assertFalse(progress.shouldUpdate(999))
+ assertTrue(progress.shouldUpdate(1000))
+ assertTrue(progress.finish())
+ assertFalse(progress.finish())
+ assertFalse(progress.shouldUpdate(2000))
+ }
+
+ @Test fun percentageDoesNotOverflowLong() {
+ val progress = DownloadProgress(mapOf("content-length" to Long.MAX_VALUE.toString()))
+ progress.add(Int.MAX_VALUE)
+ progress.add(Int.MAX_VALUE)
+ assertEquals(4294967294L, progress.written)
+ assertEquals(0, progress.percent)
+ }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index af108f2..20403e5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 1.0.2+3
+version: 1.0.3+4
environment:
sdk: ^3.13.5