Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bea0ed9b5 | ||
|
|
836d876783 | ||
|
|
7f493e0f89 | ||
|
|
867989e09b |
+7
-1
@@ -85,10 +85,16 @@ Desktop.ini
|
|||||||
# Local agent/browser tooling
|
# Local agent/browser tooling
|
||||||
.mimocode/
|
.mimocode/
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
|
/output/playwright/
|
||||||
|
|
||||||
# Dependencies and caches left by the web branch
|
# Dependencies, build outputs and caches left by the web branch
|
||||||
node_modules/
|
node_modules/
|
||||||
.vite/
|
.vite/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Locally compiled manager binary
|
||||||
|
/bin/dsh-mgr
|
||||||
|
|
||||||
# Keep the Gradle Wrapper in version control
|
# Keep the Gradle Wrapper in version control
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
!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" \
|
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
|
||||||
&& rm /tmp/tools.zip \
|
&& rm /tmp/tools.zip \
|
||||||
&& (yes | sdkmanager --licenses > /dev/null || true) \
|
&& (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}" \
|
RUN flutter config --android-sdk "${ANDROID_HOME}" \
|
||||||
&& flutter --disable-analytics \
|
&& flutter --disable-analytics \
|
||||||
&& flutter precache --android \
|
&& flutter precache --android \
|
||||||
&& flutter doctor
|
&& flutter doctor
|
||||||
|
|
||||||
# 预热:跑一次与应用相同依赖的默认模板构建,把 Gradle 发行版/AGP/AndroidX
|
# 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本
|
||||||
# 组件、插件 AAR 和 SDK 平台下载进镜像,之后挂载源码的正式构建不再重复下载
|
# 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。
|
||||||
|
|
||||||
|
# Java/Gradle 不自动读取 http_proxy;仅构建时注入 JVM 代理参数,不固化到镜像 ENV。
|
||||||
|
ARG JAVA_TOOL_OPTIONS
|
||||||
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
|
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
|
||||||
&& cd /tmp/warmup \
|
&& cd /tmp/warmup \
|
||||||
&& flutter pub add webview_flutter webview_flutter_android file_selector \
|
|
||||||
&& flutter build apk --release \
|
&& flutter build apk --release \
|
||||||
&& cd / && rm -rf /tmp/warmup \
|
&& cd / && rm -rf /tmp/warmup \
|
||||||
&& flutter doctor
|
&& flutter doctor
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
# DSH 工作台(Android)
|
# 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`),不打包浏览器内核,体积小
|
- 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核
|
||||||
- 登录状态持久化:系统 WebView 的 Cookie + DOM 存储跨重启保留
|
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
|
||||||
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
|
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
|
||||||
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
|
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
|
||||||
- 文件下载:拦截面板下载导航,交给系统 DownloadManager(携带登录 Cookie)
|
- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。
|
||||||
|
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
|
||||||
|
- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
|
||||||
- 返回键:优先回退页面历史,无历史时退出应用
|
- 返回键:优先回退页面历史,无历史时退出应用
|
||||||
- 加载失败重试页、JS 原生对话框(alert/confirm)桥接
|
- 加载失败重试页、JS 原生对话框桥接
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。
|
前置:本机可用 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
|
```sh
|
||||||
./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release
|
./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 无法被新签名直接覆盖安装。
|
- **签名**: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 的传统图标;各密度资源均已生成。
|
- **图标**:使用仓库根目录的 `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 {
|
android {
|
||||||
namespace = "ren.misaka.dshapp"
|
namespace = "ren.misaka.dshapp"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk { version = release(37) { minorApiLevel = 0 } }
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -26,7 +26,7 @@ android {
|
|||||||
applicationId = "ren.misaka.dshapp"
|
applicationId = "ren.misaka.dshapp"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 26
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
// 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)
|
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||||
@@ -73,3 +73,8 @@ kotlin {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- 面板通过 HTTPS 反代访问,必须声明网络权限(release 模板默认不含) -->
|
<!-- 面板通过 HTTPS 反代访问,必须声明网络权限(release 模板默认不含) -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:label="DSH 工作台"
|
android:label="DSH 工作台"
|
||||||
android:name="${applicationName}"
|
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
|
package ren.misaka.dshapp
|
||||||
|
|
||||||
import android.app.DownloadManager
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
|
||||||
import android.webkit.CookieManager
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
internal val browserViews = mutableSetOf<GeckoPlatformView>()
|
||||||
companion object {
|
private val requests = mutableMapOf<Int, Pair<GeckoPlatformView, (Int, Intent?) -> Unit>>()
|
||||||
private const val CHANNEL = "dsh.workbench/downloads"
|
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) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
flutterEngine.platformViewsController.registry.registerViewFactory(
|
||||||
.setMethodCallHandler { call, result ->
|
"dsh.workbench/gecko", GeckoViewFactory(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||||
when (call.method) {
|
)
|
||||||
"download" -> {
|
}
|
||||||
val url = call.argument<String>("url")
|
|
||||||
if (url == null) {
|
internal fun launchDocument(owner: GeckoPlatformView, intent: Intent, callback: (Int, Intent?) -> Unit) {
|
||||||
result.error("ARG_MISSING", "url is required", null)
|
val code = nextRequest++
|
||||||
return@setMethodCallHandler
|
check(code <= 65535) { "Too many document requests" }
|
||||||
}
|
requests[code] = owner to callback
|
||||||
try {
|
documentQueue.addLast(code to intent)
|
||||||
startDownload(url)
|
drainDialogs()
|
||||||
result.success(true)
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("DOWNLOAD_FAILED", e.message, null)
|
internal fun withDownloadNotifications(owner: GeckoPlatformView, callback: (Boolean) -> Unit) {
|
||||||
}
|
permissionWaiters.getOrPut(owner) { mutableListOf() }.add(callback)
|
||||||
}
|
drainDialogs()
|
||||||
else -> result.notImplemented()
|
}
|
||||||
|
|
||||||
|
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
|
private fun notificationDenied() {
|
||||||
/// (下载接口与站点同源、需要 JWT 会话,不能走无 Cookie 的外部浏览器)。
|
Toast.makeText(applicationContext, "未允许下载通知,下载仍会保存到所选位置", Toast.LENGTH_LONG).show()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从下载 URL 推断文件名,优先取 path 参数的 basename(即真实文件名)。
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||||
private fun deriveFileName(url: String): String {
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
val uri = Uri.parse(url)
|
if (requestCode == 1900) {
|
||||||
uri.getQueryParameter("path")?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
|
permissionPending = false
|
||||||
?.let { return sanitize(it) }
|
if (grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) notificationDenied()
|
||||||
uri.lastPathSegment?.takeIf { it.isNotBlank() && it != "download" }
|
drainDialogs()
|
||||||
?.let { return sanitize(it) }
|
}
|
||||||
return "dsh-download-${System.currentTimeMillis()}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sanitize(name: String): String =
|
internal fun cancelDocuments(owner: GeckoPlatformView) {
|
||||||
name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" }
|
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 {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
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)"
|
echo "==> 构建镜像 $IMAGE(首次较慢,需下载 Flutter/Android SDK)"
|
||||||
BUILD_ARGS=()
|
BUILD_ARGS=()
|
||||||
if [ "$REBUILD" = true ]; then BUILD_ARGS+=(--no-cache); fi
|
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
|
fi
|
||||||
|
|
||||||
echo "==> 在容器内构建 release APK"
|
echo "==> 在容器内构建 release APK"
|
||||||
@@ -80,6 +80,7 @@ docker run --rm \
|
|||||||
-e DSH_KEYSTORE=/run/dsh-signing/release.keystore \
|
-e DSH_KEYSTORE=/run/dsh-signing/release.keystore \
|
||||||
-e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \
|
-e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \
|
||||||
-e http_proxy -e https_proxy -e no_proxy \
|
-e http_proxy -e https_proxy -e no_proxy \
|
||||||
|
-e JAVA_TOOL_OPTIONS \
|
||||||
-e PUB_CACHE=/opt/pub-cache \
|
-e PUB_CACHE=/opt/pub-cache \
|
||||||
-e DSH_SITE_URL="$DSH_SITE_URL" \
|
-e DSH_SITE_URL="$DSH_SITE_URL" \
|
||||||
"$IMAGE" \
|
"$IMAGE" \
|
||||||
|
|||||||
+49
-164
@@ -1,10 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:file_selector/file_selector.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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';
|
import 'config.dart';
|
||||||
|
|
||||||
@@ -53,176 +50,53 @@ class HomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
/// 原生下载通道(MainActivity 中用系统 DownloadManager 实现)
|
MethodChannel? _channel;
|
||||||
static const MethodChannel _downloadsChannel = MethodChannel(
|
|
||||||
'dsh.workbench/downloads',
|
|
||||||
);
|
|
||||||
|
|
||||||
static final Uri _siteUri = Uri.parse(kSiteUrl);
|
|
||||||
|
|
||||||
late final WebViewController _controller;
|
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _loadError;
|
String? _loadError;
|
||||||
|
|
||||||
@override
|
Future<void> _onViewCreated(int id) async {
|
||||||
void initState() {
|
final channel = MethodChannel('dsh.workbench/gecko/$id');
|
||||||
super.initState();
|
_channel = channel;
|
||||||
_controller = _buildController();
|
channel.setMethodCallHandler((call) async {
|
||||||
}
|
if (!mounted) return;
|
||||||
|
switch (call.method) {
|
||||||
WebViewController _buildController() {
|
case 'onPageStarted':
|
||||||
final controller = WebViewController()
|
setState(() {
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
_loading = true;
|
||||||
..setBackgroundColor(const Color(kThemeColorValue))
|
_loadError = null;
|
||||||
..setNavigationDelegate(
|
});
|
||||||
NavigationDelegate(
|
case 'onPageFinished':
|
||||||
onPageStarted: (_) {
|
setState(() => _loading = false);
|
||||||
if (mounted) {
|
case 'onLoadError':
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = false;
|
||||||
_loadError = null;
|
_loadError = call.arguments as String? ?? '页面加载失败';
|
||||||
});
|
});
|
||||||
}
|
case 'onDownloadError':
|
||||||
},
|
ScaffoldMessenger.of(context)
|
||||||
onPageFinished: (_) {
|
.showSnackBar(SnackBar(content: Text('下载失败:${call.arguments}')));
|
||||||
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 ?? '未知错误'}')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
await _invoke('load');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebView 里的 <input type="file"> 上传(工作区上传/备份导入)。
|
Future<T?> _invoke<T>(String method) async {
|
||||||
/// Android 的类型过滤一次只支持单一 MIME 或单一扩展名,
|
try {
|
||||||
/// 多约束时退化为不限类型,避免选择器异常。
|
return await _channel?.invokeMethod<T>(method);
|
||||||
Future<List<String>> _onShowFileSelector(FileSelectorParams params) async {
|
} on PlatformException catch (error) {
|
||||||
final typeGroups = <XTypeGroup>[];
|
if (mounted) {
|
||||||
final mimes = params.acceptTypes.where((t) => t.contains('/')).toList();
|
setState(() {
|
||||||
if (mimes.length == 1) {
|
_loading = false;
|
||||||
typeGroups.add(XTypeGroup(mimeTypes: mimes));
|
_loadError = error.message ?? '浏览器启动失败';
|
||||||
} else {
|
});
|
||||||
final exts = params.acceptTypes
|
}
|
||||||
.where((t) => t.startsWith('.'))
|
return null;
|
||||||
.map((t) => t.toLowerCase())
|
|
||||||
.toList();
|
|
||||||
if (exts.length == 1) typeGroups.add(XTypeGroup(extensions: exts));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
Future<void> _onPopInvoked(bool didPop, Object? result) async {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
// 先回退 WebView 历史(SPA 内部导航),没有历史再退出应用
|
if (await _invoke<bool>('goBack') == false && mounted) {
|
||||||
if (await _controller.canGoBack()) {
|
|
||||||
await _controller.goBack();
|
|
||||||
} else {
|
|
||||||
await SystemNavigator.pop();
|
await SystemNavigator.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +106,13 @@ class _HomePageState extends State<HomePage> {
|
|||||||
_loadError = null;
|
_loadError = null;
|
||||||
_loading = true;
|
_loading = true;
|
||||||
});
|
});
|
||||||
unawaited(_controller.reload());
|
unawaited(_invoke<void>('reload'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_channel?.setMethodCallHandler(null);
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -245,7 +125,12 @@ class _HomePageState extends State<HomePage> {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
WebViewWidget(controller: _controller),
|
AndroidView(
|
||||||
|
viewType: 'dsh.workbench/gecko',
|
||||||
|
creationParams: const {'url': kSiteUrl},
|
||||||
|
creationParamsCodec: const StandardMessageCodec(),
|
||||||
|
onPlatformViewCreated: _onViewCreated,
|
||||||
|
),
|
||||||
if (_loadError != null)
|
if (_loadError != null)
|
||||||
_ErrorView(message: _loadError!, onRetry: _retry)
|
_ErrorView(message: _loadError!, onRetry: _retry)
|
||||||
else if (_loading)
|
else if (_loading)
|
||||||
|
|||||||
+1
-150
@@ -41,14 +41,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -57,70 +49,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -139,27 +67,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -224,14 +131,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -285,14 +184,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -309,46 +200,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.3.0"
|
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:
|
sdks:
|
||||||
dart: ">=3.13.5 <4.0.0"
|
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
|
name: dsh_workbench
|
||||||
description: "DSH 工作台 Android 客户端:系统 WebView 壳。"
|
description: "DSH 工作台 Android 客户端:内置 GeckoView 内核。"
|
||||||
# The following line prevents the package from being accidentally published to
|
# The following line prevents the package from being accidentally published to
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# 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
|
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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.13.5
|
sdk: ^3.13.5
|
||||||
@@ -31,10 +31,7 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# DSH 面板 WebView 壳所需
|
# GeckoView 由 Android 原生平台视图提供,版本固定于 android/app/build.gradle.kts。
|
||||||
webview_flutter: ^4.14.1
|
|
||||||
webview_flutter_android: ^4.14.1
|
|
||||||
file_selector: ^1.1.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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