feat(android): add download status notifications for 1.0.3
This commit is contained in:
@@ -75,5 +75,6 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 面板通过 HTTPS 反代访问,必须声明网络权限(release 模板默认不含) -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="DSH 工作台"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.text.format.Formatter
|
||||
import java.util.UUID
|
||||
|
||||
internal class DownloadNotifications(context: Context, private val name: String, headers: Map<String, String>) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ren.misaka.dshapp
|
||||
|
||||
internal class DownloadProgress(headers: Map<String, String>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ internal class GeckoPlatformView(
|
||||
private val prompts = mutableMapOf<BasePrompt, () -> Unit>()
|
||||
private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>())
|
||||
private val io = Executors.newSingleThreadExecutor()
|
||||
private val downloads = java.util.Collections.synchronizedSet(mutableSetOf<DownloadNotifications>())
|
||||
@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)
|
||||
|
||||
@@ -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<GeckoPlatformView>()
|
||||
private val requests = mutableMapOf<Int, Pair<GeckoPlatformView, (Int, Intent?) -> Unit>>()
|
||||
private var nextRequest = 2000
|
||||
private val documentQueue = ArrayDeque<Pair<Int, Intent>>()
|
||||
private var activeDocument: Int? = null
|
||||
private val permissionWaiters = mutableMapOf<GeckoPlatformView, MutableList<(Boolean) -> 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<out String>, 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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user