Compare commits

4 Commits
18 changed files with 1252 additions and 407 deletions
+7 -1
View File
@@ -85,10 +85,16 @@ Desktop.ini
# Local agent/browser tooling
.mimocode/
.playwright-cli/
/output/playwright/
# Dependencies and caches left by the web branch
# Dependencies, build outputs and caches left by the web branch
node_modules/
.vite/
dist/
*.tsbuildinfo
# Locally compiled manager binary
/bin/dsh-mgr
# Keep the Gradle Wrapper in version control
!gradle/wrapper/gradle-wrapper.jar
+6 -4
View File
@@ -41,18 +41,20 @@ RUN curl -fsSL -o /tmp/tools.zip \
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm /tmp/tools.zip \
&& (yes | sdkmanager --licenses > /dev/null || true) \
&& sdkmanager "platform-tools" > /dev/null
&& sdkmanager "platform-tools" "platforms;android-37.0" > /dev/null
RUN flutter config --android-sdk "${ANDROID_HOME}" \
&& flutter --disable-analytics \
&& flutter precache --android \
&& flutter doctor
# 预热:跑一次与应用相同依赖的默认模板构建,把 Gradle 发行版/AGP/AndroidX
# 组件、插件 AAR 和 SDK 平台下载进镜像,之后挂载源码的正式构建不再重复下载
# 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本
# 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。
# Java/Gradle 不自动读取 http_proxy;仅构建时注入 JVM 代理参数,不固化到镜像 ENV。
ARG JAVA_TOOL_OPTIONS
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
&& cd /tmp/warmup \
&& flutter pub add webview_flutter webview_flutter_android file_selector \
&& flutter build apk --release \
&& cd / && rm -rf /tmp/warmup \
&& flutter doctor
+30 -7
View File
@@ -1,21 +1,25 @@
# DSH 工作台(Android)
DSH Web Manager 的 Android 客户端:基于系统 WebView 的轻量壳应用,全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoView 内核,不依赖手机的系统 WebView。全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
## 特性
- 系统 WebView(`webview_flutter`),不打包浏览器内核,体积小
- 登录状态持久化:系统 WebView 的 Cookie + DOM 存储跨重启保留
- 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
- 文件下载:拦截面板下载导航,交给系统 DownloadManager(携带登录 Cookie)
- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
- 返回键:优先回退页面历史,无历史时退出应用
- 加载失败重试页、JS 原生对话框(alert/confirm)桥接
- 加载失败重试页、JS 原生对话框桥接
## 构建
前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。
Java/Gradle 不自动使用上述代理变量。需要时另外设置 `JAVA_TOOL_OPTIONS`,例如 `-Dhttp.proxyHost=代理主机 -Dhttp.proxyPort=端口 -Dhttps.proxyHost=代理主机 -Dhttps.proxyPort=端口`;`build.sh` 会将其传入镜像构建和 APK 构建容器。代理必须能从容器访问,不要把宿主机的 `127.0.0.1` 直接当成容器代理地址。Java 会输出此变量,切勿在其中放入密码或其他秘密。
```sh
./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release
```
@@ -47,7 +51,26 @@ CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD`
## 注意事项
- **证书**:反代必须使用系统 WebView 信任的证书(如 Let's Encrypt)。自签证书会被拒绝,WebView 无法像浏览器那样手动放行。
- **系统要求**:当前 GeckoView 158 最低支持 Android 8.0(API 26),构建使用 Android SDK 37.0;旧 Android 设备不能安装此版本。已有工具链镜像可用 `--no-cache` 重建以预装新 SDK。
- **证书**:反代必须使用 Gecko 信任的有效证书(如 Let's Encrypt)。不提供忽略 TLS 错误或自签证书放行开关。
- **签名**:release 必须提供外部密钥库;缺失配置或密码/alias 不正确时构建失败,不回退到 debug 签名。此前的 debug 签名 APK 无法被新签名直接覆盖安装。
- **Android 9 及以下**:下载保存到应用外部目录(免存储权限),不进公共下载目录。
- **升级迁移**:旧版系统 WebView 的 Cookie/LocalStorage 不会迁移到 Gecko;首次升级需重新登录,服务器端数据不受影响。
- **内核更新**:GeckoView 版本固定在 `android/app/build.gradle.kts`,来自 Mozilla 官方 Maven 仓库。内核随 APK 发布,不会自动下载替换;应定期检查稳定版安全更新,更新版本并完成回归后发布新 APK。
- **开源许可**:GeckoView/Gecko 包含 MPL 2.0 等许可证下的组件。分发 APK 时需保留上游许可声明并履行相应源码提供义务;参见 [Mozilla 许可说明](https://www.mozilla.org/MPL/) 与 [GeckoView 项目](https://mozilla.github.io/geckoview/)。
- **体积**:APK 包含 Gecko 原生库,体积会明显增加;继续按 ABI 分包,用户只安装匹配设备架构的 APK。
- **下载生命周期**:保存过程中请保持应用运行;退出应用或进程被系统终止会中断下载,不提供系统 DownloadManager 的后台续传能力。
- **图标**:使用仓库根目录的 `logo.webp`。API 26+ 使用浅冰蓝底色的自适应图标,旧系统使用同一 logo 的传统图标;各密度资源均已生成。
## 内核升级回归
发布前除 `flutter analyze`、`flutter test` 和 APK 构建外,应在真实 Android 设备上检查:
- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
- 工作区单文件/多文件上传、选择器取消、配置备份导入。
- 从下载目录、内部存储及云盘提供程序选择文件;验证中文名、同名多选、空文件、大文件、复制失败及复制中退出。目录上传仍走 Gecko 原有目录 URI 处理路径,需单独验收。
- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
- 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。
- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
- 断网重试与无效证书拒绝;确认没有绕过 TLS 校验。
Flutter 桥接单测不运行 Gecko 内核,不能替代上述真机测试。
+7 -2
View File
@@ -13,7 +13,7 @@ if (gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = tr
android {
namespace = "ren.misaka.dshapp"
compileSdk = flutter.compileSdkVersion
compileSdk { version = release(37) { minorApiLevel = 0 } }
ndkVersion = flutter.ndkVersion
compileOptions {
@@ -26,7 +26,7 @@ android {
applicationId = "ren.misaka.dshapp"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 26
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
@@ -73,3 +73,8 @@ kotlin {
flutter {
source = "../.."
}
dependencies {
testImplementation("junit:junit:4.13.2")
implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
}
+1
View File
@@ -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
}
}
@@ -0,0 +1,425 @@
package ren.misaka.dshapp
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.CancellationSignal
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.view.View
import android.widget.EditText
import android.widget.Toast
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
import org.mozilla.geckoview.*
import org.mozilla.geckoview.GeckoSession.PromptDelegate.*
import java.io.InputStream
import java.util.concurrent.Executors
import java.util.UUID
private object BrowserUploads {
val executor = Executors.newCachedThreadPool()
private var cache: UploadCache? = null
@Synchronized fun get(context: Context): UploadCache = cache ?: UploadCache(context.cacheDir).also { cache = it }
}
private object BrowserRuntime {
private var instance: GeckoRuntime? = null
fun get(context: Context): GeckoRuntime = instance ?: GeckoRuntime.create(context.applicationContext).also { instance = it }
}
internal class GeckoViewFactory(private val activity: MainActivity, private val messenger: BinaryMessenger) :
PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
val url = (args as? Map<*, *>)?.get("url") as? String
val uri = url?.let { Uri.parse(it) }
require(uri != null && uri.scheme == "https" && !uri.host.isNullOrBlank() && uri.userInfo == null) {
"GeckoView requires an HTTPS url without credentials"
}
return GeckoPlatformView(activity, context, messenger, viewId, url!!)
}
}
internal class GeckoPlatformView(
private val activity: MainActivity, context: Context, messenger: BinaryMessenger, id: Int,
private val initialUrl: String
) : PlatformView {
private val view = GeckoView(context).apply { setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW) }
private val session = GeckoSession(GeckoSessionSettings.Builder().usePrivateMode(false).build())
private val channel = MethodChannel(messenger, "dsh.workbench/gecko/" + id)
private val prompts = mutableMapOf<BasePrompt, () -> Unit>()
private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>())
private val io = Executors.newSingleThreadExecutor()
private var uploadSession = UUID.randomUUID().toString()
private val downloads = java.util.Collections.synchronizedSet(mutableSetOf<DownloadNotifications>())
@Volatile private var disposed = false
private var loaded = false
private var opened = false
private var canGoBack = false
private var loadFailed = false
init {
val appContext = activity.applicationContext
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(appContext) } }
session.progressDelegate = object : GeckoSession.ProgressDelegate {
override fun onPageStart(session: GeckoSession, url: String) { loadFailed = false; emit("onPageStarted") }
override fun onPageStop(session: GeckoSession, success: Boolean) {
if (!loadFailed) emit("onPageFinished")
}
}
session.navigationDelegate = object : GeckoSession.NavigationDelegate {
override fun onCanGoBack(session: GeckoSession, value: Boolean) { canGoBack = value }
override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
loadFailed = true
emit("onLoadError", "页面加载失败 (" + error.code + ")")
return null
}
}
session.contentDelegate = object : GeckoSession.ContentDelegate {
override fun onExternalResponse(session: GeckoSession, response: WebResponse) { saveResponse(response) }
override fun onCrash(session: GeckoSession) { processStopped() }
override fun onKill(session: GeckoSession) { processStopped() }
}
session.promptDelegate = object : GeckoSession.PromptDelegate {
override fun onAlertPrompt(session: GeckoSession, prompt: AlertPrompt) =
dialog(prompt, prompt.message, positive = { prompt.dismiss() })
override fun onButtonPrompt(session: GeckoSession, prompt: ButtonPrompt) =
dialog(prompt, prompt.message, positive = { prompt.confirm(ButtonPrompt.Type.POSITIVE) }, negative = { prompt.confirm(ButtonPrompt.Type.NEGATIVE) })
override fun onTextPrompt(session: GeckoSession, prompt: TextPrompt): GeckoResult<PromptResponse> {
val input = EditText(activity).apply { setText(prompt.defaultValue) }
return dialog(prompt, prompt.message, input, { prompt.confirm(input.text.toString()) }, { prompt.dismiss() })
}
override fun onFilePrompt(session: GeckoSession, prompt: FilePrompt) = chooseFiles(prompt)
override fun onChoicePrompt(session: GeckoSession, prompt: ChoicePrompt) = chooseOptions(prompt)
}
activity.browserViews.add(this)
channel.setMethodCallHandler { call, result ->
try {
when (call.method) {
"load" -> { loadInitial(); result.success(null) }
"goBack" -> { val back = canGoBack; if (back) session.goBack(); result.success(back) }
"reload" -> { if (loaded) session.reload() else loadInitial(); result.success(null) }
else -> result.notImplemented()
}
} catch (error: Exception) { result.error("GECKO_ERROR", error.message, null) }
}
}
private fun emit(method: String, value: String? = null) {
activity.runOnUiThread { if (!disposed) channel.invokeMethod(method, value) }
}
private fun loadInitial() {
if (!opened) {
session.open(BrowserRuntime.get(activity))
opened = true
view.setSession(session)
setActive(activity.browserActive)
}
if (!loaded) { session.loadUri(initialUrl); loaded = true }
}
private fun processStopped() {
opened = false
loaded = false
canGoBack = false
loadFailed = true
view.releaseSession()
activity.cancelDocuments(this)
cancelDownloads()
prompts.values.toList().forEach { it() }
emit("onLoadError", "网页进程已停止,请重试")
closeUploads()
}
private fun closeUploads() {
val previous = uploadSession
uploadSession = UUID.randomUUID().toString()
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(activity.applicationContext).closeSession(previous) } }
}
private fun chooseOptions(prompt: ChoicePrompt): GeckoResult<PromptResponse> {
val result = GeckoResult<PromptResponse>()
val choices = mutableListOf<ChoicePrompt.Choice>()
fun flatten(items: Array<ChoicePrompt.Choice>) {
items.filter { !it.disabled && !it.separator }.forEach { choice ->
val children = choice.items
if (children != null) flatten(children) else choices.add(choice)
}
}
flatten(prompt.choices)
var dialog: AlertDialog? = null
fun finish(ids: Array<String>?) {
if (!prompt.isComplete) result.complete(if (ids == null) prompt.dismiss() else prompt.confirm(ids))
prompt.setDelegate(null)
prompts.remove(prompt)
dialog?.dismiss()
}
prompts[prompt] = { finish(null) }
prompt.setDelegate(object : PromptInstanceDelegate {
override fun onPromptDismiss(prompt: BasePrompt) { finish(null) }
override fun onPromptUpdate(prompt: BasePrompt) { finish(null) }
})
val labels = choices.map { it.label }.toTypedArray()
val builder = AlertDialog.Builder(activity).setTitle(prompt.title)
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(null) }
.setOnCancelListener { finish(null) }
if (prompt.type == ChoicePrompt.Type.MULTIPLE) {
val checked = choices.map { it.selected }.toBooleanArray()
builder.setMultiChoiceItems(labels, checked) { _, index, selected -> checked[index] = selected }
.setPositiveButton(android.R.string.ok) { _, _ ->
finish(choices.filterIndexed { index, _ -> checked[index] }.map { it.id }.toTypedArray())
}
} else {
builder.setSingleChoiceItems(labels, choices.indexOfFirst { it.selected }) { _, index ->
finish(arrayOf(choices[index].id))
}
}
dialog = builder.create()
dialog.show()
return result
}
private fun dialog(
prompt: BasePrompt, message: String?, input: EditText? = null,
positive: () -> PromptResponse, negative: (() -> PromptResponse)? = null
): GeckoResult<PromptResponse> {
val result = GeckoResult<PromptResponse>()
var dialog: AlertDialog? = null
fun finish(response: () -> PromptResponse) {
if (!prompt.isComplete) result.complete(response())
prompts.remove(prompt)
prompt.setDelegate(null)
dialog?.dismiss()
}
val cancel = { finish { prompt.dismiss() } }
prompts[prompt] = cancel
prompt.setDelegate(object : PromptInstanceDelegate {
override fun onPromptDismiss(prompt: BasePrompt) { cancel() }
})
val builder = AlertDialog.Builder(activity).setTitle(prompt.title).setMessage(message)
.setPositiveButton(android.R.string.ok) { _, _ -> finish(positive) }
.setOnCancelListener { cancel() }
if (input != null) builder.setView(input)
if (negative != null) builder.setNegativeButton(android.R.string.cancel) { _, _ -> finish(negative) }
dialog = builder.create()
dialog.show()
return result
}
private fun chooseFiles(prompt: FilePrompt): GeckoResult<PromptResponse> {
val result = GeckoResult<PromptResponse>()
val cancellation = UploadCancellation()
val signal = CancellationSignal()
val unregisterSignal = cancellation.onCancel { signal.cancel() }
val selectionSession = uploadSession
var finished = false
fun discard(batch: UploadCache.Batch) {
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(activity.applicationContext).discard(batch) } }
}
fun finish(uris: Array<Uri>?, batch: UploadCache.Batch? = null) {
if (finished) { batch?.let { discard(it) }; return }
finished = true
cancellation.cancel()
var confirmed = false
if (!prompt.isComplete) {
try {
result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris))
confirmed = !uris.isNullOrEmpty()
} catch (error: Exception) {
if (!prompt.isComplete) result.complete(prompt.dismiss())
if (!disposed) Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
}
}
if (!confirmed) batch?.let { discard(it) }
prompts.remove(prompt)
prompt.setDelegate(null)
}
prompts[prompt] = { finish(null) }
prompt.setDelegate(object : PromptInstanceDelegate {
override fun onPromptDismiss(prompt: BasePrompt) { finish(null) }
})
val folder = prompt.type == FilePrompt.Type.FOLDER
val intent = Intent(if (folder) Intent.ACTION_OPEN_DOCUMENT_TREE else Intent.ACTION_OPEN_DOCUMENT)
if (!folder) {
intent.addCategory(Intent.CATEGORY_OPENABLE)
val types = prompt.mimeTypes?.filter { it.contains("/") }?.toTypedArray()
intent.type = types?.singleOrNull() ?: "*/*"
if (!types.isNullOrEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types)
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, prompt.type == FilePrompt.Type.MULTIPLE)
}
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
activity.launchDocument(this, intent) { code, data ->
if (finished || disposed || code != Activity.RESULT_OK || data == null) finish(null)
else {
val uris = mutableListOf<Uri>()
data.clipData?.let { clip -> for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri) }
if (uris.isEmpty()) data.data?.let { uris.add(it) }
val selected = if (prompt.type == FilePrompt.Type.MULTIPLE) uris else uris.take(1)
// Gecko's FOLDER traversal is separate; copying files cannot preserve directory semantics.
if (folder || selected.isEmpty()) finish(selected.toTypedArray())
else {
Toast.makeText(activity, "正在准备上传文件…", Toast.LENGTH_SHORT).show()
val appContext = activity.applicationContext
BrowserUploads.executor.execute {
try {
cancellation.check()
val resolver = appContext.contentResolver
val sources = selected.map { uri ->
cancellation.check()
val name = runCatching {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null, signal)?.use { cursor ->
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null
}
}.getOrNull() ?: uri.lastPathSegment
cancellation.check()
UploadCache.Source(name) {
cancellation.check()
val descriptor = resolver.openAssetFileDescriptor(uri, "r", signal)
?: throw java.io.IOException("无法打开所选文件")
try { descriptor.createInputStream() } catch (error: Exception) {
descriptor.close()
throw error
}
}
}
val batch = BrowserUploads.get(appContext).copy(selectionSession, sources, cancellation)
activity.runOnUiThread {
if (disposed || finished || prompt.isComplete) {
discard(batch)
finish(null)
} else finish(batch.files.map { Uri.fromFile(it) }.toTypedArray(), batch)
}
} catch (error: Exception) {
activity.runOnUiThread {
if (!finished && !disposed) {
Toast.makeText(activity, "无法准备上传文件,请检查可用空间或重新选择:" + error.message, Toast.LENGTH_LONG).show()
}
finish(null)
}
} finally {
unregisterSignal()
}
}
}
}
}
} catch (error: Exception) { finish(null); Toast.makeText(activity, "无法打开文件选择器:" + error.message, Toast.LENGTH_LONG).show() }
return result
}
private fun saveResponse(response: WebResponse) {
if (!loadFailed) emit("onPageFinished")
val body = response.body
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
val encodedName = disposition?.let { Regex("(?i)filename\\*\\s*=\\s*UTF-8''([^;]+)").find(it)?.groupValues?.get(1) }
val headerName = encodedName?.let { Uri.decode(it) } ?: disposition?.let {
Regex("(?i)filename\\s*=\\s*(?:\"([^\"]*)\"|([^;]*))").find(it)?.let { match ->
match.groupValues[1].ifEmpty { match.groupValues[2].trim() }
}
}
val name = (headerName ?: (if (uri.isHierarchical) uri.getQueryParameter("path")?.substringAfterLast('/') else null) ?: uri.lastPathSegment ?: "download")
.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 {
activity.withDownloadNotifications(this) { continueDownload ->
try {
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) }
}
} 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)
}
}
}
}
} 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() } }
internal fun setActive(active: Boolean) { if (!disposed && opened) { session.setActive(active); session.setFocused(active) } }
override fun getView(): View = view
override fun dispose() {
if (disposed) return
disposed = true
channel.setMethodCallHandler(null)
activity.cancelDocuments(this)
prompts.values.toList().forEach { it() }
cancelDownloads()
io.shutdown()
if (opened) {
session.setActive(false)
view.releaseSession()
session.close()
}
closeUploads()
session.progressDelegate = null
session.navigationDelegate = null
session.contentDelegate = null
session.promptDelegate = null
activity.browserViews.remove(this)
}
}
@@ -1,92 +1,119 @@
package ren.misaka.dshapp
import android.app.DownloadManager
import android.net.Uri
import android.content.Intent
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import android.webkit.CookieManager
import android.widget.Toast
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
private const val CHANNEL = "dsh.workbench/downloads"
}
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
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"download" -> {
val url = call.argument<String>("url")
if (url == null) {
result.error("ARG_MISSING", "url is required", null)
return@setMethodCallHandler
}
try {
startDownload(url)
result.success(true)
} catch (e: Exception) {
result.error("DOWNLOAD_FAILED", e.message, null)
}
}
else -> result.notImplemented()
flutterEngine.platformViewsController.registry.registerViewFactory(
"dsh.workbench/gecko", GeckoViewFactory(this, flutterEngine.dartExecutor.binaryMessenger)
)
}
internal fun launchDocument(owner: GeckoPlatformView, intent: Intent, callback: (Int, Intent?) -> Unit) {
val code = nextRequest++
check(code <= 65535) { "Too many document requests" }
requests[code] = owner to callback
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)
}
}
}
/// 把下载交给系统 DownloadManager,并携带面板登录 Cookie
/// (下载接口与站点同源、需要 JWT 会话,不能走无 Cookie 的外部浏览器)。
private fun startDownload(url: String) {
val request = DownloadManager.Request(Uri.parse(url))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
CookieManager.getInstance().getCookie(url)?.takeIf { it.isNotEmpty() }?.let { cookie ->
request.addRequestHeader("Cookie", cookie)
}
val fileName = deriveFileName(url)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// DownloadManager 不接受 MediaStore 的 content:// URI。
// targetSdk >= 29 时,系统下载器写公共 Downloads 不需要存储权限。
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS, fileName
)
} else {
// Android 9-:公共目录写入需运行时权限;退到应用外部目录(免权限)
@Suppress("DEPRECATION")
request.setDestinationInExternalFilesDir(
this, Environment.DIRECTORY_DOWNLOADS, fileName
)
}
val manager = getSystemService(DownloadManager::class.java)
manager.enqueue(request)
val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"系统下载目录"
} else {
"应用下载目录"
}
Toast.makeText(this, "已开始下载到$destination:$fileName", Toast.LENGTH_SHORT).show()
private fun notificationDenied() {
Toast.makeText(applicationContext, "未允许下载通知,下载仍会保存到所选位置", Toast.LENGTH_LONG).show()
}
/// 从下载 URL 推断文件名,优先取 path 参数的 basename(即真实文件名)。
private fun deriveFileName(url: String): String {
val uri = Uri.parse(url)
uri.getQueryParameter("path")?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
?.let { return sanitize(it) }
uri.lastPathSegment?.takeIf { it.isNotBlank() && it != "download" }
?.let { return sanitize(it) }
return "dsh-download-${System.currentTimeMillis()}"
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()
}
}
private fun sanitize(name: String): String =
name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" }
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() {
super.onResume()
browserActive = true
browserViews.forEach { it.setActive(true) }
}
override fun onPause() {
browserActive = false
browserViews.forEach { it.setActive(false) }
super.onPause()
}
override fun onDestroy() {
browserViews.toList().forEach { it.dispose() }
requests.clear()
super.onDestroy()
}
}
@@ -0,0 +1,144 @@
package ren.misaka.dshapp
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.Executors
internal class UploadCancellation {
private val cancelled = AtomicBoolean(false)
private val actions = mutableSetOf<() -> Unit>()
fun cancel() {
val pending = synchronized(actions) {
if (!cancelled.compareAndSet(false, true)) return
actions.toList().also { actions.clear() }
}
pending.forEach { action -> closer.execute { runCatching(action) } }
}
fun onCancel(action: () -> Unit): () -> Unit {
synchronized(actions) {
if (cancelled.get()) closer.execute { runCatching(action) } else actions.add(action)
}
return { synchronized(actions) { actions.remove(action) }; Unit }
}
fun check() { if (cancelled.get()) throw CancellationException("Upload cancelled") }
companion object {
// Provider cancellation and close can themselves block; neither may run on the UI thread.
private val closer = Executors.newCachedThreadPool { task ->
Thread(task, "upload-cancel").apply { isDaemon = true }
}
}
}
// Never hold the cache monitor while calling a provider.
internal class UploadCache(
cacheDir: File,
private val openOutput: (File) -> java.io.OutputStream = { it.outputStream() }
) {
private val root = File(cacheDir.canonicalFile, "gecko-upload-v1")
private val active = mutableMapOf<File, UploadCancellation>()
private val closedSessions = mutableSetOf<String>()
init {
require(root.canonicalFile == root) { "Upload cache must not be a symlink" }
remove(root)
if (!root.mkdirs()) throw IOException("Cannot create upload cache")
}
data class Source(val name: String?, val open: () -> InputStream)
data class Batch(val directory: File, val files: List<File>)
fun copy(session: String, sources: List<Source>, cancellation: UploadCancellation): Batch {
require(UUID.fromString(session).toString() == session)
val directory = File(File(root, session), UUID.randomUUID().toString())
synchronized(this) {
cancellation.check()
if (session in closedSessions) throw CancellationException("Upload session closed")
if (!directory.mkdirs()) throw IOException("Cannot create upload directory")
active[directory] = cancellation
}
var completed = false
try {
val files = sources.mapIndexed { index, source ->
cancellation.check()
val parent = File(directory, index.toString())
if (!parent.mkdir()) throw IOException("Cannot create upload directory")
val target = File(parent, safeName(source.name))
source.open().use { input ->
val unregister = cancellation.onCancel { input.close() }
try {
cancellation.check()
openOutput(target).use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
cancellation.check()
val count = input.read(buffer)
if (count < 0) break
cancellation.check()
output.write(buffer, 0, count)
}
}
} finally { unregister() }
}
target
}
return synchronized(this) {
cancellation.check()
completed = true
Batch(directory, files)
}
} finally {
synchronized(this) {
active.remove(directory)
if (!completed || session in closedSessions) remove(directory)
if (session in closedSessions && active.keys.none { it.parentFile == directory.parentFile }) {
deleteOwned(directory.parentFile)
}
}
}
}
@Synchronized fun discard(batch: Batch) {
require(batch.directory !in active)
deleteOwned(batch.directory)
}
@Synchronized fun closeSession(session: String) {
require(UUID.fromString(session).toString() == session)
closedSessions.add(session)
val directory = File(root, session)
val live = active.filterKeys { it.parentFile == directory }
live.values.forEach { it.cancel() }
if (live.isEmpty()) deleteOwned(directory)
else directory.listFiles()?.filter { it !in live }?.forEach { deleteOwned(it) }
}
private fun deleteOwned(file: File) {
require(file.toPath().normalize().startsWith(root.toPath()) && file != root)
require(root.canonicalFile == root && file.canonicalFile == file)
remove(file)
}
private fun remove(file: File) {
if (!Files.exists(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
Files.walk(file.toPath()).use { paths ->
paths.sorted(Comparator.reverseOrder()).forEach { Files.delete(it) }
}
}
companion object {
fun safeName(name: String?): String {
val cleaned = name.orEmpty().map { if (it == '/' || it.code == 92 || it.isISOControl()) '_' else it }.joinToString("")
if (cleaned.isBlank() || cleaned == "." || cleaned == "..") return "upload"
if (cleaned.toByteArray(Charsets.UTF_8).size <= 240) return cleaned
val extension = cleaned.substringAfterLast('.', "").takeIf { it.length in 1..20 }?.let { ".$it" }.orEmpty()
var stem = cleaned.removeSuffix(extension)
while ((stem + extension).toByteArray(Charsets.UTF_8).size > 240) stem = stem.dropLast(1)
return stem + extension
}
}
}
@@ -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)
}
}
@@ -0,0 +1,240 @@
package ren.misaka.dshapp
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class UploadCacheTest {
@get:Rule val temporary = TemporaryFolder()
private fun session() = UUID.randomUUID().toString()
private fun source(name: String = "报告.txt", value: String = "content") =
UploadCache.Source(name) { value.byteInputStream() }
@Test fun preservesNamesAndSeparatesDuplicatesAndSelections() {
val cache = UploadCache(temporary.root)
val id = session()
val first = cache.copy(id, listOf(source(), source(value = "second")), UploadCancellation())
val second = cache.copy(id, listOf(source()), UploadCancellation())
assertEquals(listOf("报告.txt", "报告.txt"), first.files.map { it.name })
assertEquals(listOf("content", "second"), first.files.map { it.readText() })
assertNotEquals(first.files[0], first.files[1])
cache.discard(second)
assertTrue(first.files.all { it.exists() })
cache.closeSession(id)
assertTrue(first.files.none { it.exists() })
}
@Test fun sanitizesTraversalAndKeepsExtensions() {
assertEquals(".._.._report.pdf", UploadCache.safeName("../../report.pdf"))
assertEquals("a_b.txt", UploadCache.safeName("a" + 92.toChar() + "b.txt"))
assertEquals("a_b.txt", UploadCache.safeName("a" + 0.toChar() + "b.txt"))
listOf(null, "", " ", ".", "..").forEach { assertEquals("upload", UploadCache.safeName(it)) }
val longName = UploadCache.safeName("文".repeat(300) + ".pdf")
assertTrue(longName.endsWith(".pdf"))
assertTrue(longName.toByteArray().size <= 240)
}
@Test fun streamsLargeInputWithBoundedReadsAndClosesIt() {
val cache = UploadCache(temporary.root)
var remaining = 10 * 1024 * 1024
var closed = false
val input = object : InputStream() {
override fun read(): Int = error("Must use buffered reads")
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
assertTrue(length <= 64 * 1024)
if (remaining == 0) return -1
val count = minOf(length, remaining)
bytes.fill(42, offset, offset + count)
remaining -= count
return count
}
override fun close() { closed = true }
}
val batch = cache.copy(session(), listOf(UploadCache.Source("large.bin") { input }), UploadCancellation())
assertEquals(10L * 1024 * 1024, batch.files.single().length())
assertTrue(closed)
}
@Test fun cancellationBeforeCopyDoesNotOpenInput() {
val cache = UploadCache(temporary.root)
val cancel = UploadCancellation().apply { cancel() }
assertThrows(CancellationException::class.java) {
cache.copy(session(), listOf(UploadCache.Source("file") { error("Opened after cancellation") }), cancel)
}
assertFalse(temporary.root.walkTopDown().any { it.isFile })
}
@Test fun cancellationDuringCopyClosesInputAndRemovesWholeBatch() {
val cache = UploadCache(temporary.root)
val cancel = UploadCancellation()
var closed = false
val input = object : InputStream() {
override fun read(): Int { cancel.cancel(); return 1 }
override fun close() { closed = true }
}
assertThrows(CancellationException::class.java) {
cache.copy(session(), listOf(source(), UploadCache.Source("cancel.bin") { input }), cancel)
}
assertTrue(closed)
assertFalse(temporary.root.walkTopDown().any { it.isFile })
}
@Test fun providerFailureRemovesEarlierFilesAndPartialFile() {
val cache = UploadCache(temporary.root)
val input = object : InputStream() {
var reads = 0
override fun read(): Int { if (++reads > 70000) throw IOException("Provider failed"); return 1 }
}
assertThrows(IOException::class.java) {
cache.copy(session(), listOf(source(), UploadCache.Source("failed") { input }), UploadCancellation())
}
assertFalse(temporary.root.walkTopDown().any { it.isFile })
}
@Test fun externalCancellationClosesBlockedReadOffCallerThread() {
val cache = UploadCache(temporary.root)
val cancellation = UploadCancellation()
val reading = CountDownLatch(1)
val closed = CountDownLatch(1)
val caller = Thread.currentThread()
val worker = Executors.newSingleThreadExecutor()
val input = object : InputStream() {
override fun read(): Int {
reading.countDown()
check(closed.await(5, TimeUnit.SECONDS))
throw IOException("Closed")
}
override fun close() {
assertNotEquals(caller, Thread.currentThread())
closed.countDown()
}
}
try {
val copy = worker.submit {
assertThrows(IOException::class.java) {
cache.copy(session(), listOf(UploadCache.Source("blocked") { input }), cancellation)
}
}
assertTrue(reading.await(5, TimeUnit.SECONDS))
cancellation.cancel()
assertTrue(closed.await(5, TimeUnit.SECONDS))
copy.get(5, TimeUnit.SECONDS)
assertFalse(temporary.root.walkTopDown().any { it.isFile })
} finally { closed.countDown(); worker.shutdownNow() }
}
@Test fun uncooperativeOpenDoesNotBlockOtherCopiesOrDeleteLiveBatch() {
val cache = UploadCache(temporary.root)
val id = session()
val opening = CountDownLatch(1)
val release = CountDownLatch(1)
val workers = Executors.newCachedThreadPool()
try {
val blocked = workers.submit {
assertThrows(CancellationException::class.java) {
cache.copy(id, listOf(UploadCache.Source("blocked") {
opening.countDown()
check(release.await(5, TimeUnit.SECONDS))
"late".byteInputStream()
}), UploadCancellation())
}
}
assertTrue(opening.await(5, TimeUnit.SECONDS))
val directory = File(temporary.root, "gecko-upload-v1/$id")
val live = directory.listFiles()!!.single()
workers.submit { cache.closeSession(id) }.get(5, TimeUnit.SECONDS)
assertTrue(live.exists())
val other = workers.submit<UploadCache.Batch> {
cache.copy(session(), listOf(source()), UploadCancellation())
}.get(5, TimeUnit.SECONDS)
assertEquals("content", other.files.single().readText())
assertThrows(CancellationException::class.java) {
cache.copy(id, listOf(source()), UploadCancellation())
}
release.countDown()
blocked.get(5, TimeUnit.SECONDS)
assertFalse(directory.exists())
assertTrue(other.files.single().exists())
cache.discard(other)
cache.discard(other)
} finally { release.countDown(); workers.shutdownNow() }
}
@Test fun blockedCancellationCallbackDoesNotBlockCallerOrOtherCallbacks() {
val cancellation = UploadCancellation()
val release = CountDownLatch(1)
val started = CountDownLatch(1)
val other = CountDownLatch(1)
cancellation.onCancel { started.countDown(); release.await(5, TimeUnit.SECONDS) }
cancellation.onCancel { other.countDown() }
try {
cancellation.cancel()
assertTrue(started.await(5, TimeUnit.SECONDS))
assertTrue(other.await(5, TimeUnit.SECONDS))
val late = CountDownLatch(1)
cancellation.onCancel { late.countDown() }
assertTrue(late.await(5, TimeUnit.SECONDS))
} finally { release.countDown() }
}
@Test fun diskFullClosesStreamsAndDeletesPartialBatch() {
var inputClosed = false
var outputClosed = false
val cache = UploadCache(temporary.root) { file ->
val output = file.outputStream()
object : java.io.OutputStream() {
override fun write(value: Int) { output.write(value); throw IOException("No space left on device") }
override fun close() { outputClosed = true; output.close() }
}
}
val input = object : java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3)) {
override fun close() { inputClosed = true; super.close() }
}
assertThrows(IOException::class.java) {
cache.copy(session(), listOf(UploadCache.Source("full.bin") { input }), UploadCancellation())
}
assertTrue(inputClosed)
assertTrue(outputClosed)
assertFalse(temporary.root.walkTopDown().any { it.isFile })
}
@Test fun restartCleansOnlyOwnedCacheAndSessionCleanupIsIsolated() {
val other = temporary.newFile("unrelated")
val cache = UploadCache(temporary.root)
val firstId = session()
val first = cache.copy(firstId, listOf(source()), UploadCancellation())
val second = cache.copy(session(), listOf(source()), UploadCancellation())
cache.closeSession(firstId)
assertFalse(first.files.single().exists())
assertTrue(second.files.single().exists())
UploadCache(temporary.root)
assertFalse(second.files.single().exists())
assertTrue(other.exists())
assertThrows(IllegalArgumentException::class.java) { cache.closeSession("../unrelated") }
assertThrows(IllegalArgumentException::class.java) { cache.discard(UploadCache.Batch(other, emptyList())) }
}
@Test fun rejectsSymlinkRootAndDoesNotFollowNestedSymlinks() {
val outside = temporary.newFolder("outside")
val retained = File(outside, "retained").apply { writeText("keep") }
val parent = temporary.newFolder("cache")
val root = File(parent, "gecko-upload-v1")
Files.createSymbolicLink(root.toPath(), outside.toPath())
assertThrows(IllegalArgumentException::class.java) { UploadCache(parent) }
Files.delete(root.toPath())
root.mkdir()
Files.createSymbolicLink(File(root, "link").toPath(), outside.toPath())
UploadCache(parent)
assertTrue(retained.exists())
}
}
+1
View File
@@ -2,6 +2,7 @@ allprojects {
repositories {
google()
mavenCentral()
maven { url = uri("https://maven.mozilla.org/maven2/") }
}
}
+2 -1
View File
@@ -69,7 +69,7 @@ if [ "$REBUILD" = true ] || ! docker image inspect "$IMAGE" > /dev/null 2>&1; th
echo "==> 构建镜像 $IMAGE(首次较慢,需下载 Flutter/Android SDK)"
BUILD_ARGS=()
if [ "$REBUILD" = true ]; then BUILD_ARGS+=(--no-cache); fi
docker build --build-arg http_proxy --build-arg https_proxy --build-arg no_proxy "${BUILD_ARGS[@]}" -t "$IMAGE" .
docker build --build-arg http_proxy --build-arg https_proxy --build-arg no_proxy --build-arg JAVA_TOOL_OPTIONS "${BUILD_ARGS[@]}" -t "$IMAGE" .
fi
echo "==> 在容器内构建 release APK"
@@ -80,6 +80,7 @@ docker run --rm \
-e DSH_KEYSTORE=/run/dsh-signing/release.keystore \
-e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \
-e http_proxy -e https_proxy -e no_proxy \
-e JAVA_TOOL_OPTIONS \
-e PUB_CACHE=/opt/pub-cache \
-e DSH_SITE_URL="$DSH_SITE_URL" \
"$IMAGE" \
+49 -164
View File
@@ -1,10 +1,7 @@
import 'dart:async';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'config.dart';
@@ -53,176 +50,53 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> {
/// 原生下载通道(MainActivity 中用系统 DownloadManager 实现)
static const MethodChannel _downloadsChannel = MethodChannel(
'dsh.workbench/downloads',
);
static final Uri _siteUri = Uri.parse(kSiteUrl);
late final WebViewController _controller;
MethodChannel? _channel;
bool _loading = true;
String? _loadError;
@override
void initState() {
super.initState();
_controller = _buildController();
}
WebViewController _buildController() {
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(kThemeColorValue))
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (_) {
if (mounted) {
setState(() {
_loading = true;
_loadError = null;
});
}
},
onPageFinished: (_) {
if (mounted) setState(() => _loading = false);
},
onWebResourceError: (error) {
// 只把主文档加载失败视作页面错误;子资源错误(图片等)
// 以及被新导航打断(ERR_ABORTED)的情况交给页面自行处理
if (!(error.isForMainFrame ?? false)) return;
if (error.description.contains('ERR_ABORTED')) return;
if (mounted) {
setState(() {
_loading = false;
_loadError = error.description;
});
}
},
onNavigationRequest: _onNavigationRequest,
),
)
..loadRequest(Uri.parse(kSiteUrl));
// Android 专属能力:文件选择器(上传)、原生 JS 对话框(alert/confirm)
final platform = controller.platform;
if (platform is AndroidWebViewController) {
platform
..setOnShowFileSelector(_onShowFileSelector)
..setOnJavaScriptAlertDialog(_onJavaScriptAlert)
..setOnJavaScriptConfirmDialog(_onJavaScriptConfirm);
}
return controller;
}
/// 面板用 window.location.assign 触发下载(文件/目录 ZIP/备份包),
/// 这里拦下来交给原生 DownloadManager(携带登录 Cookie),WebView 停留原页。
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final uri = Uri.tryParse(request.url);
if (uri != null && _isPanelDownload(uri)) {
_startDownload(uri);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
bool _isPanelDownload(Uri uri) {
if (uri.host != _siteUri.host) return false;
// 工作区文件/目录 ZIP 下载
if (uri.path.endsWith('/api/ws/download')) return true;
// 配置备份包下载
if (uri.path.endsWith('/backups') &&
uri.queryParameters.containsKey('download')) {
return true;
}
return false;
}
Future<void> _startDownload(Uri uri) async {
try {
await _downloadsChannel.invokeMethod<bool>('download', {
'url': uri.toString(),
});
} on PlatformException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('下载失败:${e.message ?? '未知错误'}')),
);
Future<void> _onViewCreated(int id) async {
final channel = MethodChannel('dsh.workbench/gecko/$id');
_channel = channel;
channel.setMethodCallHandler((call) async {
if (!mounted) return;
switch (call.method) {
case 'onPageStarted':
setState(() {
_loading = true;
_loadError = null;
});
case 'onPageFinished':
setState(() => _loading = false);
case 'onLoadError':
setState(() {
_loading = false;
_loadError = call.arguments as String? ?? '页面加载失败';
});
case 'onDownloadError':
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('下载失败:${call.arguments}')));
}
}
});
await _invoke('load');
}
/// WebView 里的 <input type="file"> 上传(工作区上传/备份导入)。
/// Android 的类型过滤一次只支持单一 MIME 或单一扩展名,
/// 多约束时退化为不限类型,避免选择器异常。
Future<List<String>> _onShowFileSelector(FileSelectorParams params) async {
final typeGroups = <XTypeGroup>[];
final mimes = params.acceptTypes.where((t) => t.contains('/')).toList();
if (mimes.length == 1) {
typeGroups.add(XTypeGroup(mimeTypes: mimes));
} else {
final exts = params.acceptTypes
.where((t) => t.startsWith('.'))
.map((t) => t.toLowerCase())
.toList();
if (exts.length == 1) typeGroups.add(XTypeGroup(extensions: exts));
Future<T?> _invoke<T>(String method) async {
try {
return await _channel?.invokeMethod<T>(method);
} on PlatformException catch (error) {
if (mounted) {
setState(() {
_loading = false;
_loadError = error.message ?? '浏览器启动失败';
});
}
return null;
}
if (params.mode == FileSelectorMode.openMultiple) {
final files = await openFiles(acceptedTypeGroups: typeGroups);
return files.map((f) => f.path).toList();
}
final file = await openFile(acceptedTypeGroups: typeGroups);
return file == null ? const <String>[] : [file.path];
}
/// 面板少量路径仍使用 window.alert / window.confirm,
/// 未注册回调时 WebView 会静默返回 false,必须在这里补上。
Future<void> _onJavaScriptAlert(JavaScriptAlertDialogRequest request) async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
content: Text(request.message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('确定'),
),
],
),
);
}
Future<bool> _onJavaScriptConfirm(
JavaScriptConfirmDialogRequest request,
) async {
if (!mounted) return false;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
content: Text(request.message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('确定'),
),
],
),
);
return confirmed ?? false;
}
Future<void> _onPopInvoked(bool didPop, Object? result) async {
if (didPop) return;
// 先回退 WebView 历史(SPA 内部导航),没有历史再退出应用
if (await _controller.canGoBack()) {
await _controller.goBack();
} else {
if (await _invoke<bool>('goBack') == false && mounted) {
await SystemNavigator.pop();
}
}
@@ -232,7 +106,13 @@ class _HomePageState extends State<HomePage> {
_loadError = null;
_loading = true;
});
unawaited(_controller.reload());
unawaited(_invoke<void>('reload'));
}
@override
void dispose() {
_channel?.setMethodCallHandler(null);
super.dispose();
}
@override
@@ -245,7 +125,12 @@ class _HomePageState extends State<HomePage> {
body: SafeArea(
child: Stack(
children: [
WebViewWidget(controller: _controller),
AndroidView(
viewType: 'dsh.workbench/gecko',
creationParams: const {'url': kSiteUrl},
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: _onViewCreated,
),
if (_loadError != null)
_ErrorView(message: _loadError!, onRetry: _retry)
else if (_loading)
+1 -150
View File
@@ -41,14 +41,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
fake_async:
dependency: transitive
description:
@@ -57,70 +49,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
file_selector:
dependency: "direct main"
description:
name: file_selector
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
url: "https://pub.dev"
source: hosted
version: "1.1.0"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
url: "https://pub.dev"
source: hosted
version: "0.5.2+11"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c"
url: "https://pub.dev"
source: hosted
version: "0.5.3+6"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.dev"
source: hosted
version: "0.9.4+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
url: "https://pub.dev"
source: hosted
version: "0.9.5+1"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_web:
dependency: transitive
description:
name: file_selector_web
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.dev"
source: hosted
version: "0.9.3+6"
flutter:
dependency: "direct main"
description: flutter
@@ -139,27 +67,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
@@ -224,14 +131,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
@@ -285,14 +184,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
@@ -309,46 +200,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
url: "https://pub.dev"
source: hosted
version: "4.14.1"
webview_flutter_android:
dependency: "direct main"
description:
name: webview_flutter_android
sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964"
url: "https://pub.dev"
source: hosted
version: "4.14.1"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: a9cc3f507ee40127ed0812ca54d00701a27833f51ea0b5820f4b3e7f42518bf3
url: "https://pub.dev"
source: hosted
version: "3.27.0"
sdks:
dart: ">=3.13.5 <4.0.0"
flutter: ">=3.47.0"
flutter: ">=3.18.0-18.0.pre.54"
+3 -6
View File
@@ -1,5 +1,5 @@
name: dsh_workbench
description: "DSH 工作台 Android 客户端:系统 WebView 壳。"
description: "DSH 工作台 Android 客户端:内置 GeckoView 内核。"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -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.1+2
version: 1.0.4+5
environment:
sdk: ^3.13.5
@@ -31,10 +31,7 @@ dependencies:
flutter:
sdk: flutter
# DSH 面板 WebView 壳所需
webview_flutter: ^4.14.1
webview_flutter_android: ^4.14.1
file_selector: ^1.1.0
# GeckoView 由 Android 原生平台视图提供,版本固定于 android/app/build.gradle.kts。
dev_dependencies:
flutter_test:
+97
View File
@@ -0,0 +1,97 @@
import 'package:dsh_workbench/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('dsh.workbench/gecko/42');
final calls = <String>[];
var canGoBack = true;
final systemCalls = <String>[];
setUp(() {
calls.clear();
systemCalls.clear();
canGoBack = true;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return call.method == 'goBack' ? canGoBack : null;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
systemCalls.add(call.method);
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
Future<void> emit(String method, [Object? value]) async {
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
const StandardMethodCodec().encodeMethodCall(
MethodCall(method, value),
),
(_) {},
);
}
testWidgets('返回键优先回退 Gecko 历史,之后退出应用', (tester) async {
await tester.pumpWidget(const DshApp());
tester.widget<AndroidView>(find.byType(AndroidView)).onPlatformViewCreated!(
42,
);
await tester.pump();
final pop = tester.widget<PopScope>(
find.byWidgetPredicate((w) => w is PopScope),
);
pop.onPopInvokedWithResult!(false, null);
await tester.pump();
expect(calls.last, 'goBack');
expect(systemCalls, isNot(contains('SystemNavigator.pop')));
canGoBack = false;
pop.onPopInvokedWithResult!(false, null);
await tester.pump();
expect(systemCalls, contains('SystemNavigator.pop'));
await tester.pumpWidget(const SizedBox());
});
testWidgets('Gecko 页面事件、错误及重试通过独立通道连接', (tester) async {
await tester.pumpWidget(const DshApp());
final view = tester.widget<AndroidView>(find.byType(AndroidView));
expect(view.viewType, 'dsh.workbench/gecko');
view.onPlatformViewCreated!(42);
await tester.pump();
expect(calls, ['load']);
expect(find.text('正在加载…'), findsOneWidget);
await emit('onPageFinished');
await tester.pump();
expect(find.text('正在加载…'), findsNothing);
await emit('onLoadError', '连接已断开');
await tester.pump();
expect(find.text('连接已断开'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pump();
expect(calls.last, 'reload');
expect(find.text('正在加载…'), findsOneWidget);
await emit('onPageStarted');
await emit('onPageFinished');
await emit('onDownloadError', '空间不足');
await tester.pump();
expect(find.text('下载失败:空间不足'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await emit('onPageStarted');
expect(tester.takeException(), isNull);
});
}