From 79d268fd5ed7a0a395ee3507ba35929811e7bcbb Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 15 Oct 2026 16:39:27 +0800 Subject: [PATCH] fix: recover authenticated undergraduate full pages with session guards --- README.md | 4 +- docs/android-mvp-validation.md | 2 +- docs/school-integration-status.md | 4 +- ...ebview-missing-head-analysis-2026-10-15.md | 37 +++ docs/tpschedule-0.7.3-review.md | 17 + docs/tpschedule-0.7.3-validation.md | 33 ++ mobile/README.md | 4 +- mobile/android/app/build.gradle.kts | 2 + .../SchoolDocumentRenderInstrumentation.java | 97 ++++++ .../tpschedule/app/SchoolDocumentPending.java | 56 ++++ .../tpschedule/app/SchoolDocumentReader.java | 162 ++++++++++ .../app/SchoolDocumentRenderer.java | 39 +++ .../kotlin/com/tpschedule/app/MainActivity.kt | 9 + .../tpschedule/app/SchoolDocumentBridge.kt | 165 ++++++++++ .../app/SchoolDocumentPendingTest.java | 53 ++++ .../app/SchoolDocumentReaderTest.java | 111 +++++++ mobile/lib/app/app_version.dart | 4 +- .../school/school_full_page_recovery.dart | 295 ++++++++++++++++++ .../school/undergraduate/session.dart | 51 ++- mobile/pubspec.yaml | 2 +- .../school/full_page_recovery_js_test.cjs | 25 ++ .../school_full_page_recovery_test.dart | 259 +++++++++++++++ 22 files changed, 1418 insertions(+), 13 deletions(-) create mode 100644 docs/school-webview-missing-head-analysis-2026-10-15.md create mode 100644 docs/tpschedule-0.7.3-review.md create mode 100644 docs/tpschedule-0.7.3-validation.md create mode 100644 mobile/android/app/src/androidTest/java/com/tpschedule/app/SchoolDocumentRenderInstrumentation.java create mode 100644 mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentPending.java create mode 100644 mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentReader.java create mode 100644 mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentRenderer.java create mode 100644 mobile/android/app/src/main/kotlin/com/tpschedule/app/SchoolDocumentBridge.kt create mode 100644 mobile/android/app/src/test/java/com/tpschedule/app/SchoolDocumentPendingTest.java create mode 100644 mobile/android/app/src/test/java/com/tpschedule/app/SchoolDocumentReaderTest.java create mode 100644 mobile/lib/features/school/school_full_page_recovery.dart create mode 100644 mobile/test/school/full_page_recovery_js_test.cjs create mode 100644 mobile/test/school/school_full_page_recovery_test.dart diff --git a/README.md b/README.md index 1426ef7..ad4a857 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # TPSchedule -当前Android版本0.7.2+20:细化教务页面脚本异常、基础库和样式表状态日志,导出头部显示当前版本及时间。用户0.7.1日志显示认证已成功、随后脚本报错;具体根因仍需新日志确认,本版不能认定已解决组件缺失。详见[0.7.2验证记录](docs/tpschedule-0.7.2-validation.md)。 +当前Android版本0.7.3+21:本科登录后缺少公共脚本和样式时,沿用同一学校会话尝试恢复完整页面。包含实际恢复逻辑,仍需校园网内真实账号复测。详见[0.7.3验证记录](docs/tpschedule-0.7.3-validation.md)。 0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。 -学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.2+20,Android 7.0 及以上。 +学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.3+21,Android 7.0 及以上。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 diff --git a/docs/android-mvp-validation.md b/docs/android-mvp-validation.md index a46be63..2b8d1cb 100644 --- a/docs/android-mvp-validation.md +++ b/docs/android-mvp-validation.md @@ -1,6 +1,6 @@ # Android 首版验证记录 -当前浏览器诊断细化版为0.7.2+20,验证见 [0.7.2记录](tpschedule-0.7.2-validation.md)。本科接入记录见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 +当前完整页面恢复版为0.7.3+21,验证见 [0.7.3记录](tpschedule-0.7.3-validation.md)。本科接入记录见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。 diff --git a/docs/school-integration-status.md b/docs/school-integration-status.md index e88141e..4fe2827 100644 --- a/docs/school-integration-status.md +++ b/docs/school-integration-status.md @@ -1,6 +1,6 @@ -# 学校接入状态 · TPSchedule 0.7.2 +# 学校接入状态 · TPSchedule 0.7.3 -0.7.1真机新日志确认本科认证成功,进入教务页面后有重复脚本错误。0.7.2细化现有日志,记录固定错误类型、已知基础库与样式状态及导出时间;尚未确定实际组件缺失的根因。详见 [0.7.2记录](tpschedule-0.7.2-validation.md)。 +0.7.2真机日志确认本科认证后缺少公共脚本和样式,框架片段输出规则与WebView请求头行为是高概率原因。0.7.3增加有界的同会话完整页恢复;已通过离线及Android原生验证,仍待校园网真实账号复测。详见 [0.7.3记录](tpschedule-0.7.3-validation.md)。 0.7.1补充学校浏览器宽视口、第三方Cookie和资源错误诊断,修复本科认证跳转导致读取永久中断的竞态。255项单元测试及Android16原生浏览器测试通过。用户截图中的样式与组件缺失尚需真机复测,不能仅凭测试认定已解决,详见 [0.7.1验证记录](tpschedule-0.7.1-validation.md)。 diff --git a/docs/school-webview-missing-head-analysis-2026-10-15.md b/docs/school-webview-missing-head-analysis-2026-10-15.md new file mode 100644 index 0000000..a1d0f88 --- /dev/null +++ b/docs/school-webview-missing-head-analysis-2026-10-15.md @@ -0,0 +1,37 @@ +# 教务页面缺少公共头部:证据与修复设计 + +2026-10-15,TPSchedule 0.7.2+20。本文不包含学生原始HTML、请求凭证或原始日志。 + +## 已确认的证据 + +用户更正后的日志导出于北京时间15:41:07。15:40:47后,学校认证返回本科系统,读取证据authenticated=true;控制台先后记录reference_error和jquery_missing。原生页面状态为脚本标签2、样式表0、样式链接0,jQuery、Beangle和Require均不存在。这是登录后基础依赖未进入页面的证据,不能用调整课表解析器解决。 + +15:31–15:32的空白尝试属于用户后来确认未连接校园网时的记录,应与联网后组件缺失区分。身份认证图片404和此前主文档连接错误不能直接当作15:40教务页面的根因。 + +此前用户提供的Chrome导出本科HTML,其head包含多组jQuery、Beangle、Require及CSS依赖。仓库未复制原始附件。 + +## 官方源码交叉核对 + +1. [Beangle旧Java框架的head.ftl](https://github.com/beangle/beangle3/blob/main/struts/s2/src/main/resources/template/html/head.ftl)以请求头和同名参数是否存在,决定是否输出公共head。判断并非仅比较XMLHttpRequest值;把头改为空字符串并不能据此保证输出head。该规则可以产生正文和少数内联脚本仍在、公共脚本和样式缺失的现象。 +2. [Chromium的Android WebView网络实现](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/android_webview/browser/network_service/aw_proxying_url_loader_factory.cc)会在请求尚未显式设置X-Requested-With时添加宿主包名,且注明该自动值对shouldInterceptRequest不可见。因此仅看拦截回调中的请求头也不足以证明线上没有该头。 +3. [AndroidX WebSettingsCompat文档](https://developer.android.com/reference/androidx/webkit/WebSettingsCompat)说明setRequestedWithHeaderOriginAllowList自1.15.0起失效;当前固定WebView插件依赖的正是AndroidX WebKit1.15.0。不能把该接口当作有效修复,也不应为此偷偷降级插件依赖。 + +三者与日志高度吻合,但尚未直接捕获用户手机的线上请求头,也没有获得受影响在线页面的原始响应;该原因仍应标为高概率推断。用户暂不方便USB联调,后续继续文件与可验证的兼容处理。 + +## 有界修复设计(用户已确认,2026-10-15) + +目标保持原学校网页登录、验证码、手动选择本科课表及APP读取流程。仅针对已认证而缺少公共依赖的学校页面增加完整页恢复,沿用系统WebView。 + +- 学生仍在学校网页输入账号密码和验证码,应用不接管输入。 +- 对受限制的学校HTTPS只读页面,用同一登录会话请求原地址的完整HTML;该请求不携带触发片段输出的X-Requested-With标记。Cookie只用于原学校域名,不进入日志、反馈或其他服务器。 +- 读取到完整页面并验证来源、状态、类型及大小后,再交给现有WebView显示。异常、跳转到认证页、页面已变更、退出、取消或超限时不替换当前页面,不导入或清空旧课表。 +- 不接管验证码、选课、提交表单等POST操作;不放宽HTTPS证书校验,不修改学校安全策略;完整页恢复次数有限,避免循环刷新。 +- 先以框架同类片段/完整页规则、会话隔离、取消和恶意跳转做回归,再做Android原生验证。真实学校账号复测仍是最终确认,测试通过不能代替该确认。 + +用户已明确确认同会话完整页读取和现有WebView显示的方案。0.7.3先接入有明确日志证据的本科系统,研究生流程保持原有实现。 + +## 实现边界 + +Android原生读取只允许准确学校域名下有限的只读路由、HTTPS默认端口及GET;拒绝凭证或业务操作查询字段、跳转、非HTML、超限响应。连接使用默认TLS校验,读取超时和解压前后均有上限。Cookie由原生CookieManager取出,仅发往原学校;返回Cookie限制为同域和系统路径。不写磁盘或日志。 + +完整HTML经Flutter确认公共脚本、CSS及已登录结构后,原生使用20秒有效的单次令牌恢复页面。页面URL、WebView实例、当前登录Cookie和Flutter文档代次均需匹配;恢复地址和来源保留原学校HTTPS。一个文档最多尝试一次、一次打开最多四次;暂存全局最多四项,后台读取两个工作线程和两个排队任务。失败保留当前页面,导入仍沿用原有确认与保存流程。 diff --git a/docs/tpschedule-0.7.3-review.md b/docs/tpschedule-0.7.3-review.md new file mode 100644 index 0000000..b915426 --- /dev/null +++ b/docs/tpschedule-0.7.3-review.md @@ -0,0 +1,17 @@ +# TPSchedule 0.7.3 独立审查 + +2026-10-15,审查范围为本科完整页恢复、Android会话读取及临时令牌,不含原始学生附件或凭证。审查者仅获得需求、代码位置和验证范围,无本会话历史;未修改源码或运行重复构建。 + +## 发现与处理 + +P2:最初原生最终渲染仅比较WebView实例、URL及Cookie。相同URL、相同Cookie的新文档可能在Flutter最后一次检查和原生渲染之间替换旧文档,旧结果仍可通过检查;取消也未主动失效原生令牌。 + +已增加私有、不可枚举的每文档随机标记,随完整页读取传递;最终渲染先在真实WebView核对标记与URL,回调再检查临时令牌、视图实例、URL、Cookie及桥接生命周期,然后消费令牌并加载。页面开始、关闭、重置和销毁主动失效原生候选;异步渲染增加超时。回归覆盖原生校验等待中取消、相同URL新文档旧标记拒绝。实际脚本测试确认标记在同文档内稳定、跨文档变化,跳转和密码框跳过且不读取私密数据。 + +复审结论:在公开WebView能力与原生接受渲染作为提交点的边界内,该问题已处理,无其他阻塞项。跨进程页面导航与JS验证回调不具备形式上的绝对原子性;本实现不替换插件浏览器客户端,也不使用可能残留脚本全局状态的document.write替换方式。 + +## 验证与限制 + +原生16项JVM回归及API36实际渲染检查通过,实际URL、来源、中文、相对路径脚本/CSS、会话和文档保护均验证。Flutter完整套件265项通过,随后新增的取消渲染回归包含于8项恢复测试并通过,静态检查无问题。新增4个Node脚本中的恢复脚本及此前3个脚本均通过。最终交付详见tpschedule-0.7.3-validation.md。 + +学校真实账户、校园网络与用户手机Chromium156的联调仍由用户复测。离线和模拟器结果不代表线上组件已确认恢复。 diff --git a/docs/tpschedule-0.7.3-validation.md b/docs/tpschedule-0.7.3-validation.md new file mode 100644 index 0000000..0a31240 --- /dev/null +++ b/docs/tpschedule-0.7.3-validation.md @@ -0,0 +1,33 @@ +# TPSchedule 0.7.3+21:本科教务完整页面恢复 + +2026-10-15,工程F:/program/app。按用户已确认的同会话完整页恢复设计实现;本版包含实际恢复逻辑。 + +## 证据和范围 + +更正后的0.7.2日志显示认证成功但jQuery不存在,页面只有两个脚本、没有样式或样式链接。Beangle旧框架会依据X-Requested-With头是否存在省略公共head,而Android WebView有自动添加该头的实现。源码与日志吻合,但尚未捕获该手机的实际线上请求,不能将推断写成已确认的网络事实。分析和官方源码链接见[证据与设计](school-webview-missing-head-analysis-2026-10-15.md)。 + +本版先对本科系统启用恢复,研究生流程不变。无需更换浏览器内核,学校账号和验证码继续由学生在学校网页填写。本科仍需自行打开课表和选择学期,再点击导入。 + +## 实现 + +识别已认证而公共依赖缺失的页面后,原生沿用Cookie与浏览器标识GET原学校HTTPS只读地址,不携带X-Requested-With。禁止自动跳转及表单提交,检查状态、类型、大小、超时和Cookie作用域。完整HTML通过结构校验后,在相同WebView恢复,并保留学校原URL和来源。20秒临时令牌、文档代次及同会话检查避免迟到结果覆盖新页面;失败、关闭或切换时保留旧页面和旧课表。重试和并发有上限。 + +新增school_full_page_restore固定状态日志,记录requested/applied/rejected/failed/cancelled及固定错误类别。原始HTML、正文、URL查询、Cookie和密码均不写日志;反馈协议和服务端部署不变。 + +## 验证记录 + +核心恢复及Android读取先观察到回归失败,再实现并通过。Flutter覆盖完整页恢复、片段/登录表单/外部base/重定向拒绝、切页取消、重复恢复保护,以及原生渲染开始回调存在或缺失的两种会话路径。 + +Android JVM检查覆盖省略头部的模拟框架、请求头与会话、GET和禁止跳转、路由限制、MIME、压缩大小、Cookie作用域及临时令牌生命周期。API36模拟器的实际WebView渲染验证了原URL与origin、中文、相对路径外部脚本与CSS,以及地址/会话变化时不替换DOM。模拟器Chromium133.0.6943.137,与用户156.0.8078.4不同。 + +完整Flutter套件265项通过,随后新增的渲染等待中取消回归在8项恢复测试中通过;静态检查无问题。4个Node脚本检查通过,原生JVM16项通过,Android原生渲染检查和Flutter原生浏览器测试各1项通过。独立审查提出的相同URL文档竞态已补上私有文档标记、原生失效和异步后复核,复审无阻塞项,见[审查记录](tpschedule-0.7.3-review.md)。上述测试不能代替校园网内真实学校账号复测。 + +## 用户复测 + +覆盖安装0.7.3,连接校园网后选择电子科技大学(本科生)并登录。缺少依赖时应出现“正在恢复完整教务页面”,随后确认学校菜单与组件能显示;打开课表选择学期后导入,再检查考试查询。若仍异常,立即导出本次新日志,检查恢复状态和固定失败类别。 + +## 安装包核对 + +发布包构建成功,com.tpschedule.app,versionName=0.7.3、versionCode=21,minSdk24、targetSdk36。TPSchedule-0.7.3.apk与TPSchedule.apk字节相同,60155727字节,SHA256为E78FD460F006EA879BC9BBCF24BCADB0377686E0A4DDDE2A00EBB218417F7E0E。 + +与0.7.2证书逐字节一致,证书SHA256为AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。源码包由最终提交生成,不含原始学生附件、私钥或运行缓存。登记冻结及旧版安装包在交付打包时重新校验。 diff --git a/mobile/README.md b/mobile/README.md index 443ab84..29c4e7f 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,8 +1,8 @@ # TPSchedule Flutter工程 -当前版本0.7.2+20,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。 +当前版本0.7.3+21,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。 -学校浏览器沿用宽视口及第三方Cookie配置、Chromium版本显示和跳转重试;0.7.2细化脚本错误和页面依赖状态,258项单元测试通过。实际教务页面组件缺失仍未确认根因,见 `../docs/tpschedule-0.7.2-validation.md`。 +学校浏览器沿用宽视口及第三方Cookie配置。0.7.3新增本科已登录缺依赖页面的同会话完整页恢复,保留原学校地址与来源;失败或切页不覆盖旧页面和课表,真实账号仍需复测。见 `../docs/tpschedule-0.7.3-validation.md`。 ## 本科使用 diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 61bc2b1..cc4a04c 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -27,6 +27,7 @@ android { // flag during build. versionCode = flutter.versionCode versionName = flutter.versionName + testInstrumentationRunner = "com.tpschedule.app.SchoolDocumentRenderInstrumentation" } buildTypes { @@ -40,6 +41,7 @@ android { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + testImplementation("junit:junit:4.12") } kotlin { diff --git a/mobile/android/app/src/androidTest/java/com/tpschedule/app/SchoolDocumentRenderInstrumentation.java b/mobile/android/app/src/androidTest/java/com/tpschedule/app/SchoolDocumentRenderInstrumentation.java new file mode 100644 index 0000000..93e1f3e --- /dev/null +++ b/mobile/android/app/src/androidTest/java/com/tpschedule/app/SchoolDocumentRenderInstrumentation.java @@ -0,0 +1,97 @@ +package com.tpschedule.app; + +import android.app.Activity; +import android.app.Instrumentation; +import android.os.Bundle; +import android.webkit.*; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import org.json.*; + +/** Device-only check using public, local fixtures; no school requests or credentials. */ +public final class SchoolDocumentRenderInstrumentation extends Instrumentation { + private static final String URL = "https://eams.uestc.edu.cn/eams/home!main.action"; + private WebView view; + private volatile CountDownLatch loaded; + private volatile String failureCode = "initial_fixture"; + private final AtomicInteger fixtures = new AtomicInteger(); + @Override public void onCreate(Bundle arguments) { super.onCreate(arguments); start(); } + private void require(boolean value, String code) { if (!value) { failureCode = code; throw new AssertionError(code); } } + private void waitFor(CountDownLatch latch) throws Exception { require(latch.await(12, TimeUnit.SECONDS), "fixture_timeout"); } + private JSONObject evaluate(String expression) throws Exception { + CountDownLatch done = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + runOnMainSync(() -> view.evaluateJavascript(expression, value -> { result.set(value); done.countDown(); })); + waitFor(done); + return new JSONObject(new JSONArray("[" + result.get() + "]").getString(0)); + } + private boolean checkDocument(String nonce) throws Exception { + CountDownLatch done = new CountDownLatch(1); + AtomicBoolean result = new AtomicBoolean(); + runOnMainSync(() -> SchoolDocumentRenderer.checkDocument(view, URL, nonce, valid -> { result.set(valid); done.countDown(); })); + waitFor(done); + return result.get(); + } + @Override public void onStart() { + Bundle result = new Bundle(); + int finishCode = Activity.RESULT_CANCELED; + try { + loaded = new CountDownLatch(1); + runOnMainSync(() -> { + view = new WebView(getTargetContext()); + view.getSettings().setJavaScriptEnabled(true); + view.setWebViewClient(new WebViewClient() { + @Override public void onPageFinished(WebView webView, String url) { loaded.countDown(); } + @Override public WebResourceResponse shouldInterceptRequest(WebView webView, WebResourceRequest request) { + String path = request.getUrl().getPath(); + if (path == null) path = ""; + String body = ""; + String mime = "text/plain"; + if (path.endsWith("fixture.js")) { body = "window.fixtureReady='external-script';"; mime = "application/javascript"; fixtures.incrementAndGet(); } + if (path.endsWith("fixture.css")) { body = "#probe {color:rgb(12, 34, 56)}"; mime = "text/css"; fixtures.incrementAndGet(); } + return new WebResourceResponse(mime,"UTF-8",new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + } + }); + CookieManager.getInstance().setCookie(URL, "SchoolDocumentFixture=public; Path=/eams; Secure"); + view.loadDataWithBaseURL(URL,"old-public-fixture","text/html","UTF-8",URL); + }); + waitFor(loaded); + require(checkDocument("fixture-old"), "current_document_rejected"); + loaded = new CountDownLatch(1); + runOnMainSync(() -> view.loadDataWithBaseURL(URL,"same-url-new-document","text/html","UTF-8",URL)); + waitFor(loaded); + require(!checkDocument("fixture-old"), "same_url_new_document_accepted"); + require(checkDocument("fixture-new"), "new_document_rejected"); + String html = "

电子科技大学 • 课表 ✓

"; + loaded = new CountDownLatch(1); + AtomicBoolean applied = new AtomicBoolean(); + runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, URL, CookieManager.getInstance().getCookie(URL), new SchoolDocumentReader.Document(html, java.util.Collections.emptyList())))); + require(applied.get(), "render_not_applied"); waitFor(loaded); + JSONObject actual = evaluate("JSON.stringify({href:location.href,origin:location.origin,text:document.getElementById('probe').textContent,script:window.fixtureReady,color:getComputedStyle(document.getElementById('probe')).color})"); + require(URL.equals(actual.getString("href")), "visible_url_changed"); + require("https://eams.uestc.edu.cn".equals(actual.getString("origin")), "origin_changed"); + require("电子科技大学 • 课表 ✓".equals(actual.getString("text")), "unicode_changed"); + require("external-script".equals(actual.getString("script")), "external_script_missing"); + require("rgb(12, 34, 56)".equals(actual.getString("color")), "external_css_missing"); + require(fixtures.get() == 2, "relative_asset_resolution_failed"); + runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, "https://eams.uestc.edu.cn/eams/index.action", CookieManager.getInstance().getCookie(URL), new SchoolDocumentReader.Document("bad",java.util.Collections.emptyList())))); + require(!applied.get(), "stale_url_accepted"); + runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, URL, "SchoolDocumentFixture=wrong", new SchoolDocumentReader.Document("bad",java.util.Collections.emptyList())))); + require(!applied.get(), "changed_session_accepted"); + JSONObject retained = evaluate("JSON.stringify({text:document.getElementById('probe').textContent})"); + require("电子科技大学 • 课表 ✓".equals(retained.getString("text")), "failed_render_changed_dom"); + result.putString("stream", "SchoolDocument render: URL/origin/Unicode/scripts/CSS/session/document guards passed\n"); + sendStatus(0,result); + finishCode = Activity.RESULT_OK; + } catch (Throwable ignored) { + // Fixed output only: never print URI, cookie, page source or exception messages. + result.putString("stream", "SchoolDocument render fixture failed: " + failureCode + "\n"); + sendStatus(-2,result); + } finally { + runOnMainSync(() -> { if (view != null) view.destroy(); }); + } + finish(finishCode,result); + } +} diff --git a/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentPending.java b/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentPending.java new file mode 100644 index 0000000..dbd28c5 --- /dev/null +++ b/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentPending.java @@ -0,0 +1,56 @@ +package com.tpschedule.app; + +import java.util.function.LongSupplier; +import java.util.*; +import java.util.concurrent.Semaphore; + +/** UI-thread confined registry. Contents never cross the Flutter channel. */ +public final class SchoolDocumentPending { + private static final Semaphore SLOTS = new Semaphore(4); + private static final long TTL = 20000; + private static final class Entry { + final long viewId, created; + final T value; + Entry(long viewId, long created, T value) { this.viewId = viewId; this.created = created; this.value = value; } + } + private final LongSupplier clock; + private final Map> entries = new HashMap<>(); + private boolean closed; + public SchoolDocumentPending(LongSupplier clock) { this.clock = clock; } + public String put(long viewId, T value) { + expire(); + if (closed) return null; + Iterator> old = entries.values().iterator(); + while (old.hasNext()) if (old.next().viewId == viewId) { old.remove(); SLOTS.release(); } + if (!SLOTS.tryAcquire()) return null; + String token = UUID.randomUUID().toString(); + entries.put(token, new Entry<>(viewId, clock.getAsLong(), value)); + return token; + } + public T get(String token) { + expire(); + Entry entry = entries.get(token); + return entry == null ? null : entry.value; + } + public T remove(String token) { + Entry removed = entries.remove(token); + if (removed == null) return null; + SLOTS.release(); + return removed.value; + } + public void removeView(long viewId) { + Iterator> current = entries.values().iterator(); + while (current.hasNext()) if (current.next().viewId == viewId) { current.remove(); SLOTS.release(); } + } + private void expire() { + long now = clock.getAsLong(); + Iterator> current = entries.values().iterator(); + while (current.hasNext()) if (now - current.next().created >= TTL) { current.remove(); SLOTS.release(); } + } + public void close() { + if (closed) return; + closed = true; + SLOTS.release(entries.size()); + entries.clear(); + } +} diff --git a/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentReader.java b/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentReader.java new file mode 100644 index 0000000..2d072d5 --- /dev/null +++ b/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentReader.java @@ -0,0 +1,162 @@ +package com.tpschedule.app; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.regex.*; +import java.util.zip.GZIPInputStream; + +/** Bounded transport for an already visible read-only school document. */ +public final class SchoolDocumentReader { + public interface ConnectionFactory { HttpURLConnection open(URL url) throws IOException; } + public static final class Failure extends Exception { + public final String code; + public Failure(String code) { super(code); this.code = code; } + } + public static final class Document { + public final String html; + public final List setCookies; + public Document(String html, List setCookies) { this.html = html; this.setCookies = setCookies; } + } + private final ConnectionFactory connections; + public SchoolDocumentReader(ConnectionFactory connections) { this.connections = connections; } + public static URI validateUrl(String value) throws Failure { + try { + if (value == null || value.length() > 8192) throw new Failure("unsafe_url"); + URI uri = new URI(value); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null || + (uri.getPort() != -1 && uri.getPort() != 443)) throw new Failure("unsafe_url"); + String path = uri.getRawPath(); + boolean undergraduate = "eams.uestc.edu.cn".equalsIgnoreCase(uri.getHost()) && path != null && + path.matches("/eams/?|/eams/(home(?:Ext)?(?:!(?:main|index|welcome))?|index|courseTableForStd(?:!courseTable)?|stdExamTable!examTable)\\.action"); + boolean graduate = "yjsjy.uestc.edu.cn".equalsIgnoreCase(uri.getHost()) && path != null && + path.matches("/pyxx/pygl/(xskbcx/index/[1-9][0-9]*|kskcxx/index)"); + if (!undergraduate && !graduate) throw new Failure("unsafe_url"); + if (uri.getRawQuery() != null) { + for (String parameter : uri.getRawQuery().split("[&;]")) { + String key = URLDecoder.decode(parameter.split("=", 2)[0], "UTF-8").toLowerCase(Locale.ROOT); + if (key.matches("x-requested-with|ticket|password|username|j_username|j_password|action|optype|operation|method|command|redirect|redirect_uri|service")) + throw new Failure("unsafe_url"); + } + } + return uri; + } catch (Failure failure) { throw failure; } + catch (Exception ignored) { throw new Failure("unsafe_url"); } + } + + public Document fetch(String url, String cookie, String userAgent) throws Failure { + URI uri = validateUrl(url); + if (cookie == null || cookie.trim().isEmpty() || cookie.length() > 16384 || hasControls(cookie)) throw new Failure("no_session"); + HttpURLConnection connection = null; + long deadline = System.nanoTime() + 15_000_000_000L; + try { + connection = connections.open(uri.toURL()); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(8000); + connection.setReadTimeout(8000); + connection.setUseCaches(false); + connection.setRequestProperty("Cookie", cookie); + if (userAgent != null && userAgent.length() <= 4096 && !hasControls(userAgent)) connection.setRequestProperty("User-Agent", userAgent); + connection.setRequestProperty("Accept", "text/html,application/xhtml+xml"); + connection.setRequestProperty("Accept-Encoding", "gzip"); + if (connection.getResponseCode() != 200) throw new Failure("invalid_response"); + String contentType = connection.getContentType(); + if (contentType == null) throw new Failure("invalid_response"); + String mime = contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); + if (!mime.equals("text/html") && !mime.equals("application/xhtml+xml")) throw new Failure("invalid_response"); + Charset charset = StandardCharsets.UTF_8; + Matcher declared = Pattern.compile("(?:^|;)\\s*charset\\s*=\\s*[\"']?([^;\\s\"']+)", Pattern.CASE_INSENSITIVE).matcher(contentType); + if (declared.find()) { + try { charset = Charset.forName(declared.group(1)); } + catch (Exception ignored) { throw new Failure("invalid_response"); } + } + String encoding = connection.getContentEncoding(); + if (encoding != null && !encoding.equalsIgnoreCase("gzip") && !encoding.equalsIgnoreCase("identity")) throw new Failure("invalid_response"); + byte[] bytes; + try (InputStream raw = new BoundedInput(connection.getInputStream(), deadline); + InputStream decoded = "gzip".equalsIgnoreCase(encoding) ? new GZIPInputStream(raw) : raw; + ByteArrayOutputStream body = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int count; + while ((count = decoded.read(buffer)) != -1) { + if (System.nanoTime() > deadline) throw new Failure("network_failed"); + if (body.size() + count > 2 * 1024 * 1024) throw new Failure("too_large"); + body.write(buffer, 0, count); + } + bytes = body.toByteArray(); + } + return new Document(new String(bytes, charset), safeCookies(uri, connection.getHeaderFields())); + } catch (Failure failure) { throw failure; } + catch (BoundFailure failure) { throw new Failure(failure.code); } + catch (Exception ignored) { throw new Failure("network_failed"); } + finally { if (connection != null) connection.disconnect(); } + } + + private static final class BoundFailure extends IOException { + final String code; + BoundFailure(String code) { super(code); this.code = code; } + } + /** Also bounds gzip metadata, which the decoder may read before emitting bytes. */ + private static final class BoundedInput extends FilterInputStream { + final long deadline; + int total; + BoundedInput(InputStream source, long deadline) { super(source); this.deadline = deadline; } + private void check(int count) throws BoundFailure { + if (System.nanoTime() > deadline) throw new BoundFailure("network_failed"); + if (count > 0) total += count; + if (total > 2 * 1024 * 1024) throw new BoundFailure("too_large"); + } + @Override public int read() throws IOException { + check(0); int result = in.read(); check(result < 0 ? 0 : 1); return result; + } + @Override public int read(byte[] bytes, int offset, int length) throws IOException { + check(0); int count = in.read(bytes, offset, length); check(count); return count; + } + } + + private static boolean hasControls(String value) { + for (int i = 0; i < value.length(); i++) if (value.charAt(i) < 32 || value.charAt(i) == 127) return true; + return false; + } + + private static List safeCookies(URI uri, Map> headers) { + List accepted = new ArrayList<>(); + int total = 0; + if (headers == null) return accepted; + String root = uri.getHost().equalsIgnoreCase("eams.uestc.edu.cn") ? "/eams" : "/pyxx"; + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() == null || !entry.getKey().equalsIgnoreCase("Set-Cookie") || entry.getValue() == null) continue; + for (String cookie : entry.getValue()) { + if (cookie == null || cookie.length() > 4096 || hasControls(cookie)) continue; + String[] parts = cookie.split(";", -1); + if (!parts[0].trim().matches("[!#$%&'*+.^_`|~0-9A-Za-z-]+=[^;]*")) continue; + boolean safe = true, domainSeen = false, pathSeen = false; + for (int i = 1; i < parts.length; i++) { + String[] attr = parts[i].trim().split("=", 2); + if (attr[0].equalsIgnoreCase("Domain")) { + if (domainSeen || attr.length != 2 || !attr[1].equalsIgnoreCase(uri.getHost())) safe = false; + domainSeen = true; + } + if (attr[0].equalsIgnoreCase("Path")) { + if (pathSeen || attr.length != 2 || !(attr[1].equals(root) || attr[1].startsWith(root+"/")) || attr[1].contains("%") || attr[1].contains("\\") || attr[1].contains("..")) safe = false; + pathSeen = true; + } + } + if (safe && accepted.size() < 16 && total + cookie.length() <= 16384) { + accepted.add(cookie); total += cookie.length(); + } + } + } + return Collections.unmodifiableList(accepted); + } + + static List cookiesForRender(String url, List cookies) throws Failure { + return safeCookies(validateUrl(url), Collections.singletonMap("Set-Cookie", cookies)); + } +} diff --git a/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentRenderer.java b/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentRenderer.java new file mode 100644 index 0000000..c0bfa47 --- /dev/null +++ b/mobile/android/app/src/main/java/com/tpschedule/app/SchoolDocumentRenderer.java @@ -0,0 +1,39 @@ +package com.tpschedule.app; + +import android.webkit.WebView; +import android.webkit.CookieManager; +import android.webkit.ValueCallback; +import android.os.Looper; +import org.json.JSONObject; + +/** Same-turn final guards and WebView load, shared with device instrumentation. */ +public final class SchoolDocumentRenderer { + private SchoolDocumentRenderer() { } + /** Checks the live document; callers must recheck native state in this callback. */ + public static void checkDocument(WebView view, String url, String documentToken, ValueCallback callback) { + if (Looper.myLooper() != Looper.getMainLooper() || view == null || documentToken == null || + !documentToken.matches("[A-Za-z0-9-]{1,128}")) { callback.onReceiveValue(false); return; } + try { + SchoolDocumentReader.validateUrl(url); + view.evaluateJavascript("location.href === " + JSONObject.quote(url) + + " && window.__tpscheduleDocumentToken === " + JSONObject.quote(documentToken), + value -> callback.onReceiveValue("true".equals(value))); + } catch (Exception ignored) { callback.onReceiveValue(false); } + } + public static boolean render(WebView view, String url, String originalCookie, SchoolDocumentReader.Document document) { + if (Looper.myLooper() != Looper.getMainLooper()) return false; + try { + SchoolDocumentReader.validateUrl(url); + if (view == null || document == null || document.html == null || document.html.length() > 2 * 1024 * 1024 || + originalCookie == null || originalCookie.trim().isEmpty() || !url.equals(view.getUrl())) return false; + CookieManager cookies = CookieManager.getInstance(); + if (!originalCookie.equals(cookies.getCookie(url))) return false; + // The guards, scoped cookie application and load occur in one UI turn. + for (String cookie : SchoolDocumentReader.cookiesForRender(url, document.setCookies)) cookies.setCookie(url, cookie); + view.loadDataWithBaseURL(url, document.html, "text/html", "UTF-8", url); + return true; + } catch (Exception ignored) { + return false; + } + } +} diff --git a/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt index f0cc047..a0ff41f 100644 --- a/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt @@ -8,6 +8,7 @@ import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterActivity() { private var calendar: CalendarBridge? = null private var reminders: ReminderSystemBridge? = null + private var schoolDocuments: SchoolDocumentBridge? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -20,6 +21,13 @@ class MainActivity : FlutterActivity() { super.configureFlutterEngine(flutterEngine) calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger) reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger) + schoolDocuments?.close() + schoolDocuments = SchoolDocumentBridge(flutterEngine) + } + override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { + schoolDocuments?.close() + schoolDocuments = null + super.cleanUpFlutterEngine(flutterEngine) } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) @@ -28,6 +36,7 @@ class MainActivity : FlutterActivity() { override fun onDestroy() { calendar?.close() reminders?.close() + schoolDocuments?.close() super.onDestroy() } } diff --git a/mobile/android/app/src/main/kotlin/com/tpschedule/app/SchoolDocumentBridge.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/SchoolDocumentBridge.kt new file mode 100644 index 0000000..3fd70ba --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/SchoolDocumentBridge.kt @@ -0,0 +1,165 @@ +package com.tpschedule.app + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.webkit.CookieManager +import android.webkit.WebView +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi +import java.lang.ref.WeakReference +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import javax.net.ssl.HttpsURLConnection + +/** Fetches a bounded full document with the visible WebView's existing session. */ +class SchoolDocumentBridge(private val engine: FlutterEngine) { + private class Candidate( + val viewId: Long, + val url: String, + val cookie: String, + val documentToken: String, + val view: WeakReference, + var document: SchoolDocumentReader.Document? = null, + ) + + private val ui = Handler(Looper.getMainLooper()) + private val worker = ThreadPoolExecutor( + 2, 2, 0L, TimeUnit.MILLISECONDS, ArrayBlockingQueue(2), + ) + private val pending = SchoolDocumentPending { SystemClock.elapsedRealtime() } + private val reader = SchoolDocumentReader { url -> url.openConnection() as HttpsURLConnection } + private val channel = MethodChannel(engine.dartExecutor.binaryMessenger, "com.tpschedule.app/school_document") + private var closed = false + + init { + channel.setMethodCallHandler { call, result -> + try { + if (closed || Looper.myLooper() != Looper.getMainLooper()) { + fail(result, "unavailable") + } else { + when (call.method) { + "fetch" -> fetch(call, result) + "render" -> render(call, result) + "invalidate" -> { + val viewId = call.argument("viewId")?.toLong() + if (viewId != null) pending.removeView(viewId) + result.success(null) + } + "discard" -> { + call.argument("token")?.let { pending.remove(it) } + result.success(null) + } + else -> result.notImplemented() + } + } + } catch (failure: SchoolDocumentReader.Failure) { + fail(result, failure.code) + } catch (_: Exception) { + fail(result, "unavailable") + } + } + } + + @Suppress("DEPRECATION") + private fun webView(viewId: Long): WebView? = + WebViewFlutterAndroidExternalApi.getWebView(engine, viewId) + + private fun fetch(call: MethodCall, result: MethodChannel.Result) { + val viewId = call.argument("viewId")?.toLong() + ?: return fail(result, "unavailable") + val url = call.argument("url") ?: return fail(result, "unsafe_url") + val documentToken = call.argument("documentToken") + ?.takeIf { it.matches(Regex("[A-Za-z0-9-]{1,128}")) } + ?: return fail(result, "stale_document") + SchoolDocumentReader.validateUrl(url) + val view = webView(viewId) ?: return fail(result, "unavailable") + if (view.url != url) return fail(result, "stale_document") + val cookie = CookieManager.getInstance().getCookie(url) + if (cookie.isNullOrBlank()) return fail(result, "no_session") + val userAgent = view.settings.userAgentString + val candidate = Candidate(viewId, url, cookie, documentToken, WeakReference(view)) + val token = pending.put(viewId, candidate) ?: return fail(result, "unavailable") + // Expiration clears native bodies/cookies even when Dart never calls discard. + ui.postDelayed({ pending.remove(token) }, 20_000) + try { + worker.execute { + var document: SchoolDocumentReader.Document? = null + var error: String? = null + try { + document = reader.fetch(url, cookie, userAgent) + } catch (failure: SchoolDocumentReader.Failure) { + error = failure.code + } catch (_: Exception) { + error = "network_failed" + } + ui.post { + try { + if (closed) { + pending.remove(token) + fail(result, "unavailable") + } else if (pending.get(token) !== candidate || !matches(candidate)) { + pending.remove(token) + fail(result, "stale_document") + } else if (error != null || document == null) { + pending.remove(token) + fail(result, error ?: "invalid_response") + } else { + candidate.document = document + result.success(mapOf("token" to token, "html" to document!!.html)) + } + } catch (_: Exception) { + pending.remove(token) + fail(result, "unavailable") + } + } + } + } catch (_: RejectedExecutionException) { + pending.remove(token) + fail(result, "unavailable") + } + } + + private fun matches(candidate: Candidate): Boolean { + val actual = webView(candidate.viewId) ?: return false + return actual === candidate.view.get() && actual.url == candidate.url && + CookieManager.getInstance().getCookie(candidate.url) == candidate.cookie + } + + private fun render(call: MethodCall, result: MethodChannel.Result) { + val token = call.argument("token") ?: return result.success(false) + val candidate = pending.get(token) ?: return result.success(false) + val document = candidate.document ?: return result.success(false) + if (!matches(candidate)) return result.success(false) + val view = candidate.view.get() ?: return result.success(false) + SchoolDocumentRenderer.checkDocument(view, candidate.url, candidate.documentToken) { sameDocument -> + try { + // Invalidation, replacement or expiration during JS verification cancels this render. + if (closed || !sameDocument || pending.get(token) !== candidate || !matches(candidate)) { + pending.remove(token) + result.success(false) + } else { + pending.remove(token) + result.success(SchoolDocumentRenderer.render(view, candidate.url, candidate.cookie, document)) + } + } catch (_: Exception) { + pending.remove(token) + result.success(false) + } + } + } + + private fun fail(result: MethodChannel.Result, code: String) = result.error(code, code, null) + + fun close() { + closed = true + channel.setMethodCallHandler(null) + pending.close() + worker.shutdownNow() + ui.removeCallbacksAndMessages(null) + } +} diff --git a/mobile/android/app/src/test/java/com/tpschedule/app/SchoolDocumentPendingTest.java b/mobile/android/app/src/test/java/com/tpschedule/app/SchoolDocumentPendingTest.java new file mode 100644 index 0000000..c5d2bd9 --- /dev/null +++ b/mobile/android/app/src/test/java/com/tpschedule/app/SchoolDocumentPendingTest.java @@ -0,0 +1,53 @@ +package com.tpschedule.app; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class SchoolDocumentPendingTest { + @Test public void invalidateViewRemovesCandidateAwaitingDocumentVerification() { + SchoolDocumentPending store = new SchoolDocumentPending<>(() -> 0); + try { + String waiting = store.put(42, "awaiting-verification"), other = store.put(43, "other-view"); + store.removeView(42); + assertNull(store.get(waiting)); + assertEquals("other-view", store.get(other)); + assertNotNull(store.put(42, "new-document")); + assertNull(store.get(waiting)); + } finally { store.close(); } + } + @Test public void capsPendingAcrossViewsAndReleasesOnDiscard() { + long[] now = {0}; + SchoolDocumentPending store = new SchoolDocumentPending<>(() -> now[0]); + for (int i=0;i<4;i++) assertNotNull(store.put(i, "doc")); + assertNull(store.put(5, "over-cap")); + String removed = store.put(0, "replacement"); + assertNotNull(removed); + store.remove(removed); + assertNotNull(store.put(5, "after-discard")); + store.close(); + } + @Test public void expiresAtTwentySecondsAndDoesNotReviveAfterClose() { + long[] now = {100}; + SchoolDocumentPending store = new SchoolDocumentPending<>(() -> now[0]); + String token = store.put(42, "private"); + now[0] = 20099; assertEquals("private", store.get(token)); + now[0] = 20100; assertNull(store.get(token)); + String next = store.put(43, "private"); store.close(); + assertNull(store.get(next)); assertNull(store.put(44, "late")); + } + @Test public void newFetchReplacesOnlyTheSameViewAndInvalidatesOldToken() { + SchoolDocumentPending store = new SchoolDocumentPending<>(() -> 0); + String first = store.put(1,"old"), other = store.put(2,"other"), latest = store.put(1,"latest"); + assertNull(store.get(first)); assertEquals("other",store.get(other)); assertEquals("latest",store.get(latest)); + assertEquals("latest",store.remove(latest)); assertNull(store.remove(latest)); + store.close(); + } + @Test public void fourSlotLimitIsSharedAcrossRegistries() { + SchoolDocumentPending a = new SchoolDocumentPending<>(() -> 0), b = new SchoolDocumentPending<>(() -> 0); + try { + for(int i=0;i<4;i++) assertNotNull(a.put(i,"private")); + assertNull(b.put(7,"over-cap")); + a.close(); assertNotNull(b.put(7,"released")); + } finally { a.close(); b.close(); } + } +} diff --git a/mobile/android/app/src/test/java/com/tpschedule/app/SchoolDocumentReaderTest.java b/mobile/android/app/src/test/java/com/tpschedule/app/SchoolDocumentReaderTest.java new file mode 100644 index 0000000..4289dc5 --- /dev/null +++ b/mobile/android/app/src/test/java/com/tpschedule/app/SchoolDocumentReaderTest.java @@ -0,0 +1,111 @@ +package com.tpschedule.app; + +import org.junit.Test; +import static org.junit.Assert.*; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.zip.GZIPOutputStream; + +public class SchoolDocumentReaderTest { + private static final String URL = "https://eams.uestc.edu.cn/eams/home!main.action"; + // Mimics beangle3 head.ftl: presence of XRW suppresses the complete head. + static class Backend extends HttpURLConnection { + int status = 200; + String mime = "text/html; charset=UTF-8"; + byte[] body; + String encoding; + List cookies = new ArrayList<>(); + boolean disconnected; + Backend() throws Exception { super(new URL(URL)); } + public void connect() { } + public boolean usingProxy() { return false; } + public void disconnect() { disconnected = true; } + public int getResponseCode() { return status; } + public String getContentType() { return mime; } + public String getContentEncoding() { return encoding; } + public Map> getHeaderFields() { return Collections.singletonMap("Set-Cookie", cookies); } + public InputStream getInputStream() { + if (body != null) return new ByteArrayInputStream(body); + boolean fragment = getRequestProperty("X-Requested-With") != null; + return new ByteArrayInputStream((fragment ? "
课表
" : "课表").getBytes(StandardCharsets.UTF_8)); + } + } + private SchoolDocumentReader reader(Backend backend) { return new SchoolDocumentReader(url -> backend); } + private void reject(String code, Throwing action) throws Exception { + try { action.run(); fail("expected " + code); } + catch (SchoolDocumentReader.Failure failure) { assertEquals(code, failure.code); assertEquals(code, failure.getMessage()); } + } + interface Throwing { void run() throws Exception; } + + @Test public void missingXrwRestoresFullHeadWithSameSessionGet() throws Exception { + Backend backend = new Backend(); + SchoolDocumentReader.Document document = reader(backend).fetch(URL, "SESSION=private", "fixture-agent"); + assertTrue(document.html.contains("jquery.js")); + assertTrue(document.html.contains("site.css")); + assertTrue(document.html.contains("课表")); + assertEquals("GET", backend.getRequestMethod()); + assertEquals("SESSION=private", backend.getRequestProperty("Cookie")); + assertEquals("fixture-agent", backend.getRequestProperty("User-Agent")); + assertNull(backend.getRequestProperty("X-Requested-With")); + assertFalse(backend.getInstanceFollowRedirects()); + assertTrue(backend.disconnected); + } + @Test public void missingSessionMakesNoRequest() throws Exception { + reject("no_session", () -> new SchoolDocumentReader(url -> { fail("request before session validation"); return null; }).fetch(URL, "", "ua")); + } + @Test public void rejectsUnsafeTargetsAndDecodedCredentialQuery() throws Exception { + String[] urls = {"http://eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn:444/eams/", "https://eams.uestc.edu.cn.evil/eams/", "https://user@eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn/eams/logout.action", "https://eams.uestc.edu.cn/eams/home!save.action", URL+"?%74icket=secret", URL+"?X-Requested-With=a", URL+"?action=delete", URL+"?opType=save", "https://eams.uestc.edu.cn/eams/%2e%2e/eams/"}; + for (String url : urls) reject("unsafe_url", () -> reader(new Backend()).fetch(url, "S=x", "ua")); + } + @Test public void allowsOnlySettledReadonlyRoutes() throws Exception { + String[] urls = {"https://eams.uestc.edu.cn/eams", "https://eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn/eams/homeExt!index.action", "https://eams.uestc.edu.cn/eams/courseTableForStd!courseTable.action?semester.id=42", "https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action", "https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/42", "https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index"}; + for (String url : urls) assertNotNull(reader(new Backend()).fetch(url, "S=x", "ua")); + } + @Test public void neverFollowsRedirectsIncludingSameHost() throws Exception { + Backend backend = new Backend(); backend.status = 302; + reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua")); + assertFalse(backend.getInstanceFollowRedirects()); assertTrue(backend.disconnected); + } + @Test public void rejectsNonHtmlOrNonSuccess() throws Exception { + for (String mime : new String[]{"application/json", "text/plain", null}) { + Backend backend = new Backend(); backend.mime = mime; + reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua")); + } + for (int status : new int[]{201,204,401,403,500}) { + Backend backend = new Backend(); backend.status = status; + reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua")); + } + } + @Test public void enforcesDecodedLimitEvenForGzip() throws Exception { + byte[] oversized = new byte[2*1024*1024+1]; Arrays.fill(oversized, (byte)'a'); + Backend plain = new Backend(); plain.body = oversized; + reject("too_large", () -> reader(plain).fetch(URL, "S=x", "ua")); + Backend zipped = new Backend(); zipped.encoding = "gzip"; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(buffer)) { gzip.write(oversized); } + zipped.body = buffer.toByteArray(); + reject("too_large", () -> reader(zipped).fetch(URL, "S=x", "ua")); + } + @Test public void acceptsHtmlCharsetAndGzip() throws Exception { + Backend backend = new Backend(); backend.mime = "application/xhtml+xml; charset=GB18030"; + backend.body = "中文".getBytes("GB18030"); + assertEquals("中文", reader(backend).fetch(URL,"S=x","ua").html); + } + @Test public void boundsCompressedHeadersBeforeDecoderProducesOutput() throws Exception { + Backend backend = new Backend(); backend.encoding = "gzip"; + backend.body = new byte[2*1024*1024+1]; Arrays.fill(backend.body,(byte)'a'); + byte[] gzipHeader = {(byte)0x1f,(byte)0x8b,8,8,0,0,0,0,0,3}; + System.arraycopy(gzipHeader,0,backend.body,0,gzipHeader.length); + reject("too_large", () -> reader(backend).fetch(URL,"S=x","ua")); + } + @Test public void onlyKeepsBoundedSameSchoolCookieScope() throws Exception { + Backend backend = new Backend(); + backend.cookies = Arrays.asList("SID=next; Path=/eams; Secure; HttpOnly", "A=b; Domain=eams.uestc.edu.cn; Path=/eams/", "BAD=x; Domain=.uestc.edu.cn; Path=/eams", "BAD=x; Domain=eams.uestc.edu.cn.evil; Path=/eams", "BAD=x; Path=/", "BAD=x; Path=/eamsOther", "BAD=x\r\nInjected: yes", "BAD=x; Domain=eams.uestc.edu.cn; Domain=evil", "z="+String.join("", Collections.nCopies(4097,"a"))); + assertEquals(Arrays.asList("SID=next; Path=/eams; Secure; HttpOnly", "A=b; Domain=eams.uestc.edu.cn; Path=/eams/"),reader(backend).fetch(URL,"S=x","ua").setCookies); + } + @Test public void sanitizesTransportFailure() throws Exception { + reject("network_failed", () -> new SchoolDocumentReader(url -> { throw new IOException("secret-cookie-and-url"); }).fetch(URL,"S=x","ua")); + } +} diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart index 03383db..13f001d 100644 --- a/mobile/lib/app/app_version.dart +++ b/mobile/lib/app/app_version.dart @@ -1,2 +1,2 @@ -const appVersion = '0.7.2'; -const appBuildNumber = 20; +const appVersion = '0.7.3'; +const appBuildNumber = 21; diff --git a/mobile/lib/features/school/school_full_page_recovery.dart b/mobile/lib/features/school/school_full_page_recovery.dart new file mode 100644 index 0000000..6a76cfd --- /dev/null +++ b/mobile/lib/features/school/school_full_page_recovery.dart @@ -0,0 +1,295 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:html/parser.dart' as html; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; + +import '../../services/diagnostic_log.dart'; +import 'school_browser_diagnostics.dart'; + +class SchoolPageCandidate { + const SchoolPageCandidate(this.token, this.html); + final String token, html; +} + +abstract interface class SchoolFullPageGateway { + bool supports(WebViewController web); + Future fetch( + WebViewController web, + Uri url, + String documentToken, + ); + Future render(String token); + Future discard(String token); + Future invalidate(WebViewController web); +} + +enum FullPageRecoveryResult { unchanged, restored, failed, cancelled } + +class AndroidSchoolFullPageGateway implements SchoolFullPageGateway { + const AndroidSchoolFullPageGateway(); + static const _channel = MethodChannel('com.tpschedule.app/school_document'); + @override + bool supports(WebViewController web) => + web.platform is AndroidWebViewController; + @override + Future fetch( + WebViewController web, + Uri url, + String documentToken, + ) async { + final response = await _channel.invokeMapMethod('fetch', { + 'viewId': (web.platform as AndroidWebViewController).webViewIdentifier, + 'url': url.toString(), + 'documentToken': documentToken, + }); + final token = response?['token'], page = response?['html']; + if (token is! String || + !RegExp(r'^[A-Za-z0-9-]{1,128}$').hasMatch(token) || + page is! String) { + throw const FormatException('Invalid page response'); + } + return SchoolPageCandidate(token, page); + } + + @override + Future render(String token) async => + await _channel.invokeMethod('render', {'token': token}) == true; + @override + Future discard(String token) => + _channel.invokeMethod('discard', {'token': token}); + @override + Future invalidate(WebViewController web) => _channel.invokeMethod( + 'invalidate', + {'viewId': (web.platform as AndroidWebViewController).webViewIdentifier}, + ); +} + +class SchoolFullPageRecovery { + SchoolFullPageRecovery({SchoolFullPageGateway? gateway}) + : gateway = gateway ?? const AndroidSchoolFullPageGateway(); + final SchoolFullPageGateway gateway; + final _attempted = {}; + int _epoch = 0; + WebViewController? _lastWeb; + void reset() { + invalidateDocument(); + _attempted.clear(); + } + + void invalidateDocument() { + _epoch++; + final web = _lastWeb; + if (web != null && gateway.supports(web)) { + unawaited(gateway.invalidate(web).catchError((Object _) {})); + } + } + + static bool canRestore(Uri uri) { + if (uri.scheme != 'https' || + uri.host != 'eams.uestc.edu.cn' || + uri.port != 443 || + uri.userInfo.isNotEmpty) { + return false; + } + if (uri.queryParametersAll.keys.any( + (key) => const { + 'x-requested-with', + 'ticket', + 'password', + 'username', + 'j_username', + 'j_password', + 'optype', + 'operation', + 'action', + 'method', + }.contains(key.toLowerCase()), + )) { + return false; + } + return uri.path == '/eams' || + uri.path == '/eams/' || + RegExp( + r'^/eams/(home(?:Ext)?(?:!(?:main|index|welcome))?|index|courseTableForStd(?:!courseTable)?|stdExamTable!examTable)\.action$', + ).hasMatch(uri.path); + } + + Map? _pageState(Object raw) { + dynamic value = raw; + for (var i = 0; i < 2 && value is String; i++) { + value = jsonDecode(value); + } + return value is Map && + value['scripts'] is int && + value['scripts'] >= 0 && + value['scripts'] <= 4 && + value['styles'] == 0 && + value['links'] == 0 && + value['jquery'] == false && + value['documentToken'] is String && + RegExp(r'^[A-Za-z0-9-]{1,128}$').hasMatch(value['documentToken']) + ? value + : null; + } + + bool _completePage(String source, Uri uri) { + if (source.length > 2 * 1024 * 1024 || + utf8.encode(source).length > 2 * 1024 * 1024 || + !RegExp(r')', caseSensitive: false).hasMatch(source)) { + return false; + } + final document = html.parse(source); + final head = document.head; + if (document.querySelector('input[type="password"]') != null || + head == null || + !head + .querySelectorAll('script[src]') + .any((e) => e.attributes['src']!.trim().isNotEmpty) || + !head + .querySelectorAll('link[rel~="stylesheet"][href]') + .any((e) => e.attributes['href']!.trim().isNotEmpty)) { + return false; + } + if (head + .querySelectorAll('meta[http-equiv]') + .any((e) => e.attributes['http-equiv']!.toLowerCase() == 'refresh')) { + return false; + } + for (final base in head.querySelectorAll('base[href]')) { + if (uri.resolve(base.attributes['href']!).origin != uri.origin) { + return false; + } + } + return document.querySelector( + '#manualArrangeCourseTable, #stdExamTableForm', + ) != + null || + document.querySelectorAll('a[href]').any((a) { + final target = uri.resolve(a.attributes['href']!); + return target.scheme == 'https' && + target.host == uri.host && + target.port == 443 && + target.userInfo.isEmpty && + target.path == '/eams/logout.action'; + }); + } + + void _record(String phase, [String? code]) => DiagnosticLog.instance.add( + 'school_full_page_restore', + fields: {'school': 'undergraduate', 'phase': phase, 'code': code}, + ); + + Future tryRestore( + WebViewController web, + Uri url, { + required bool authenticated, + required String documentKey, + required bool Function() isCurrent, + void Function()? onStarted, + }) async { + if (!authenticated || + !canRestore(url) || + !gateway.supports(web) || + _attempted.contains(documentKey)) { + return FullPageRecoveryResult.unchanged; + } + final epoch = _epoch; + _lastWeb = web; + bool valid() => isCurrent() && epoch == _epoch; + SchoolPageCandidate? candidate; + try { + if (!valid() || await web.currentUrl() != url.toString() || !valid()) { + return FullPageRecoveryResult.cancelled; + } + final nonce = List.generate( + 4, + (_) => Random.secure() + .nextInt(0x100000000) + .toRadixString(16) + .padLeft(8, '0'), + ).join(); + final diagnostic = schoolPageDiagnosticsScript.replaceAll( + '__EXPECTED_URL__', + jsonEncode(url.toString()), + ); + final raw = await web.runJavaScriptReturningResult('''(() => { + const state=JSON.parse($diagnostic); + if(!state)return 'null'; + if(typeof window.__tpscheduleDocumentToken!=='string')Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'$nonce',configurable:true}); + state.documentToken=window.__tpscheduleDocumentToken; + return JSON.stringify(state); + })()'''); + if (!valid() || await web.currentUrl() != url.toString() || !valid()) { + return FullPageRecoveryResult.cancelled; + } + final state = _pageState(raw); + if (state == null) return FullPageRecoveryResult.unchanged; + if (_attempted.length >= 4) return FullPageRecoveryResult.failed; + _attempted.add(documentKey); + onStarted?.call(); + if (!valid()) return FullPageRecoveryResult.cancelled; + _record('requested', 'missing_head'); + candidate = await gateway + .fetch(web, url, state['documentToken'] as String) + .timeout(const Duration(seconds: 35)); + if (!valid() || await web.currentUrl() != url.toString() || !valid()) { + _record('cancelled', 'document_changed'); + return FullPageRecoveryResult.cancelled; + } + if (!_completePage(candidate.html, url)) { + _record('rejected', 'incomplete_head'); + return FullPageRecoveryResult.failed; + } + if (!valid()) return FullPageRecoveryResult.cancelled; + final rendered = await gateway + .render(candidate.token) + .timeout(const Duration(seconds: 10)); + _record( + rendered ? 'applied' : 'cancelled', + rendered ? null : 'document_changed', + ); + return rendered + ? FullPageRecoveryResult.restored + : FullPageRecoveryResult.cancelled; + } catch (error) { + if (!valid()) return FullPageRecoveryResult.cancelled; + const codes = { + 'invalid_url', + 'no_session', + 'document_changed', + 'unexpected_status', + 'redirect_rejected', + 'invalid_content_type', + 'too_large', + 'network_failed', + 'tls_failed', + 'invalid_cookie', + 'cookie_scope', + 'unsupported_encoding', + 'unsafe_url', + 'stale_document', + 'invalid_response', + 'unavailable', + }; + final code = error is PlatformException && codes.contains(error.code) + ? error.code + : error is TimeoutException + ? 'timeout' + : 'capture_failed'; + _record('failed', code); + return FullPageRecoveryResult.failed; + } finally { + if (candidate != null) { + try { + await gateway.discard(candidate.token); + } catch (_) { + /* No pending private page is persisted. */ + } + } + } + } +} diff --git a/mobile/lib/features/school/undergraduate/session.dart b/mobile/lib/features/school/undergraduate/session.dart index bb0b9f7..724c5f1 100644 --- a/mobile/lib/features/school/undergraduate/session.dart +++ b/mobile/lib/features/school/undergraduate/session.dart @@ -13,9 +13,14 @@ import 'timetable_adapter.dart'; import 'exam_adapter.dart'; import '../school_browser.dart'; import '../school_browser_diagnostics.dart'; +import '../school_full_page_recovery.dart'; class UndergraduateSession extends ChangeNotifier { - UndergraduateSession({this.configureBrowser = configureSchoolBrowser}); + UndergraduateSession({ + this.configureBrowser = configureSchoolBrowser, + SchoolFullPageRecovery? pageRecovery, + }) : _pageRecovery = pageRecovery ?? SchoolFullPageRecovery(); + final SchoolFullPageRecovery _pageRecovery; final SchoolBrowserConfigurator configureBrowser; Future? _browserReady; String? browserVersion; @@ -28,6 +33,7 @@ class UndergraduateSession extends ChangeNotifier { _connected = false, _loading = false, _manualCapture = false, + _recoveringPage = false, _failed = false, _complete = false; bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false; @@ -44,7 +50,8 @@ class UndergraduateSession extends ChangeNotifier { int get pageGeneration => _generation; bool get requestingExams => query != null; bool get canLogout => hasOpened || logoutFailed; - bool get busy => _loading || _manualCapture || _itemTimeout != null; + bool get busy => + _loading || _manualCapture || _recoveringPage || _itemTimeout != null; String get statusLabel => logoutFailed ? '退出未完成' : _failed @@ -70,6 +77,8 @@ class UndergraduateSession extends ChangeNotifier { void _reset() { _generation++; + _pageRecovery.reset(); + _recoveringPage = false; _cancelTimers(); _parts.clear(); _index = 0; @@ -123,6 +132,7 @@ class UndergraduateSession extends ChangeNotifier { return NavigationDecision.navigate; }, onPageStarted: (url) { + _pageRecovery.invalidateDocument(); DiagnosticLog.instance.navigation('undergraduate', 'started', url); // Within a batch, type-to-type loads retain their generation. Once // published, any new document invalidates the deferred save result. @@ -130,6 +140,7 @@ class UndergraduateSession extends ChangeNotifier { _reset(); } _document++; + _recoveringPage = false; _poll?.cancel(); _probeKey = null; pendingDraft = null; @@ -350,6 +361,37 @@ class UndergraduateSession extends ChangeNotifier { } _connected = true; awaitingAuthentication = false; + final recovery = await _pageRecovery.tryRestore( + web, + uri, + authenticated: true, + documentKey: '$g:$d', + isCurrent: () => _valid(g) && d == _document, + onStarted: () { + _recoveringPage = true; + message = '已登录,正在恢复完整教务页面…'; + _emit(); + }, + ); + if (!_valid(g) || d != _document) return; + _recoveringPage = false; + if (recovery == FullPageRecoveryResult.restored) { + _loading = true; + message = '完整页面已恢复,正在检查…'; + _schedulePoll(g, d, url); + _emit(); + return; + } + if (recovery == FullPageRecoveryResult.cancelled) { + message = '页面已变化,请等待加载完成或重新认证。'; + _emit(); + return; + } + if (recovery == FullPageRecoveryResult.failed) { + message = '已登录,但完整教务页面暂未恢复,请重新认证后重试。'; + _emit(); + return; + } if (!requestingExams) { message = manualMessage; _emit(); @@ -438,7 +480,9 @@ class UndergraduateSession extends ChangeNotifier { } Future readCurrentTimetable() async { - if (requestingExams || _manualCapture || !_active) return; + if (requestingExams || _manualCapture || _recoveringPage || !_active) { + return; + } final g = _generation, d = _document; _manualCapture = true; _emit(); @@ -535,6 +579,7 @@ class UndergraduateSession extends ChangeNotifier { _disposed = true; _active = false; _generation++; + _pageRecovery.reset(); _cancelTimers(); super.dispose(); } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index bf79acb..3a5c90f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: TPSchedule 学生课表 publish_to: none -version: 0.7.2+20 +version: 0.7.3+21 environment: sdk: ^3.13.5 dependencies: diff --git a/mobile/test/school/full_page_recovery_js_test.cjs b/mobile/test/school/full_page_recovery_js_test.cjs new file mode 100644 index 0000000..b85ae12 --- /dev/null +++ b/mobile/test/school/full_page_recovery_js_test.cjs @@ -0,0 +1,25 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const root = path.join(__dirname, '../../lib/features/school'); +const diagnostic = fs.readFileSync(path.join(root, 'school_browser_diagnostics.dart'), 'utf8').match(/schoolPageDiagnosticsScript = r'''([\s\S]*?)''';/)[1]; +const wrapper = fs.readFileSync(path.join(root, 'school_full_page_recovery.dart'), 'utf8').match(/'''(\(\(\) => \{[\s\S]*?)'''/)[1]; +const url = 'https://eams.uestc.edu.cn/eams/home.action'; +function context(password = false) { + const document = {scripts: Array(2), styleSheets: [], querySelector: () => password ? {} : null, querySelectorAll: () => []}; + for (const key of ['cookie', 'body', 'forms']) Object.defineProperty(document, key, {get() {throw Error('private_data_read');}}); + return vm.createContext({document, location: {href: url}, window: {}, performance: {getEntriesByType: () => []}}); +} +function run(ctx, nonce) { + return JSON.parse(vm.runInContext(wrapper.replace('$diagnostic', diagnostic.replace('__EXPECTED_URL__', JSON.stringify(url))).replace('$nonce', nonce), ctx)); +} +const ctx = context(); +assert.equal(run(ctx, 'first-document').documentToken, 'first-document'); +assert.equal(run(ctx, 'unused-token').documentToken, 'first-document'); +assert.equal(Object.keys(ctx.window).includes('__tpscheduleDocumentToken'), false); +assert.equal(run(context(), 'new-document').documentToken, 'new-document'); +assert.equal(run(context(true), 'ignored'), null); +const moved = context(); moved.location.href = 'https://idas.uestc.edu.cn/authserver/login'; +assert.equal(run(moved, 'ignored'), null); +console.log('Recovery snapshot: actual wrapper, stable document nonce, new-document change, password/redirect guards and no private reads passed.'); diff --git a/mobile/test/school/school_full_page_recovery_test.dart b/mobile/test/school/school_full_page_recovery_test.dart new file mode 100644 index 0000000..e206c84 --- /dev/null +++ b/mobile/test/school/school_full_page_recovery_test.dart @@ -0,0 +1,259 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:hepan_everyday/features/school/school_full_page_recovery.dart'; +import 'package:hepan_everyday/features/school/undergraduate/session.dart'; +import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart'; + +import '../support/fake_school_web.dart'; + +const _complete = ''' + + +退出PRIVATE_PAGE_CONTENT'''; +const _fragment = + '退出'; + +class Gateway implements SchoolFullPageGateway { + int requests = 0, renders = 0, discards = 0; + String html = _complete; + Completer? pending; + Completer? pendingRender; + bool live = false; + void Function()? onRender; + @override + bool supports(WebViewController web) => true; + @override + Future fetch( + WebViewController web, + Uri uri, + String documentToken, + ) async { + requests++; + live = true; + return pending == null + ? SchoolPageCandidate('opaque-token', html) + : pending!.future; + } + + @override + Future render(String token) async { + renders++; + if (pendingRender != null) { + return await pendingRender!.future && live; + } + final accepted = live; + onRender?.call(); + return accepted; + } + + @override + Future discard(String token) async { + discards++; + } + + @override + Future invalidate(WebViewController web) async { + live = false; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FakeSchoolPlatform platform; + late WebViewController web; + late Gateway gateway; + late SchoolFullPageRecovery recovery; + final url = Uri.parse('https://eams.uestc.edu.cn/eams/home.action'); + setUp(() { + platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + web = WebViewController(); + platform.web.url = url.toString(); + platform.web.javascript = (_) async => jsonEncode({ + 'scripts': 2, + 'styles': 0, + 'links': 0, + 'loaded': 0, + 'jquery': false, + 'documentToken': 'fixture-document-token', + 'beangle': false, + 'require': false, + 'script202': 0, + 'style202': 0, + }); + gateway = Gateway(); + recovery = SchoolFullPageRecovery(gateway: gateway); + }); + Future restore({ + bool authenticated = true, + bool Function()? current, + }) => recovery.tryRestore( + web, + url, + authenticated: authenticated, + documentKey: 'visit:document', + isCurrent: current ?? () => true, + ); + + test( + 'authenticated fragment is replaced only after a complete page is fetched', + () async { + expect(await restore(), FullPageRecoveryResult.restored); + expect(gateway.requests, 1); + expect(gateway.renders, 1); + expect(gateway.discards, 1); + }, + ); + test( + 'incomplete head or password form preserves current page and does not loop', + () async { + for (final html in [ + _fragment, + _complete.replaceFirst( + 'PRIVATE_PAGE_CONTENT', + '', + ), + _complete.replaceFirst( + '', + '', + ), + _complete.replaceFirst( + '', + '', + ), + ]) { + gateway.html = html; + recovery.reset(); + expect(await restore(), FullPageRecoveryResult.failed); + expect(await restore(), FullPageRecoveryResult.unchanged); + } + expect(gateway.renders, 0); + expect(gateway.requests, 4); + expect(gateway.discards, 4); + }, + ); + for (final started in [false, true]) { + test( + 'session recovers once and completes after render, started=$started', + () async { + final session = UndergraduateSession(pageRecovery: recovery); + addTearDown(session.dispose); + await session.openTimetable(); + var rendered = false; + platform.web.javascript = (script) async { + if (script == undergraduateStatusProbeScript) { + return jsonEncode({ + 'topUrl': platform.web.url!, + 'authenticated': true, + 'pages': [], + }); + } + return jsonEncode({ + 'scripts': rendered ? 20 : 2, + 'styles': 0, + 'links': rendered ? 3 : 0, + 'jquery': rendered, + 'documentToken': 'fixture-document-token', + }); + }; + gateway.onRender = () { + rendered = true; + if (started) platform.delegate.started(platform.web.url!); + platform.delegate.finished(platform.web.url!); + }; + platform.web.url = url.toString(); + platform.delegate.started(url.toString()); + platform.delegate.finished(url.toString()); + await flushSchoolEvents(); + await Future.delayed(const Duration(milliseconds: 550)); + await flushSchoolEvents(); + expect(session.statusLabel, '已连接'); + expect(session.busy, false); + expect(session.message, UndergraduateSession.manualMessage); + expect(gateway.requests, 1); + expect(gateway.renders, 1); + expect(session.pendingDraft, isNull); + }, + ); + } + test( + 'same URL document replacement or closing during fetch cancels rendering', + () async { + gateway.pending = Completer(); + var current = true; + final task = restore(current: () => current); + await flushSchoolEvents(); + expect(gateway.requests, 1); + current = false; + gateway.pending!.complete( + const SchoolPageCandidate('opaque-token', _complete), + ); + expect(await task, FullPageRecoveryResult.cancelled); + expect(gateway.renders, 0); + expect(gateway.discards, 1); + }, + ); + test( + 'reset invalidates a native render awaiting document verification', + () async { + gateway.pendingRender = Completer(); + final task = restore(); + await flushSchoolEvents(); + expect(gateway.renders, 1); + recovery.reset(); + gateway.pendingRender!.complete(true); + expect(await task, FullPageRecoveryResult.cancelled); + expect(gateway.live, false); + expect(gateway.discards, 1); + }, + ); + test( + 'authentication pages and complete documents are not refetched', + () async { + expect( + await restore(authenticated: false), + FullPageRecoveryResult.unchanged, + ); + platform.web.javascript = (_) async => jsonEncode({ + 'scripts': 20, + 'styles': 4, + 'links': 4, + 'loaded': 4, + 'jquery': true, + 'beangle': true, + 'require': true, + 'script202': 0, + 'style202': 0, + }); + expect(await restore(), FullPageRecoveryResult.unchanged); + expect(gateway.requests, 0); + }, + ); + test( + 'logout and external or credential-bearing addresses never use the gateway', + () async { + for (final address in [ + 'https://eams.uestc.edu.cn/eams/logout.action', + 'https://other.test/eams/home.action', + 'https://eams.uestc.edu.cn/eams/home.action?ticket=PRIVATE_TICKET', + 'http://eams.uestc.edu.cn/eams/home.action', + ]) { + platform.web.url = address; + expect( + await recovery.tryRestore( + web, + Uri.parse(address), + authenticated: true, + documentKey: address, + isCurrent: () => true, + ), + FullPageRecoveryResult.unchanged, + ); + } + expect(gateway.requests, 0); + }, + ); +}