Compare commits

6 Commits
50 changed files with 2453 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
.git
.gitignore
web/
.mimocode/
build/
dist/
*.apk
*.aab
signing/
**/*.jks
**/*.keystore
**/*.p12
**/*.pfx
**/keystore.properties
**/signing.properties
.env
.env.*
+101
View File
@@ -0,0 +1,101 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history/
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VS Code local state
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
# ---------------------------------------------------------------------------
# 以下为 DSH 工作台项目补充规则
# Gradle caches and build outputs (all modules)
.gradle/
.kotlin/
build/
out/
# Android SDK paths and native build intermediates
local.properties
.cxx/
.externalNativeBuild/
captures/
# Generated Android packages
*.apk
*.aab
*.ap_
*.dex
# Signing keys and local credentials
*.jks
*.keystore
*.p12
*.pfx
keystore.properties
signing.properties
.env
.env.*
!.env.example
!.env.sample
# OS metadata (already listed above, kept for clarity)
Thumbs.db
Desktop.ini
# Local agent/browser tooling
.mimocode/
.playwright-cli/
/output/playwright/
# 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
!gradle/wrapper/gradle-wrapper.properties
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "abaf9c523780a608bd46686fd5e53740a07077f8"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: android
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+62
View File
@@ -0,0 +1,62 @@
# DSH 工作台 Android 构建环境
# 用法:
# docker build -t dsh-appbuilder \
# --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy \
# --build-arg no_proxy=$no_proxy .
# ./build.sh
#
# 产出:build/app/outputs/flutter-apk/app-*.apk
FROM eclipse-temurin:17-jdk-jammy
ARG http_proxy
ARG https_proxy
ARG no_proxy
ENV http_proxy=$http_proxy \
https_proxy=$https_proxy \
no_proxy=$no_proxy \
DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
PUB_CACHE=/opt/pub-cache \
PATH=/opt/flutter/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH
RUN apt-get update \
&& apt-get install -y --no-install-recommends xz-utils unzip ca-certificates curl git \
&& rm -rf /var/lib/apt/lists/*
ARG FLUTTER_VERSION=3.47.7
ARG CMDLINE_TOOLS_VERSION=13114758
# Flutter SDK(固定 stable 版本,来自官方 release 归档)
RUN curl -fsSL -o /tmp/flutter.tar.xz \
"https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" \
&& tar -xJf /tmp/flutter.tar.xz -C /opt \
&& rm /tmp/flutter.tar.xz \
&& git config --global --add safe.directory /opt/flutter
# Android SDK cmdline-tools + 许可 + platform-tools
RUN curl -fsSL -o /tmp/tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" \
&& mkdir -p "${ANDROID_HOME}/cmdline-tools" \
&& unzip -q /tmp/tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
&& 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" "platforms;android-37.0" > /dev/null
RUN flutter config --android-sdk "${ANDROID_HOME}" \
&& flutter --disable-analytics \
&& flutter precache --android \
&& flutter doctor
# 预热默认模板的 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 build apk --release \
&& cd / && rm -rf /tmp/warmup \
&& flutter doctor
WORKDIR /project
+76
View File
@@ -0,0 +1,76 @@
# DSH 工作台(Android)
DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoView 内核,不依赖手机的系统 WebView。全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。
## 特性
- 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
- 返回键:优先回退页面历史,无历史时退出应用
- 加载失败重试页、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
```
脚本做两件事:
1. 若本地没有 `dsh-appbuilder:3.47.7` 镜像,先构建它(Flutter 3.47.7 + Android SDK + 预热 Gradle/NDK 缓存,首次约 20–40 分钟)。
2. 在容器内执行 `flutter build apk --release --split-per-abi --obfuscate`。
产物在 `build/app/outputs/flutter-apk/`:
- `app-arm64-v8a-release.apk`:主流 64 位手机(一般装这个)
- `app-armeabi-v7a-release.apk`:老设备
- `app-x86_64-release.apk`:模拟器/ChromeOS
## 配置
站点地址由 `build.sh` 的第一个参数传入,通过 `--dart-define=DSH_SITE_URL=...` 编译进 APK,无需修改源码,应用启动时也无需输入。必须使用完整 HTTPS URL,可带端口和路径,不能带账号密码、空白、查询参数或片段。脚本在编译前验证地址并运行测试;地址不会写入 Docker 镜像。示例域名仅供说明,请替换为真实站点。
需要强制重建工具链镜像时,在上述命令后加 `--no-cache`。更换地址必须重新打包,已有 APK 不会自动更新。
## Release 签名
`--keystore` 指向包含私钥的 JKS/PKCS12 密钥库(不是网站 TLS 证书,也不是仅含公钥的 .cer);`--alias` 指定私钥条目。相对路径基于运行命令时的目录。脚本需要 Bash,密码默认隐藏输入,私钥密码回车时沿用密钥库密码。
CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD` 环境变量;后者省略时沿用前者。不支持把密码作为命令参数。密码会传入临时容器环境,Docker 管理员可查看,因此仅在可信的 Docker 主机上构建。不要使用 shell 跟踪(`bash -x`)或把密码打印到日志。
密钥库以只读 bind mount 提供给容器,不复制进镜像。推荐把密钥库放在仓库外;常见密钥扩展名及 signing/ 已从 Docker 构建上下文排除。密钥库和密码必须备份,覆盖升级需要保持包名与签名一致。
## 注意事项
- **系统要求**:当前 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 无法被新签名直接覆盖安装。
- **升级迁移**:旧版系统 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 内核,不能替代上述真机测试。
+33
View File
@@ -0,0 +1,33 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+80
View File
@@ -0,0 +1,80 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// 在任务创建前校验,避免缺配置时 AGP 仅报签名任务不存在。
if (gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = true) }) {
for (name in listOf("DSH_KEYSTORE", "DSH_STORE_PASSWORD", "DSH_KEY_ALIAS", "DSH_KEY_PASSWORD")) {
check(!System.getenv(name).isNullOrBlank()) { "Release signing requires $name" }
}
}
android {
namespace = "ren.misaka.dshapp"
compileSdk { version = release(37) { minorApiLevel = 0 } }
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
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 = 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)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
storeFile = System.getenv("DSH_KEYSTORE")?.let { file(it) }
storePassword = System.getenv("DSH_STORE_PASSWORD")
keyAlias = System.getenv("DSH_KEY_ALIAS")
keyPassword = System.getenv("DSH_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
// 直接运行 Gradle/Flutter 也不能绕过 release 签名要求。debug 开发不受影响。
gradle.taskGraph.whenReady {
if (allTasks.any { it.project == project && it.name.contains("Release") }) {
for (name in listOf("DSH_KEYSTORE", "DSH_STORE_PASSWORD", "DSH_KEY_ALIAS", "DSH_KEY_PASSWORD")) {
check(!System.getenv(name).isNullOrBlank()) {
"Release signing requires $name; use build.sh --keystore PATH --alias NAME"
}
}
check(file(System.getenv("DSH_KEYSTORE")).isFile) { "Signing keystore does not exist" }
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
dependencies {
testImplementation("junit:junit:4.13.2")
implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+48
View File
@@ -0,0 +1,48 @@
<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}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -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)
}
}
@@ -0,0 +1,119 @@
package ren.misaka.dshapp
import android.content.Intent
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.widget.Toast
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
internal val browserViews = mutableSetOf<GeckoPlatformView>()
private val requests = mutableMapOf<Int, Pair<GeckoPlatformView, (Int, Intent?) -> Unit>>()
private var nextRequest = 2000
private val documentQueue = ArrayDeque<Pair<Int, Intent>>()
private var activeDocument: Int? = null
private val permissionWaiters = mutableMapOf<GeckoPlatformView, MutableList<(Boolean) -> Unit>>()
private var permissionPending = false
internal var browserActive = false
private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
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)
}
}
}
private fun notificationDenied() {
Toast.makeText(applicationContext, "未允许下载通知,下载仍会保存到所选位置", Toast.LENGTH_LONG).show()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 1900) {
permissionPending = false
if (grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) notificationDenied()
drainDialogs()
}
}
internal fun cancelDocuments(owner: GeckoPlatformView) {
permissionWaiters.remove(owner)?.forEach { it(false) }
requests.entries.filter { it.value.first === owner }.map { it.key }.forEach { code ->
requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (activeDocument == requestCode) activeDocument = null
val request = requests.remove(requestCode)
if (request != null) request.second(resultCode, data)
else super.onActivityResult(requestCode, resultCode, data)
drainDialogs()
}
override fun onResume() {
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,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 启动底色:与站点主题色一致,避免启动白闪(v21 同款) -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/dsh_theme" />
</layer-list>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 前景图形:终端提示符 “>_”,代表 DSH 工作台(工作区/终端面板)。
自适应图标画布 108dp,可见安全区约为中心 66dp,图形保持在其中。 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- “>” 折线箭头 -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M36,38 L52,54 L36,70 L42,70 L58,54 L42,38 Z"/>
<!-- “_” 下划线光标 -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M66,50 h18 v8 h-18 Z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 启动底色:与站点主题色一致,避免启动白闪 -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/dsh_theme" />
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 全面屏:允许窗口延伸到刘海/挖孔区域(含横屏短边),
剩余安全区由 Flutter SafeArea 处理 -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 站点主题色(与 index.html theme-color 一致) -->
<color name="dsh_theme">#FF152B36</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launcher_background">#E3EEF6</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -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())
}
}
+25
View File
@@ -0,0 +1,25 @@
allprojects {
repositories {
google()
mavenCentral()
maven { url = uri("https://maven.mozilla.org/maven2/") }
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
Executable
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# DSH 工作台 APK 构建:Docker 内完成,宿主机无需安装 Flutter/Android SDK
# 用法:./build.sh URL --keystore PATH --alias NAME [--no-cache]
# 产出:build/app/outputs/flutter-apk/app-*.apk
set -euo pipefail
if [ "$#" -lt 1 ]; then
echo '用法:./build.sh https://你的域名 --keystore PATH --alias NAME [--no-cache]' >&2
exit 64
fi
DSH_SITE_URL=$1
shift
case "$DSH_SITE_URL" in
https://?*) ;;
*) echo '必须传入完整的 HTTPS 站点地址' >&2; exit 64 ;;
esac
case "$DSH_SITE_URL" in
*[[:space:]]*) echo '站点地址不能包含空白字符' >&2; exit 64 ;;
esac
REBUILD=false
KEYSTORE=''
DSH_KEY_ALIAS=''
while [ "$#" -gt 0 ]; do
case "$1" in
--keystore|--alias)
if [ "$#" -lt 2 ] || [ -z "$2" ]; then
echo "$1 缺少参数值" >&2; exit 64
fi
if [ "$1" = '--keystore' ]; then KEYSTORE=$2; else DSH_KEY_ALIAS=$2; fi
shift 2 ;;
--no-cache) REBUILD=true; shift ;;
*) echo "未知参数:$1" >&2; exit 64 ;;
esac
done
if [ -z "$KEYSTORE" ] || [ -z "$DSH_KEY_ALIAS" ]; then
echo '必须提供 --keystore PATH 和 --alias NAME;release 不使用 debug 签名' >&2
exit 64
fi
if [ ! -f "$KEYSTORE" ] || [ ! -r "$KEYSTORE" ]; then
echo '密钥库文件不存在或不可读' >&2; exit 64
fi
# 相对路径基于调用者的工作目录,不基于脚本目录。
KEYSTORE="$(cd -- "$(dirname -- "$KEYSTORE")" && pwd)/$(basename -- "$KEYSTORE")"
case "$KEYSTORE" in
*,*) echo '密钥库路径不能含逗号(Docker mount 分隔符)' >&2; exit 64 ;;
esac
if [ -z "${DSH_STORE_PASSWORD:-}" ]; then
if [ ! -t 0 ]; then
echo '非交互构建请通过受保护的环境变量提供 DSH_STORE_PASSWORD' >&2; exit 64
fi
read -r -s -p '密钥库密码:' DSH_STORE_PASSWORD
printf '\n'
fi
if [ -z "$DSH_STORE_PASSWORD" ]; then echo '密码不能为空' >&2; exit 64; fi
if [ -z "${DSH_KEY_PASSWORD:-}" ]; then
if [ -t 0 ]; then
read -r -s -p '私钥密码(回车使用密钥库密码):' DSH_KEY_PASSWORD
printf '\n'
fi
DSH_KEY_PASSWORD=${DSH_KEY_PASSWORD:-$DSH_STORE_PASSWORD}
fi
export DSH_STORE_PASSWORD DSH_KEY_PASSWORD DSH_KEY_ALIAS
cd "$(dirname "$0")"
IMAGE=dsh-appbuilder:3.47.7
if [ "$REBUILD" = true ] || ! docker image inspect "$IMAGE" > /dev/null 2>&1; then
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-arg JAVA_TOOL_OPTIONS "${BUILD_ARGS[@]}" -t "$IMAGE" .
fi
echo "==> 在容器内构建 release APK"
docker run --rm \
-v "$PWD":/project \
-w /project \
--mount "type=bind,source=$KEYSTORE,target=/run/dsh-signing/release.keystore,readonly" \
-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" \
sh -ec 'dart tool/validate_site_url.dart "$DSH_SITE_URL"; flutter pub get; flutter test --dart-define="DSH_SITE_URL=$DSH_SITE_URL"; flutter build apk --dart-define="DSH_SITE_URL=$DSH_SITE_URL" --release --split-per-abi --obfuscate --split-debug-info=build/symbols'
echo "==> 完成,产物:"
ls -lh build/app/outputs/flutter-apk/*.apk
+26
View File
@@ -0,0 +1,26 @@
/// 全局配置。
///
/// [kSiteUrl] 是本应用唯一要加载的站点地址(DSH Web Manager 的 HTTPS 反代域名)。
/// 由 build.sh 通过 --dart-define 编译进 APK,不提供占位地址。
const String kSiteUrl = String.fromEnvironment('DSH_SITE_URL');
bool isValidSiteUrl(String value) {
if (value.isEmpty || RegExp(r'\s').hasMatch(value)) return false;
final uri = Uri.tryParse(value);
if (uri == null) return false;
try {
return uri.scheme == 'https' &&
uri.host.isNotEmpty &&
uri.userInfo.isEmpty &&
!uri.hasQuery &&
!uri.hasFragment &&
uri.port > 0 &&
uri.port <= 65535;
} on FormatException {
return false;
}
}
/// 站点主题色,与 index.html 的 theme-color 保持一致(深蓝),
/// 用于启动背景、状态栏/导航栏衬底与加载/错误页。
const int kThemeColorValue = 0xFF152B36;
+209
View File
@@ -0,0 +1,209 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'config.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 全面屏:内容延伸到状态栏/导航栏之下,刘海与手势区交给 SafeArea 处理
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
runApp(const DshApp());
}
class DshApp extends StatelessWidget {
const DshApp({super.key});
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
// 站点为深色主题:状态栏透明、图标反白
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
),
child: MaterialApp(
title: 'DSH 工作台',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(kThemeColorValue),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(kThemeColorValue),
),
home: const HomePage(),
),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
MethodChannel? _channel;
bool _loading = true;
String? _loadError;
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');
}
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;
}
}
Future<void> _onPopInvoked(bool didPop, Object? result) async {
if (didPop) return;
if (await _invoke<bool>('goBack') == false && mounted) {
await SystemNavigator.pop();
}
}
void _retry() {
setState(() {
_loadError = null;
_loading = true;
});
unawaited(_invoke<void>('reload'));
}
@override
void dispose() {
_channel?.setMethodCallHandler(null);
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: _onPopInvoked,
child: Scaffold(
backgroundColor: const Color(kThemeColorValue),
body: SafeArea(
child: Stack(
children: [
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)
const _LoadingView(),
],
),
),
),
);
}
}
class _LoadingView extends StatelessWidget {
const _LoadingView();
@override
Widget build(BuildContext context) {
return const ColoredBox(
color: Color(kThemeColorValue),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.white70),
SizedBox(height: 16),
Text('正在加载…', style: TextStyle(color: Colors.white70)),
],
),
),
);
}
}
class _ErrorView extends StatelessWidget {
const _ErrorView({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(kThemeColorValue),
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 56, color: Colors.white54),
const SizedBox(height: 16),
const Text(
'无法连接到服务器',
style: TextStyle(color: Colors.white, fontSize: 16),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
),
),
);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+205
View File
@@ -0,0 +1,205 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev"
source: hosted
version: "1.18.3"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
sdks:
dart: ">=3.13.5 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
+87
View File
@@ -0,0 +1,87 @@
name: dsh_workbench
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
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# 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.4+5
environment:
sdk: ^3.13.5
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# GeckoView 由 Android 原生平台视图提供,版本固定于 android/app/build.gradle.kts。
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+32
View File
@@ -0,0 +1,32 @@
import 'package:dsh_workbench/config.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('接受 HTTPS 站点及端口、路径', () {
for (final url in [
'https://panel.example.com',
'https://host:8443/panel/',
]) {
expect(isValidSiteUrl(url), isTrue);
}
});
test('拒绝无效站点地址', () {
for (final url in [
'',
'http://host',
'host',
'https://',
'https://user:pass@host',
'https://host?q=1',
'https://host/#x',
'https://host:99999',
'https://ho st',
'https://host\n',
]) {
expect(isValidSiteUrl(url), isFalse, reason: url);
}
});
test('构建注入的地址有效(普通单测允许未配置)', () {
if (kSiteUrl.isNotEmpty) expect(isValidSiteUrl(kSiteUrl), isTrue);
});
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:dsh_workbench/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('dsh.workbench/gecko/42');
final calls = <String>[];
var canGoBack = true;
final systemCalls = <String>[];
setUp(() {
calls.clear();
systemCalls.clear();
canGoBack = true;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return call.method == 'goBack' ? canGoBack : null;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
systemCalls.add(call.method);
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
Future<void> emit(String method, [Object? value]) async {
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
const StandardMethodCodec().encodeMethodCall(
MethodCall(method, value),
),
(_) {},
);
}
testWidgets('返回键优先回退 Gecko 历史,之后退出应用', (tester) async {
await tester.pumpWidget(const DshApp());
tester.widget<AndroidView>(find.byType(AndroidView)).onPlatformViewCreated!(
42,
);
await tester.pump();
final pop = tester.widget<PopScope>(
find.byWidgetPredicate((w) => w is PopScope),
);
pop.onPopInvokedWithResult!(false, null);
await tester.pump();
expect(calls.last, 'goBack');
expect(systemCalls, isNot(contains('SystemNavigator.pop')));
canGoBack = false;
pop.onPopInvokedWithResult!(false, null);
await tester.pump();
expect(systemCalls, contains('SystemNavigator.pop'));
await tester.pumpWidget(const SizedBox());
});
testWidgets('Gecko 页面事件、错误及重试通过独立通道连接', (tester) async {
await tester.pumpWidget(const DshApp());
final view = tester.widget<AndroidView>(find.byType(AndroidView));
expect(view.viewType, 'dsh.workbench/gecko');
view.onPlatformViewCreated!(42);
await tester.pump();
expect(calls, ['load']);
expect(find.text('正在加载…'), findsOneWidget);
await emit('onPageFinished');
await tester.pump();
expect(find.text('正在加载…'), findsNothing);
await emit('onLoadError', '连接已断开');
await tester.pump();
expect(find.text('连接已断开'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pump();
expect(calls.last, 'reload');
expect(find.text('正在加载…'), findsOneWidget);
await emit('onPageStarted');
await emit('onPageFinished');
await emit('onDownloadError', '空间不足');
await tester.pump();
expect(find.text('下载失败:空间不足'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await emit('onPageStarted');
expect(tester.takeException(), isNull);
});
}
+12
View File
@@ -0,0 +1,12 @@
import 'dart:io';
// 在 flutter pub get 前运行,不能依赖生成的 package_config.json。
// ignore: avoid_relative_lib_imports
import '../lib/config.dart';
void main(List<String> args) {
if (args.length != 1 || !isValidSiteUrl(args.single)) {
stderr.writeln('站点地址必须是完整 HTTPS URL,不能包含账号密码、空白、查询参数或片段。');
exitCode = 64;
}
}