Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bea0ed9b5 | ||
|
|
836d876783 | ||
|
|
7f493e0f89 | ||
|
|
867989e09b |
+7
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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 内核,不能替代上述真机测试。
|
||||
|
||||
@@ -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,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())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://maven.mozilla.org/maven2/") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user