feat(android): bundle GeckoView and bump version to 1.0.2
This commit is contained in:
+3
-4
@@ -41,18 +41,17 @@ 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 插件。
|
||||||
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,16 +1,16 @@
|
|||||||
# 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)
|
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
|
||||||
- 返回键:优先回退页面历史,无历史时退出应用
|
- 返回键:优先回退页面历史,无历史时退出应用
|
||||||
- 加载失败重试页、JS 原生对话框(alert/confirm)桥接
|
- 加载失败重试页、JS 原生对话框桥接
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
@@ -47,7 +47,24 @@ 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 设备上检查:
|
||||||
|
|
||||||
|
- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
|
||||||
|
- 工作区单文件/多文件上传、选择器取消、配置备份导入。
|
||||||
|
- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
|
||||||
|
- 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,7 @@ kotlin {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
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.provider.DocumentsContract
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
@Volatile private var disposed = false
|
||||||
|
private var loaded = false
|
||||||
|
private var opened = false
|
||||||
|
private var canGoBack = false
|
||||||
|
private var loadFailed = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
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)
|
||||||
|
prompts.values.toList().forEach { it() }
|
||||||
|
emit("onLoadError", "网页进程已停止,请重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
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>()
|
||||||
|
fun finish(uris: Array<Uri>?) {
|
||||||
|
if (!prompt.isComplete) {
|
||||||
|
try {
|
||||||
|
result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris))
|
||||||
|
} catch (error: Exception) {
|
||||||
|
if (!prompt.isComplete) result.complete(prompt.dismiss())
|
||||||
|
Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 (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) }
|
||||||
|
finish((if (prompt.type == FilePrompt.Type.MULTIPLE) uris else uris.take(1)).toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
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 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) {
|
||||||
|
closeBody(body)
|
||||||
|
} else {
|
||||||
|
io.execute {
|
||||||
|
try {
|
||||||
|
check(!disposed) { "下载已取消" }
|
||||||
|
activity.contentResolver.openOutputStream(destination, "w").use { output ->
|
||||||
|
checkNotNull(output) { "无法打开目标文件" }
|
||||||
|
body.use { it.copyTo(output, 64 * 1024) }
|
||||||
|
}
|
||||||
|
activity.runOnUiThread { if (!disposed) Toast.makeText(activity, "下载已保存", Toast.LENGTH_SHORT).show() }
|
||||||
|
} catch (error: Exception) {
|
||||||
|
runCatching { DocumentsContract.deleteDocument(activity.contentResolver, destination) }
|
||||||
|
emit("onDownloadError", "保存下载失败:" + error.message)
|
||||||
|
} finally { closeBody(body) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: Exception) { closeBody(body); emit("onDownloadError", "无法保存下载:" + error.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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() }
|
||||||
|
synchronized(streams) { streams.toList() }.forEach { closeBody(it) }
|
||||||
|
io.shutdown()
|
||||||
|
if (opened) {
|
||||||
|
session.setActive(false)
|
||||||
|
view.releaseSession()
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
session.progressDelegate = null
|
||||||
|
session.navigationDelegate = null
|
||||||
|
session.contentDelegate = null
|
||||||
|
session.promptDelegate = null
|
||||||
|
activity.browserViews.remove(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,92 +1,62 @@
|
|||||||
package ren.misaka.dshapp
|
package ren.misaka.dshapp
|
||||||
|
|
||||||
import android.app.DownloadManager
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.webkit.CookieManager
|
|
||||||
import android.widget.Toast
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.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
|
||||||
}
|
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) {
|
|
||||||
result.error("ARG_MISSING", "url is required", null)
|
|
||||||
return@setMethodCallHandler
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
startDownload(url)
|
|
||||||
result.success(true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("DOWNLOAD_FAILED", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把下载交给系统 DownloadManager,并携带面板登录 Cookie
|
internal fun launchDocument(owner: GeckoPlatformView, intent: Intent, callback: (Int, Intent?) -> Unit) {
|
||||||
/// (下载接口与站点同源、需要 JWT 会话,不能走无 Cookie 的外部浏览器)。
|
val code = nextRequest++
|
||||||
private fun startDownload(url: String) {
|
check(code <= 65535) { "Too many document requests" }
|
||||||
val request = DownloadManager.Request(Uri.parse(url))
|
requests[code] = owner to callback
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
try {
|
||||||
.setAllowedOverMetered(true)
|
startActivityForResult(intent, code)
|
||||||
.setAllowedOverRoaming(true)
|
} catch (error: Exception) {
|
||||||
|
requests.remove(code)
|
||||||
CookieManager.getInstance().getCookie(url)?.takeIf { it.isNotEmpty() }?.let { cookie ->
|
throw error
|
||||||
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(即真实文件名)。
|
internal fun cancelDocuments(owner: GeckoPlatformView) {
|
||||||
private fun deriveFileName(url: String): String {
|
requests.entries.filter { it.value.first === owner }.map { it.key }.forEach { code ->
|
||||||
val uri = Uri.parse(url)
|
requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
|
||||||
uri.getQueryParameter("path")?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
|
}
|
||||||
?.let { return sanitize(it) }
|
|
||||||
uri.lastPathSegment?.takeIf { it.isNotBlank() && it != "download" }
|
|
||||||
?.let { return sanitize(it) }
|
|
||||||
return "dsh-download-${System.currentTimeMillis()}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sanitize(name: String): String =
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" }
|
val request = requests.remove(requestCode)
|
||||||
|
if (request != null) request.second(resultCode, data)
|
||||||
|
else super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ allprojects {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url = uri("https://maven.mozilla.org/maven2/") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.2+3
|
||||||
|
|
||||||
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