fix: recover authenticated undergraduate full pages with session guards
This commit is contained in:
@@ -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,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)。
|
||||||
|
|
||||||
|
|||||||
@@ -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。一个文档最多尝试一次、一次打开最多四次;暂存全局最多四项,后台读取两个工作线程和两个排队任务。失败保留当前页面,导入仍沿用原有确认与保存流程。
|
||||||
@@ -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的联调仍由用户复测。离线和模拟器结果不代表线上组件已确认恢复。
|
||||||
@@ -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
@@ -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`。
|
||||||
|
|
||||||
## 本科使用
|
## 本科使用
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+97
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user