diff --git a/Dockerfile b/Dockerfile index de761f7..7c2e82f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,18 +41,17 @@ RUN curl -fsSL -o /tmp/tools.zip \ && mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \ && rm /tmp/tools.zip \ && (yes | sdkmanager --licenses > /dev/null || true) \ - && sdkmanager "platform-tools" > /dev/null + && sdkmanager "platform-tools" "platforms;android-37.0" > /dev/null RUN flutter config --android-sdk "${ANDROID_HOME}" \ && flutter --disable-analytics \ && flutter precache --android \ && flutter doctor -# 预热:跑一次与应用相同依赖的默认模板构建,把 Gradle 发行版/AGP/AndroidX -# 组件、插件 AAR 和 SDK 平台下载进镜像,之后挂载源码的正式构建不再重复下载 +# 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本 +# 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。 RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \ && cd /tmp/warmup \ - && flutter pub add webview_flutter webview_flutter_android file_selector \ && flutter build apk --release \ && cd / && rm -rf /tmp/warmup \ && flutter doctor diff --git a/README.md b/README.md index b9f29d5..d1b37b9 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # DSH 工作台(Android) -DSH Web Manager 的 Android 客户端:基于系统 WebView 的轻量壳应用,全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。 +DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoView 内核,不依赖手机的系统 WebView。全部构建在 Docker 内完成,宿主机无需安装 Flutter/Android SDK。 ## 特性 -- 系统 WebView(`webview_flutter`),不打包浏览器内核,体积小 -- 登录状态持久化:系统 WebView 的 Cookie + DOM 存储跨重启保留 +- 内置 GeckoView:Flutter 平台视图连接原生 GeckoSession,各设备使用同一发布版本的内核 +- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制) - 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪 - 文件上传:`` 走系统文件选择器(工作区上传、备份导入) -- 文件下载:拦截面板下载导航,交给系统 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 无法被新签名直接覆盖安装。 -- **Android 9 及以下**:下载保存到应用外部目录(免存储权限),不进公共下载目录。 +- **升级迁移**:旧版系统 WebView 的 Cookie/LocalStorage 不会迁移到 Gecko;首次升级需重新登录,服务器端数据不受影响。 +- **内核更新**:GeckoView 版本固定在 `android/app/build.gradle.kts`,来自 Mozilla 官方 Maven 仓库。内核随 APK 发布,不会自动下载替换;应定期检查稳定版安全更新,更新版本并完成回归后发布新 APK。 +- **开源许可**:GeckoView/Gecko 包含 MPL 2.0 等许可证下的组件。分发 APK 时需保留上游许可声明并履行相应源码提供义务;参见 [Mozilla 许可说明](https://www.mozilla.org/MPL/) 与 [GeckoView 项目](https://mozilla.github.io/geckoview/)。 +- **体积**:APK 包含 Gecko 原生库,体积会明显增加;继续按 ABI 分包,用户只安装匹配设备架构的 APK。 +- **下载生命周期**:保存过程中请保持应用运行;退出应用或进程被系统终止会中断下载,不提供系统 DownloadManager 的后台续传能力。 - **图标**:使用仓库根目录的 `logo.webp`。API 26+ 使用浅冰蓝底色的自适应图标,旧系统使用同一 logo 的传统图标;各密度资源均已生成。 + +## 内核升级回归 + +发布前除 `flutter analyze`、`flutter test` 和 APK 构建外,应在真实 Android 设备上检查: + +- 登录后重启应用,确认站点的持久登录状态及本地存储保留。 +- 工作区单文件/多文件上传、选择器取消、配置备份导入。 +- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。 +- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。 +- 断网重试与无效证书拒绝;确认没有绕过 TLS 校验。 + +Flutter 桥接单测不运行 Gecko 内核,不能替代上述真机测试。 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7b122f9..3cf0722 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,7 +13,7 @@ if (gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = tr android { namespace = "ren.misaka.dshapp" - compileSdk = flutter.compileSdkVersion + compileSdk { version = release(37) { minorApiLevel = 0 } } ndkVersion = flutter.ndkVersion compileOptions { @@ -26,7 +26,7 @@ android { applicationId = "ren.misaka.dshapp" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 26 targetSdk = flutter.targetSdkVersion // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) @@ -73,3 +73,7 @@ kotlin { flutter { source = "../.." } + +dependencies { + implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254") +} diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt new file mode 100644 index 0000000..bad78d5 --- /dev/null +++ b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt @@ -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 Unit>() + private val streams = java.util.Collections.synchronizedSet(mutableSetOf()) + 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? { + 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 { + 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 { + val result = GeckoResult() + val choices = mutableListOf() + fun flatten(items: Array) { + 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?) { + 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 { + val result = GeckoResult() + 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 { + val result = GeckoResult() + fun finish(uris: Array?) { + 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() + 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) + } +} diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt index 2266bdd..5819e07 100644 --- a/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt +++ b/android/app/src/main/kotlin/ren/misaka/dshapp/MainActivity.kt @@ -1,92 +1,62 @@ package ren.misaka.dshapp -import android.app.DownloadManager -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.webkit.CookieManager -import android.widget.Toast +import android.content.Intent import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { - - companion object { - private const val CHANNEL = "dsh.workbench/downloads" - } + internal val browserViews = mutableSetOf() + private val requests = mutableMapOf Unit>>() + private var nextRequest = 2000 + internal var browserActive = false + private set override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) - .setMethodCallHandler { call, result -> - when (call.method) { - "download" -> { - val url = call.argument("url") - if (url == null) { - result.error("ARG_MISSING", "url is required", null) - return@setMethodCallHandler - } - try { - startDownload(url) - result.success(true) - } catch (e: Exception) { - result.error("DOWNLOAD_FAILED", e.message, null) - } - } - else -> result.notImplemented() - } - } + flutterEngine.platformViewsController.registry.registerViewFactory( + "dsh.workbench/gecko", GeckoViewFactory(this, flutterEngine.dartExecutor.binaryMessenger) + ) } - /// 把下载交给系统 DownloadManager,并携带面板登录 Cookie - /// (下载接口与站点同源、需要 JWT 会话,不能走无 Cookie 的外部浏览器)。 - private fun startDownload(url: String) { - val request = DownloadManager.Request(Uri.parse(url)) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setAllowedOverMetered(true) - .setAllowedOverRoaming(true) - - CookieManager.getInstance().getCookie(url)?.takeIf { it.isNotEmpty() }?.let { cookie -> - request.addRequestHeader("Cookie", cookie) + 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 + try { + startActivityForResult(intent, code) + } catch (error: Exception) { + requests.remove(code) + throw error } - - 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(即真实文件名)。 - private fun deriveFileName(url: String): String { - val uri = Uri.parse(url) - uri.getQueryParameter("path")?.substringAfterLast('/')?.takeIf { it.isNotBlank() } - ?.let { return sanitize(it) } - uri.lastPathSegment?.takeIf { it.isNotBlank() && it != "download" } - ?.let { return sanitize(it) } - return "dsh-download-${System.currentTimeMillis()}" + internal fun cancelDocuments(owner: GeckoPlatformView) { + requests.entries.filter { it.value.first === owner }.map { it.key }.forEach { code -> + requests.remove(code)?.second?.invoke(RESULT_CANCELED, null) + } } - private fun sanitize(name: String): String = - name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + 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() + } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..6ff2cc5 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -2,6 +2,7 @@ allprojects { repositories { google() mavenCentral() + maven { url = uri("https://maven.mozilla.org/maven2/") } } } diff --git a/lib/main.dart b/lib/main.dart index 34c47cb..d30e40b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,7 @@ import 'dart:async'; -import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:webview_flutter/webview_flutter.dart'; -import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'config.dart'; @@ -53,176 +50,53 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - /// 原生下载通道(MainActivity 中用系统 DownloadManager 实现) - static const MethodChannel _downloadsChannel = MethodChannel( - 'dsh.workbench/downloads', - ); - - static final Uri _siteUri = Uri.parse(kSiteUrl); - - late final WebViewController _controller; + MethodChannel? _channel; bool _loading = true; String? _loadError; - @override - void initState() { - super.initState(); - _controller = _buildController(); - } - - WebViewController _buildController() { - final controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(const Color(kThemeColorValue)) - ..setNavigationDelegate( - NavigationDelegate( - onPageStarted: (_) { - if (mounted) { - setState(() { - _loading = true; - _loadError = null; - }); - } - }, - onPageFinished: (_) { - if (mounted) setState(() => _loading = false); - }, - onWebResourceError: (error) { - // 只把主文档加载失败视作页面错误;子资源错误(图片等) - // 以及被新导航打断(ERR_ABORTED)的情况交给页面自行处理 - if (!(error.isForMainFrame ?? false)) return; - if (error.description.contains('ERR_ABORTED')) return; - if (mounted) { - setState(() { - _loading = false; - _loadError = error.description; - }); - } - }, - onNavigationRequest: _onNavigationRequest, - ), - ) - ..loadRequest(Uri.parse(kSiteUrl)); - - // Android 专属能力:文件选择器(上传)、原生 JS 对话框(alert/confirm) - final platform = controller.platform; - if (platform is AndroidWebViewController) { - platform - ..setOnShowFileSelector(_onShowFileSelector) - ..setOnJavaScriptAlertDialog(_onJavaScriptAlert) - ..setOnJavaScriptConfirmDialog(_onJavaScriptConfirm); - } - return controller; - } - - /// 面板用 window.location.assign 触发下载(文件/目录 ZIP/备份包), - /// 这里拦下来交给原生 DownloadManager(携带登录 Cookie),WebView 停留原页。 - NavigationDecision _onNavigationRequest(NavigationRequest request) { - final uri = Uri.tryParse(request.url); - if (uri != null && _isPanelDownload(uri)) { - _startDownload(uri); - return NavigationDecision.prevent; - } - return NavigationDecision.navigate; - } - - bool _isPanelDownload(Uri uri) { - if (uri.host != _siteUri.host) return false; - // 工作区文件/目录 ZIP 下载 - if (uri.path.endsWith('/api/ws/download')) return true; - // 配置备份包下载 - if (uri.path.endsWith('/backups') && - uri.queryParameters.containsKey('download')) { - return true; - } - return false; - } - - Future _startDownload(Uri uri) async { - try { - await _downloadsChannel.invokeMethod('download', { - 'url': uri.toString(), - }); - } on PlatformException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('下载失败:${e.message ?? '未知错误'}')), - ); + Future _onViewCreated(int id) async { + final channel = MethodChannel('dsh.workbench/gecko/$id'); + _channel = channel; + channel.setMethodCallHandler((call) async { + if (!mounted) return; + switch (call.method) { + case 'onPageStarted': + setState(() { + _loading = true; + _loadError = null; + }); + case 'onPageFinished': + setState(() => _loading = false); + case 'onLoadError': + setState(() { + _loading = false; + _loadError = call.arguments as String? ?? '页面加载失败'; + }); + case 'onDownloadError': + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('下载失败:${call.arguments}'))); } - } + }); + await _invoke('load'); } - /// WebView 里的 上传(工作区上传/备份导入)。 - /// Android 的类型过滤一次只支持单一 MIME 或单一扩展名, - /// 多约束时退化为不限类型,避免选择器异常。 - Future> _onShowFileSelector(FileSelectorParams params) async { - final typeGroups = []; - final mimes = params.acceptTypes.where((t) => t.contains('/')).toList(); - if (mimes.length == 1) { - typeGroups.add(XTypeGroup(mimeTypes: mimes)); - } else { - final exts = params.acceptTypes - .where((t) => t.startsWith('.')) - .map((t) => t.toLowerCase()) - .toList(); - if (exts.length == 1) typeGroups.add(XTypeGroup(extensions: exts)); + Future _invoke(String method) async { + try { + return await _channel?.invokeMethod(method); + } on PlatformException catch (error) { + if (mounted) { + setState(() { + _loading = false; + _loadError = error.message ?? '浏览器启动失败'; + }); + } + return null; } - - if (params.mode == FileSelectorMode.openMultiple) { - final files = await openFiles(acceptedTypeGroups: typeGroups); - return files.map((f) => f.path).toList(); - } - final file = await openFile(acceptedTypeGroups: typeGroups); - return file == null ? const [] : [file.path]; - } - - /// 面板少量路径仍使用 window.alert / window.confirm, - /// 未注册回调时 WebView 会静默返回 false,必须在这里补上。 - Future _onJavaScriptAlert(JavaScriptAlertDialogRequest request) async { - if (!mounted) return; - await showDialog( - context: context, - builder: (context) => AlertDialog( - content: Text(request.message), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('确定'), - ), - ], - ), - ); - } - - Future _onJavaScriptConfirm( - JavaScriptConfirmDialogRequest request, - ) async { - if (!mounted) return false; - final confirmed = await showDialog( - 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 _onPopInvoked(bool didPop, Object? result) async { if (didPop) return; - // 先回退 WebView 历史(SPA 内部导航),没有历史再退出应用 - if (await _controller.canGoBack()) { - await _controller.goBack(); - } else { + if (await _invoke('goBack') == false && mounted) { await SystemNavigator.pop(); } } @@ -232,7 +106,13 @@ class _HomePageState extends State { _loadError = null; _loading = true; }); - unawaited(_controller.reload()); + unawaited(_invoke('reload')); + } + + @override + void dispose() { + _channel?.setMethodCallHandler(null); + super.dispose(); } @override @@ -245,7 +125,12 @@ class _HomePageState extends State { body: SafeArea( child: Stack( children: [ - WebViewWidget(controller: _controller), + AndroidView( + viewType: 'dsh.workbench/gecko', + creationParams: const {'url': kSiteUrl}, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: _onViewCreated, + ), if (_loadError != null) _ErrorView(message: _loadError!, onRetry: _retry) else if (_loading) diff --git a/pubspec.lock b/pubspec.lock index e21ac76..f84f27b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 - url: "https://pub.dev" - source: hosted - version: "0.3.5+5" fake_async: dependency: transitive description: @@ -57,70 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" - file_selector: - dependency: "direct main" - description: - name: file_selector - sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a - url: "https://pub.dev" - source: hosted - version: "1.1.0" - file_selector_android: - dependency: transitive - description: - name: file_selector_android - sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c - url: "https://pub.dev" - source: hosted - version: "0.5.2+11" - file_selector_ios: - dependency: transitive - description: - name: file_selector_ios - sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" - url: "https://pub.dev" - source: hosted - version: "0.5.3+6" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab - url: "https://pub.dev" - source: hosted - version: "0.9.4+1" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 - url: "https://pub.dev" - source: hosted - version: "0.9.5+1" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - file_selector_web: - dependency: transitive - description: - name: file_selector_web - sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" - url: "https://pub.dev" - source: hosted - version: "0.9.5" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec - url: "https://pub.dev" - source: hosted - version: "0.9.3+6" flutter: dependency: "direct main" description: flutter @@ -139,27 +67,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" leak_tracker: dependency: transitive description: @@ -224,14 +131,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" sky_engine: dependency: transitive description: flutter @@ -285,14 +184,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" vector_math: dependency: transitive description: @@ -309,46 +200,6 @@ packages: url: "https://pub.dev" source: hosted version: "15.3.0" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - webview_flutter: - dependency: "direct main" - description: - name: webview_flutter - sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 - url: "https://pub.dev" - source: hosted - version: "4.14.1" - webview_flutter_android: - dependency: "direct main" - description: - name: webview_flutter_android - sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" - url: "https://pub.dev" - source: hosted - version: "4.14.1" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" - url: "https://pub.dev" - source: hosted - version: "2.15.1" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: a9cc3f507ee40127ed0812ca54d00701a27833f51ea0b5820f4b3e7f42518bf3 - url: "https://pub.dev" - source: hosted - version: "3.27.0" sdks: dart: ">=3.13.5 <4.0.0" - flutter: ">=3.47.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index ee4934b..af108f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dsh_workbench -description: "DSH 工作台 Android 客户端:系统 WebView 壳。" +description: "DSH 工作台 Android 客户端:内置 GeckoView 内核。" # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+2 +version: 1.0.2+3 environment: sdk: ^3.13.5 @@ -31,10 +31,7 @@ dependencies: flutter: sdk: flutter - # DSH 面板 WebView 壳所需 - webview_flutter: ^4.14.1 - webview_flutter_android: ^4.14.1 - file_selector: ^1.1.0 + # GeckoView 由 Android 原生平台视图提供,版本固定于 android/app/build.gradle.kts。 dev_dependencies: flutter_test: diff --git a/test/gecko_bridge_test.dart b/test/gecko_bridge_test.dart new file mode 100644 index 0000000..da5aaed --- /dev/null +++ b/test/gecko_bridge_test.dart @@ -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 = []; + var canGoBack = true; + final systemCalls = []; + + 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 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(find.byType(AndroidView)).onPlatformViewCreated!( + 42, + ); + await tester.pump(); + final pop = tester.widget( + 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(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); + }); +}