fix: recover authenticated undergraduate full pages with session guards

This commit is contained in:
Codex
2026-10-15 16:39:27 +08:00
parent 83b0c7e177
commit 79d268fd5e
22 changed files with 1418 additions and 13 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
# TPSchedule # 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未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。 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+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
+1 -1
View File
@@ -1,6 +1,6 @@
# Android 首版验证记录 # 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)。 日期: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)。
+2 -2
View File
@@ -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)。 0.7.1补充学校浏览器宽视口、第三方Cookie和资源错误诊断,修复本科认证跳转导致读取永久中断的竞态。255项单元测试及Android16原生浏览器测试通过。用户截图中的样式与组件缺失尚需真机复测,不能仅凭测试认定已解决,详见 [0.7.1验证记录](tpschedule-0.7.1-validation.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。一个文档最多尝试一次、一次打开最多四次;暂存全局最多四项,后台读取两个工作线程和两个排队任务。失败保留当前页面,导入仍沿用原有确认与保存流程。
+17
View File
@@ -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的联调仍由用户复测。离线和模拟器结果不代表线上组件已确认恢复。
+33
View File
@@ -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。源码包由最终提交生成,不含原始学生附件、私钥或运行缓存。登记冻结及旧版安装包在交付打包时重新校验。
+2 -2
View File
@@ -1,8 +1,8 @@
# TPSchedule Flutter工程 # 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`。
## 本科使用 ## 本科使用
+2
View File
@@ -27,6 +27,7 @@ android {
// flag during build. // flag during build.
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
testInstrumentationRunner = "com.tpschedule.app.SchoolDocumentRenderInstrumentation"
} }
buildTypes { buildTypes {
@@ -40,6 +41,7 @@ android {
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
testImplementation("junit:junit:4.12")
} }
kotlin { kotlin {
@@ -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<String> 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,"<html><script>Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'fixture-old',enumerable:false})</script><body>old-public-fixture</body></html>","text/html","UTF-8",URL);
});
waitFor(loaded);
require(checkDocument("fixture-old"), "current_document_rejected");
loaded = new CountDownLatch(1);
runOnMainSync(() -> view.loadDataWithBaseURL(URL,"<html><script>Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'fixture-new',enumerable:false})</script><body>same-url-new-document</body></html>","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 = "<!doctype html><html><head><meta charset='UTF-8'><link rel='stylesheet' href='fixture.css'><script src='fixture.js'></script></head><body><p id='probe'>电子科技大学 • 课表 ✓</p></body></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);
}
}
@@ -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<T> {
private static final Semaphore SLOTS = new Semaphore(4);
private static final long TTL = 20000;
private static final class Entry<T> {
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<String,Entry<T>> 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<Entry<T>> 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<T> entry = entries.get(token);
return entry == null ? null : entry.value;
}
public T remove(String token) {
Entry<T> removed = entries.remove(token);
if (removed == null) return null;
SLOTS.release();
return removed.value;
}
public void removeView(long viewId) {
Iterator<Entry<T>> current = entries.values().iterator();
while (current.hasNext()) if (current.next().viewId == viewId) { current.remove(); SLOTS.release(); }
}
private void expire() {
long now = clock.getAsLong();
Iterator<Entry<T>> 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();
}
}
@@ -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<String> setCookies;
public Document(String html, List<String> 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<String> safeCookies(URI uri, Map<String,List<String>> headers) {
List<String> 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<String,List<String>> 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<String> cookiesForRender(String url, List<String> cookies) throws Failure {
return safeCookies(validateUrl(url), Collections.singletonMap("Set-Cookie", cookies));
}
}
@@ -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<Boolean> 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;
}
}
}
@@ -8,6 +8,7 @@ import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private var calendar: CalendarBridge? = null private var calendar: CalendarBridge? = null
private var reminders: ReminderSystemBridge? = null private var reminders: ReminderSystemBridge? = null
private var schoolDocuments: SchoolDocumentBridge? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -20,6 +21,13 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger) calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger)
reminders = ReminderSystemBridge(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<out String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
@@ -28,6 +36,7 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() { override fun onDestroy() {
calendar?.close() calendar?.close()
reminders?.close() reminders?.close()
schoolDocuments?.close()
super.onDestroy() super.onDestroy()
} }
} }
@@ -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<WebView>,
var document: SchoolDocumentReader.Document? = null,
)
private val ui = Handler(Looper.getMainLooper())
private val worker = ThreadPoolExecutor(
2, 2, 0L, TimeUnit.MILLISECONDS, ArrayBlockingQueue<Runnable>(2),
)
private val pending = SchoolDocumentPending<Candidate> { 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<Number>("viewId")?.toLong()
if (viewId != null) pending.removeView(viewId)
result.success(null)
}
"discard" -> {
call.argument<String>("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<Number>("viewId")?.toLong()
?: return fail(result, "unavailable")
val url = call.argument<String>("url") ?: return fail(result, "unsafe_url")
val documentToken = call.argument<String>("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<String>("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)
}
}
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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(); }
}
}
@@ -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<String> 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<String,List<String>> 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 ? "<div>课表</div>" : "<html><head><script src='jquery.js'></script><link rel='stylesheet' href='site.css'></head><body>课表</body></html>").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 = "<html>中文</html>".getBytes("GB18030");
assertEquals("<html>中文</html>", 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"));
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.7.2'; const appVersion = '0.7.3';
const appBuildNumber = 20; const appBuildNumber = 21;
@@ -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<SchoolPageCandidate> fetch(
WebViewController web,
Uri url,
String documentToken,
);
Future<bool> render(String token);
Future<void> discard(String token);
Future<void> 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<SchoolPageCandidate> fetch(
WebViewController web,
Uri url,
String documentToken,
) async {
final response = await _channel.invokeMapMethod<String, dynamic>('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<bool> render(String token) async =>
await _channel.invokeMethod<bool>('render', {'token': token}) == true;
@override
Future<void> discard(String token) =>
_channel.invokeMethod<void>('discard', {'token': token});
@override
Future<void> invalidate(WebViewController web) => _channel.invokeMethod<void>(
'invalidate',
{'viewId': (web.platform as AndroidWebViewController).webViewIdentifier},
);
}
class SchoolFullPageRecovery {
SchoolFullPageRecovery({SchoolFullPageGateway? gateway})
: gateway = gateway ?? const AndroidSchoolFullPageGateway();
final SchoolFullPageGateway gateway;
final _attempted = <String>{};
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'<head(?:\s|>)', 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<FullPageRecoveryResult> 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. */
}
}
}
}
}
@@ -13,9 +13,14 @@ import 'timetable_adapter.dart';
import 'exam_adapter.dart'; import 'exam_adapter.dart';
import '../school_browser.dart'; import '../school_browser.dart';
import '../school_browser_diagnostics.dart'; import '../school_browser_diagnostics.dart';
import '../school_full_page_recovery.dart';
class UndergraduateSession extends ChangeNotifier { class UndergraduateSession extends ChangeNotifier {
UndergraduateSession({this.configureBrowser = configureSchoolBrowser}); UndergraduateSession({
this.configureBrowser = configureSchoolBrowser,
SchoolFullPageRecovery? pageRecovery,
}) : _pageRecovery = pageRecovery ?? SchoolFullPageRecovery();
final SchoolFullPageRecovery _pageRecovery;
final SchoolBrowserConfigurator configureBrowser; final SchoolBrowserConfigurator configureBrowser;
Future<SchoolBrowserInfo>? _browserReady; Future<SchoolBrowserInfo>? _browserReady;
String? browserVersion; String? browserVersion;
@@ -28,6 +33,7 @@ class UndergraduateSession extends ChangeNotifier {
_connected = false, _connected = false,
_loading = false, _loading = false,
_manualCapture = false, _manualCapture = false,
_recoveringPage = false,
_failed = false, _failed = false,
_complete = false; _complete = false;
bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false; bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false;
@@ -44,7 +50,8 @@ class UndergraduateSession extends ChangeNotifier {
int get pageGeneration => _generation; int get pageGeneration => _generation;
bool get requestingExams => query != null; bool get requestingExams => query != null;
bool get canLogout => hasOpened || logoutFailed; bool get canLogout => hasOpened || logoutFailed;
bool get busy => _loading || _manualCapture || _itemTimeout != null; bool get busy =>
_loading || _manualCapture || _recoveringPage || _itemTimeout != null;
String get statusLabel => logoutFailed String get statusLabel => logoutFailed
? '退出未完成' ? '退出未完成'
: _failed : _failed
@@ -70,6 +77,8 @@ class UndergraduateSession extends ChangeNotifier {
void _reset() { void _reset() {
_generation++; _generation++;
_pageRecovery.reset();
_recoveringPage = false;
_cancelTimers(); _cancelTimers();
_parts.clear(); _parts.clear();
_index = 0; _index = 0;
@@ -123,6 +132,7 @@ class UndergraduateSession extends ChangeNotifier {
return NavigationDecision.navigate; return NavigationDecision.navigate;
}, },
onPageStarted: (url) { onPageStarted: (url) {
_pageRecovery.invalidateDocument();
DiagnosticLog.instance.navigation('undergraduate', 'started', url); DiagnosticLog.instance.navigation('undergraduate', 'started', url);
// Within a batch, type-to-type loads retain their generation. Once // Within a batch, type-to-type loads retain their generation. Once
// published, any new document invalidates the deferred save result. // published, any new document invalidates the deferred save result.
@@ -130,6 +140,7 @@ class UndergraduateSession extends ChangeNotifier {
_reset(); _reset();
} }
_document++; _document++;
_recoveringPage = false;
_poll?.cancel(); _poll?.cancel();
_probeKey = null; _probeKey = null;
pendingDraft = null; pendingDraft = null;
@@ -350,6 +361,37 @@ class UndergraduateSession extends ChangeNotifier {
} }
_connected = true; _connected = true;
awaitingAuthentication = false; 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) { if (!requestingExams) {
message = manualMessage; message = manualMessage;
_emit(); _emit();
@@ -438,7 +480,9 @@ class UndergraduateSession extends ChangeNotifier {
} }
Future<void> readCurrentTimetable() async { Future<void> readCurrentTimetable() async {
if (requestingExams || _manualCapture || !_active) return; if (requestingExams || _manualCapture || _recoveringPage || !_active) {
return;
}
final g = _generation, d = _document; final g = _generation, d = _document;
_manualCapture = true; _manualCapture = true;
_emit(); _emit();
@@ -535,6 +579,7 @@ class UndergraduateSession extends ChangeNotifier {
_disposed = true; _disposed = true;
_active = false; _active = false;
_generation++; _generation++;
_pageRecovery.reset();
_cancelTimers(); _cancelTimers();
super.dispose(); super.dispose();
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday name: hepan_everyday
description: TPSchedule 学生课表 description: TPSchedule 学生课表
publish_to: none publish_to: none
version: 0.7.2+20 version: 0.7.3+21
environment: environment:
sdk: ^3.13.5 sdk: ^3.13.5
dependencies: dependencies:
@@ -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.');
@@ -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 = '''<!doctype html><html><head>
<script src="/eams/static/jquery.js"></script>
<link rel="stylesheet" href="/eams/static/site.css"></head>
<body><a href="/eams/logout.action">退出</a>PRIVATE_PAGE_CONTENT</body></html>''';
const _fragment =
'<a href="/eams/logout.action">退出</a><script>jQuery(document);</script>';
class Gateway implements SchoolFullPageGateway {
int requests = 0, renders = 0, discards = 0;
String html = _complete;
Completer<SchoolPageCandidate>? pending;
Completer<bool>? pendingRender;
bool live = false;
void Function()? onRender;
@override
bool supports(WebViewController web) => true;
@override
Future<SchoolPageCandidate> fetch(
WebViewController web,
Uri uri,
String documentToken,
) async {
requests++;
live = true;
return pending == null
? SchoolPageCandidate('opaque-token', html)
: pending!.future;
}
@override
Future<bool> render(String token) async {
renders++;
if (pendingRender != null) {
return await pendingRender!.future && live;
}
final accepted = live;
onRender?.call();
return accepted;
}
@override
Future<void> discard(String token) async {
discards++;
}
@override
Future<void> 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<FullPageRecoveryResult> 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',
'<input type="password">',
),
_complete.replaceFirst(
'</head>',
'<base href="https://other.test/"></head>',
),
_complete.replaceFirst(
'</head>',
'<meta http-equiv="refresh" content="0;url=/login"></head>',
),
]) {
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<void>.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<SchoolPageCandidate>();
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<bool>();
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);
},
);
}