From 836d87678327f14f29cd0c5852b04ce8126d6a75 Mon Sep 17 00:00:00 2001 From: m1saka Date: Sun, 18 Oct 2026 21:57:41 +0800 Subject: [PATCH] feat(android): add download status notifications for 1.0.3 --- README.md | 2 + android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 1 + .../misaka/dshapp/DownloadNotifications.kt | 55 +++++++++++++++ .../ren/misaka/dshapp/DownloadProgress.kt | 30 +++++++++ .../ren/misaka/dshapp/GeckoPlatformView.kt | 59 +++++++++++++--- .../kotlin/ren/misaka/dshapp/MainActivity.kt | 67 +++++++++++++++++-- .../ren/misaka/dshapp/DownloadProgressTest.kt | 55 +++++++++++++++ pubspec.yaml | 2 +- 9 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 android/app/src/main/kotlin/ren/misaka/dshapp/DownloadNotifications.kt create mode 100644 android/app/src/main/kotlin/ren/misaka/dshapp/DownloadProgress.kt create mode 100644 android/app/src/test/kotlin/ren/misaka/dshapp/DownloadProgressTest.kt 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