65 Commits
Author SHA1 Message Date
david1465833828 21b0df9e33 Preserve courses with pending rooms and report precise graduate import errors 2026-10-28 19:56:02 +08:00
david1465833828 8713bce0ee Prepare 0.7.9 with decisive Dock tap transitions 2026-10-28 19:37:04 +08:00
david1465833828 23c1a58ca3 Refine milky Dock and refract symbols beneath the moving lens 2026-10-28 17:56:40 +08:00
david1465833828 dc71e411e9 Fix cached reminder recovery and retain pre-reconcile idle diagnostics 2026-10-28 17:46:33 +08:00
david1465833828 93bcfd534c feat: bloom tap lens with dock follow and soft edge overflow 2026-10-28 12:47:47 +08:00
david1465833828 c5cfbced5c fix: keep dock drag springs live and add elastic leading-edge pull 2026-10-28 11:57:44 +08:00
david1465833828 6604f31d98 feat: strengthen dock spring motion, directional follow and milky glass 2026-10-28 11:28:51 +08:00
david1465833828 9da5864d29 feat: add elastic glass dock motion and Android tablet layouts 2026-10-28 11:09:43 +08:00
Codex fbcb7fb637 fix: initialize reminder channel and clarify notification permission diagnostics 2026-10-27 17:37:03 +08:00
Codex db953b8b3d refine: add milky translucency and curved reflections to Dock 2026-10-24 20:16:34 +08:00
Codex 8abb132bc3 docs: record completed 0.7.8 delivery verification 2026-10-24 15:16:00 +08:00
Codex a946aa6ec0 feat: extend optional HTML feedback to graduate imports in 0.7.8 2026-10-24 15:15:29 +08:00
Codex 9a198fe466 fix: preserve browser form origins for update panel authentication 2026-10-24 14:49:43 +08:00
Codex 2f11a8c904 fix: make update container scripts readable after restrictive ZIP extraction 2026-10-24 14:32:16 +08:00
Codex eb478a6e13 docs: explain creating the first 1Panel proxy site 2026-10-24 14:22:22 +08:00
Codex 75e0b5587d feat: add app update checks and standalone release publishing panel 2026-10-24 13:50:42 +08:00
Codex 437ed14dbe fix: import unpublished exam rooms and add opt-in school HTML feedback 2026-10-16 16:38:00 +08:00
Codex defe7fd50e fix: follow undergraduate login links and capture live history URLs 2026-10-15 17:17:54 +08:00
Codex 79d268fd5e fix: recover authenticated undergraduate full pages with session guards 2026-10-15 16:39:27 +08:00
Codex 83b0c7e177 Refine school script diagnostics and document-state capture 2026-10-15 15:15:42 +08:00
Codex a4c2df838e Fix school browser initialization and redirect detection; improve diagnostics 2026-10-15 12:23:35 +08:00
Codex 46e26ec548 docs: complete undergraduate implementation checklist 2026-10-15 10:43:05 +08:00
Codex cc75b626aa docs: record undergraduate release checks and diagnostic feedback review 2026-10-15 10:41:31 +08:00
Codex 7299f4d8b6 fix: harden school imports and add optional feedback diagnostics 2026-10-15 10:37:32 +08:00
Codex 674df1ceb3 feat: release undergraduate integration 0.7.0 2026-10-15 01:14:06 +08:00
Codex b28f333720 feat: integrate undergraduate school workflows and period twelve 2026-10-15 00:28:04 +08:00
Codex 7939bd4c3c feat: add undergraduate import and exam selection screens 2026-10-15 00:11:50 +08:00
Codex 68fe71e29a feat: connect undergraduate manual timetable and exam batches 2026-10-14 23:55:28 +08:00
Codex d101ffbf85 feat: capture undergraduate pages with scoped evidence 2026-10-14 23:40:15 +08:00
Codex ebe70e3eb2 feat: parse and merge undergraduate exam schedules 2026-10-14 23:35:30 +08:00
Codex 17e4903399 feat: parse undergraduate timetable activity data 2026-10-14 23:32:27 +08:00
Codex 141436f545 feat: keep undergraduate exams independent of course terms 2026-10-14 23:19:11 +08:00
Codex 48367b8273 feat: model undergraduate schools terms and exam queries 2026-10-14 23:14:48 +08:00
Codex 3f28e0a1b6 docs: plan UESTC undergraduate integration 2026-10-14 22:50:28 +08:00
Codex 09c94d52ef docs: design UESTC undergraduate connection 2026-10-14 22:34:53 +08:00
Codex d83481d965 feat: select school before connecting timetable 2026-10-14 20:58:47 +08:00
Codex ce21cdc10c Render startup immediately and defer native work with a smooth home transition 2026-10-14 17:26:24 +08:00
Codex bb7a7a2e58 Document SafeLine and 1Panel feedback deployment on internal ports 2026-10-14 16:13:41 +08:00
Codex 1f06e3741e Add feedback service, reminder diagnostics and smooth week transitions 2026-10-14 15:53:21 +08:00
Codex c5e736b39b Add luminous Dock icon halos for clearer glass contrast 2026-10-14 12:29:31 +08:00
Codex 4d86db3dd5 Fix release notification resource and add multiple advance reminders 2026-10-14 11:30:37 +08:00
Codex caf7abe0c0 Make compact Dock persistent and support week swipes 2026-10-14 09:49:53 +08:00
Codex fe23c65fd9 Add Dock captions and subtle blue icon glow 2026-10-14 09:22:24 +08:00
Codex 499c6a905e Refine reference liquid glass and resting grey cursor 2026-10-14 08:52:33 +08:00
Codex 57eb2c0505 Fix duplicate launch screen and native splash exit flashing 2026-10-13 22:52:25 +08:00
Codex 204e945b2e Rebrand as TPSchedule and refine clear liquid glass dock 2026-10-13 22:08:20 +08:00
Codex 50fcc70e8e feat: select school terms and smooth refractive Dock motion 2026-10-13 19:08:36 +08:00
Codex 3e2d8864af fix: use blue filled Dock icons on gray glass 2026-10-13 18:00:32 +08:00
Codex 476ef097ee fix: automate graduate timetable read and refine blue glass Dock 2026-10-13 17:50:04 +08:00
Codex 8f20a341c4 feat: deliver RSchedule service login exams and elastic glass navigation 2026-10-13 17:06:50 +08:00
Codex 9193fd8e43 feat: add course bar length setting and clear blue glass Dock 2026-10-13 12:51:27 +08:00
Codex 8403be05da feat: deliver Riverside Schedule seven-day grid and school HTML import 2026-10-13 11:18:50 +08:00
Codex 91a2522043 docs: record verified local integration into master 2026-10-13 10:18:57 +08:00
Codex e4d82387c4 docs: record final Android verification and delivery 2026-10-13 09:35:03 +08:00
Codex da5b8076a0 fix: preserve queued settings and recover interrupted or corrupt state 2026-10-13 09:32:50 +08:00
Codex 5ceab20b67 feat: compose Android app and verify native calendar and reminders 2026-10-13 09:14:47 +08:00
Codex 2c4256b880 feat: add local reminders and owned calendar synchronization 2026-10-13 00:11:48 +08:00
Codex ffd3920c0d feat: add school authentication entry with guarded import 2026-10-12 23:50:52 +08:00
Codex 8a98d49d12 feat: add two-tab timetable UI and glass dock 2026-10-12 23:33:33 +08:00
Codex 808af007a2 feat: persist timetable and validate confirmed imports 2026-10-12 23:18:51 +08:00
Codex ae8e347e49 feat: add validated semester and lesson calculations 2026-10-12 23:13:02 +08:00
Codex 36d18f029f docs: pin reminder identity and calendar permission contracts 2026-10-12 22:56:43 +08:00
Codex 3e62f470c6 docs: confirm design and add Android implementation plan 2026-10-12 22:55:39 +08:00
Codex 7f5c6df9e6 docs: record verified Android toolchain and emulator setup 2026-10-12 22:44:08 +08:00
Codex a223798c25 docs: record approved app direction and Android setup scripts 2026-10-12 22:30:13 +08:00
314 changed files with 30964 additions and 7 deletions
+16 -7
View File
@@ -1,7 +1,16 @@
/output/
/反馈/
.env
.env.*
!.env.example
key.properties
local.properties
.tools/
.dart_tool/
.flutter-plugins-dependencies
build/
android/local.properties
*.jks
*.keystore
*.log
.superpowers/
dist/
artifacts/*.xml
artifacts/emulator-*.txt
artifacts/screenshots/probe-state*.png
artifacts/reference-video/
artifacts/rschedule-gles-probe.apk
+3
View File
@@ -0,0 +1,3 @@
{
"dart.flutterSdkPath": ".tools/flutter"
}
+80
View File
@@ -0,0 +1,80 @@
# TPSchedule
当前Android版本0.7.10+36:缺教室保留课程并标为待定,研究生增加格式兼容和具体错误诊断,见[本次记录](docs/tpschedule-0.7.10-import-diagnostics.md)。构建35:点按更快到位,缩短回弹和页面过渡,见[发布记录](docs/tpschedule-0.7.9-release.md)。构建34:未选中灰色、选中蓝色,增强乳白透射与滑块对图标本身的折射,见[本次记录](docs/tpschedule-0.7.8-dock-symbol-optics.md)。构建33:修复缓存仍在但系统触发信息丢失时跳过提醒重建的问题,保留启动/暖启动恢复前的诊断;Android 16深度Doze及rare分组测试已实际投递,vivo几小时长待机根因仍待新诊断确认。见[本次记录](docs/tpschedule-0.7.8-reminder-idle.md)。构建32:点按先放大再弹跳、底座方向跟随、边缘外溢及柔软光泽,见[本次记录](docs/tpschedule-0.7.8-dock-bloom.md)。构建31提供滑块移动中持续Q弹、前缘牵拉和弹性跟随,玻璃材质减薄,见[本次记录](docs/tpschedule-0.7.8-dock-drag.md)。构建30提供增强点按挤压回弹、整条Dock随拖动方向弹动、图标到位动画及乳白折射光泽,见[本次记录](docs/tpschedule-0.7.8-dock-spring.md)。构建29提供Dock点按/拖动弹性与Android平板横竖屏、分屏适配,见[本次验证记录](docs/tpschedule-0.7.8-dock-tablet.md)。构建28已提前建立通知类别、区分通知总开关和类别状态,见[通知排查记录](docs/tpschedule-0.7.8-notification-permissions.md)。用户手机已通过开启通知权限并重测确认恢复后台通知。Dock乳白透光层和边缘折射继续保留,见[材质优化记录](docs/tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈见[0.7.8功能记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务说明](server/feedback/html-diagnostic-update.md),更新管理后台见[部署说明](server/updates/README.md)。
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.10+36,Android 7.0 及以上。
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
三个标签页:课表、考试、个人设置。界面统一蓝色,三个页面标题对齐。透明蓝色液态玻璃风格悬浮 Dock 为264×58,选中滑块为灰色玻璃,图标为实心蓝色,静止边距为3。支持点击图标或拖动选中滑块切换;按压和拖动时放大、松手弹簧回弹,支持的渲染模式启用实时背景折射。页面切换采用短距离移动与淡入淡出,并保留每页滚动位置。Dock在三个页面常驻,上下滚动均保持可见;课表左右滑动切换周次,不切换标签页。系统“减少动画”开启时减少切页与回弹动画。
课表周一至周日固定七列同屏,无横向滚动。同节数课程条等长;课程名可省略,老师和教室完整换行,点击查看全部信息。课程条默认长度125%,在「个人设置 → 课表设置 → 课程条长度」调节80%–200%。课前提醒、手机日历及学期设置继续保留。
0.5.2按新的Apple Music参考图调整Dock:底座保持通透,增强边缘折射并收窄柔化亮边;静止滑块为明确的半透明中性灰,按住时过渡回原有通透玻璃。滑动时的放大比例、弹簧参数和点击切换逻辑不变。蓝色实心图标保留。详见[0.5.2验证记录](docs/tpschedule-0.5.2-validation.md)。
0.5.3为蓝色图标增加轻微柔光,下方显示“课表、考试、我的”。文字可随系统字号放大,并在Dock固定尺寸内自动适配。灰色静止滑块、按压放大和回弹保持。详见[0.5.3验证记录](docs/tpschedule-0.5.3-validation.md)。
0.5.4主要缩小Dock高度,由68降至58,宽度保持264。Dock改为常驻。课表向左滑动下一周、向右滑动上一周,一次手势只换一周,限制在设置的学期周数内;箭头和“本周”按钮保留。未设置学期开始日时仍提示设置。详见[0.5.4验证记录](docs/tpschedule-0.5.4-validation.md)。
0.5.5修复发布包裁剪通知图标导致提醒初始化失败的问题。课前提醒可多选5/10/15/30/60分钟,各时间点独立安排通知;旧单选设置自动保留,至少选择一项。设置页显示待提醒次数、最近提醒时刻以及通知类别/精确提醒权限状态。详见[0.5.5验证记录](docs/tpschedule-0.5.5-validation.md)。
0.5.6为Dock图标增加蓝色柔光与浅色轮廓光晕,提高玻璃背景上的辨识度;未选中图标也增强蓝色填充。Dock尺寸、灰色静止滑块、按压回弹和文字保留。详见[0.5.6验证记录](docs/tpschedule-0.5.6-validation.md)。
0.6.0将Dock光晕改为白光,换周增加过渡,修复通知查询失败阻断提醒,并增加通知测试与诊断。我的页面新增反馈与共建。服务器仍需配置部署,详见[0.6.0验证记录](docs/tpschedule-0.6.0-validation.md)与[中文1Panel部署说明](server/feedback/部署说明.md)。
0.6.1优化启动:先绘制与原生开屏衔接的品牌帧,读取本地课表后以300ms过渡进入首页,通知与日历随后同步;学校浏览器首次访问时才初始化,后台返回不重播开屏。基础和旧版Android开屏资源统一蓝底与图标尺寸。详见[0.6.1启动验证记录](docs/tpschedule-0.6.1-validation.md)。
0.6.2将学校连接入口改为「选择学校」,目前提供「电子科技大学(研究生)」。选中后沿用原认证及导入,取消不开始登录;旧学校学期和连接刷新继续复用。详见[0.6.2学校选择验证记录](docs/tpschedule-0.6.2-validation.md)。
0.7.0新增「电子科技大学(本科生)」。本科课表由学生在学校网页选择学期、打开课表,再点击「导入当前课表」;考试支持一个学期及多种考试类型整批读取。查询历史本科考试保留当前课表。本科12节,第12节22:00–22:45;研究生及示例仍为11节。学校选择与成功查询选项持久保存,刷新沿用;详见[本科接入验证记录](docs/tpschedule-0.7.0-validation.md)。
0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。
## 安装与学校登录
安装 `dist/TPSchedule.apk`(release运行模式),或使用 `dist/TPSchedule-debug.apk` 测试。Android包名已按用户确认改为 `com.tpschedule.app`,显示名称为TPSchedule。它与旧 `com.example.hepan_everyday` 是两个独立应用,不能覆盖安装旧RSchedule;旧应用与数据保留,新应用需要重新导入课程并设置日期。
原签名密钥及签名配置不改,新旧 APK 证书及公钥会核对一致。保留 `.tools/android-user/debug.keystore`,不要删除、重建或随源码分发。当前保留的证书是Android Debug类型,release运行模式不代表更换为正式发行证书;提交商店/备案前须确认目标平台是否接受。`dist/TPSchedule-signing-info.json`、证书PEM及公钥PEM只含公开签名信息。
在「我的」点击「选择学校」,可选择「电子科技大学(研究生)」或「电子科技大学(本科生)」。研究生沿用[研究生系统专用认证入口](https://yjsjy.uestc.edu.cn/pyxx/jzsso/login),认证并选择学期后自动查找课表。本科生进入[本科教务系统](https://eams.uestc.edu.cn/eams/),自行在网页选择学期并打开课表,再点击「导入当前课表」;考试则在APP选择学期和一种或多种考试类型后自动读取。账号、密码和验证码均在学校原网页填写,应用不读取或注入密码。取消学校选择不开始登录,也不改已有课表;成功查询的学校及选项会保存在本机供刷新使用。
学校目录集中在 `mobile/lib/features/school/school_selection_page.dart`,当前只列出已接入的学校。学校与学期有明确的来源标记,本科独立认证和解析模块位于 `mobile/lib/features/school/undergraduate/`;以后新增学校需增加相应认证和课表/考试解析适配,再加入目录。
研究生学期73为2026-2027年秋、72为2025-2026年春、71为2025-2026年秋。课表路径为 `/pyxx/pygl/xskbcx/index/{学期编号}`;考试路径为 `/pyxx/pygl/kskcxx/index?xq={学期编号}`。本科学期523为2026-2027第一学期,503为2025-2026第二学期,相邻学期编号相差20;考试路径为 `/eams/stdExamTable!examTable.action?semester.id={编号}&examType.id={类型}`,类型1期末、2期中、3补考缓考。本科考试刷新沿用上次成功的学期及类型。「个人设置 → 更新学期」按所选学校进入相应流程。读取失败、取消或保存失败不改原数据。
研究生同源数据沿用原有跨学期更新规则;本科考试查询独立保存,不清空当前课表和学期开始日。更换课表来源或学期后需重新设置开始日,同学期刷新保留日期。课表页在已导入但未设置开始日时,显示课程数量及“设置学期开始日”入口,直接进入日期设置。页面标题分别为课表、考试、TPSchedule。
专用入口的原始302响应指向 HTTP 身份认证地址,携带研究生系统的 `service` 参数。应用将该身份认证地址转到 HTTPS 并完整保留参数。学校 HTTP 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。
真实账户认证及验证码仍需由本人操作验证;离线 HTML 与模拟回调测试不能代替真实账户联调。详见 [学校接入状态](docs/school-integration-status.md)。
## 考试安排
考试页右上角可从学校更新,或导入学校考试查询页保存的 HTML。查询路径由用户提供的导出页面确认。考试数据独立保存,同学期更新不覆盖课表,也不产生考试提醒。显示课程名称、考试时间、考场、座位号;有考核方式、考生编号或考场顺序号时一并显示。
提供的考试 HTML 含2026-2027秋学期8门课程,排考字段全部为空。当前显示“尚未安排”,不猜测考试时间和地点。未知或不完整表格拒绝导入;只有明确的无数据提示才接受空列表。原始附件与其中的学号、姓名不随源码或 APK 分发。
## 文件导入和设置
「个人设置 → 从文件导入」支持学校课表 `.html` / `.htm` 与版本1的 JSON,先预览再确认;考试文件从考试页导入。文件最多2MB,UTF-8编码;其中脚本不会执行。
HTML没有学期开始日,仍须在课表设置填写实际开始日和学期周数。离线文件只有保存时那个学期的数据,预览显示实际学期;其他学期通过学校在线查询获取。JSON 支持可选 `schoolTerm: {"id": 73}` 并兼容旧课表编号推断,不允许混合学校学期。默认课前15分钟提醒,可多选5/10/15/30/60分钟。启用通知和手机日历时按提示授权。JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例课表不覆盖本人数据,不创建通知或日历事件。
0.4.0 修复点按滑块重复回弹及速度形变:点按平滑移动,拖动和按住时放大约30%,松手弹性回位。0.5.0让Dock底座和滑块呈现较通透的液态玻璃,加入实时边缘反光,降低模糊和灰色覆盖,让背景轮廓与色彩更清楚。支持的渲染方式组合折射与轻度高斯模糊,其他方式保留轻度背景模糊和玻璃亮边。详见 [0.5.0验证记录](docs/tpschedule-0.5-validation.md)。
## 开发
源码在 `mobile/`;工具在 `.tools/`,不提交到 Git。Android包名 `com.tpschedule.app`。内部Dart包名 `hepan_everyday`、数据格式和原生通道标识保持不变。构建继续使用原有签名文件。
```powershell
$env:HEPAN_TOOLCHAIN_ROOT='C:/Users/david/Desktop/app/.tools'
. ./scripts/android-env.ps1
Set-Location mobile
flutter pub get
flutter test
flutter analyze
```
仓库根目录运行 `./scripts/build-android.ps1` 构建。验证记录:[0.5.1启动闪烁修复](docs/tpschedule-0.5.1-validation.md)、[0.3.2蓝色图标修正](docs/rschedule-0.3.2-validation.md)、[0.3.1自动读取与玻璃动画修正](docs/rschedule-0.3.1-validation.md)、[0.3.0登录、考试和动画](docs/rschedule-0.3-validation.md)。Windows模拟器可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留手机默认渲染配置。历史验证:[0.2.1](docs/blue-glass-and-course-length.md)、[0.2.0](docs/riverside-revision-validation.md)、[首版](docs/android-mvp-validation.md)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

+39
View File
@@ -0,0 +1,39 @@
# Android 首版验证记录
当前版本为0.7.10+36,导入日志定位及修复见[本次记录](tpschedule-0.7.10-import-diagnostics.md)。构建35的点按节奏及发布验证见[本次记录](tpschedule-0.7.9-release.md)。构建34的灰蓝导航、乳白透射及图标折射见[本次记录](tpschedule-0.7.8-dock-symbol-optics.md)。构建33的提醒恢复与待机诊断见[本次记录](tpschedule-0.7.8-reminder-idle.md)。构建32的点按放大、外溢及柔软材质见[本次记录](tpschedule-0.7.8-dock-bloom.md)。构建31的移动中Q弹、牵拉及减薄见[本次记录](tpschedule-0.7.8-dock-drag.md)。构建30的点按/方向弹动及乳白材质见[本次记录](tpschedule-0.7.8-dock-spring.md)。构建29的Dock弹性与Android平板验证见[本次记录](tpschedule-0.7.8-dock-tablet.md)。构建28的通知权限恢复、通道初始化及真实后台触发见[通知记录](tpschedule-0.7.8-notification-permissions.md)。Dock乳白透光材质见[优化记录](tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈及签名、备案信息见[0.7.8功能记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-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)。
## 已验证
- `flutter analyze`:No issues found。
- `flutter test`:58 项通过,包括 11 节时间、上海时区和非周一开始日、不连续周次、导入拒绝保留原课表、存储损坏恢复、示例隔离、通知去重和权限降级、日历中断恢复与归属检查、200% 字体和重叠课程、通知点击详情。
- `integration_test/app_flow_test.dart` 在 Android 模拟器通过:两页导航、单色设置、实际日期选择器和学期周数保存、导入预览取消与确认、示例不改真实保存文件、错误导入保持数据、重开恢复。
- `integration_test/calendar_bridge_test.dart` 在专用 Hepan_Integration_Test 日历通过:创建、再次同步更新同一 ID、映射丢失后找回、错误 key 无法删除、删除自有事件。设备实际查询表明 Unrelated_fixture 事件保留,CalendarContract.Reminders 无记录。
- 首次真实 APK 构建、安装、启动成功。初次编译下载依赖用时约 695 秒,后续构建约 40 秒。
- 短时通知使用独立测试入口 tool/reminder_probe.dart,不写入用户课表文件。应用退到后台后系统收到通知,实际标题及内容包含测试课程、教师和教室;点击通知打开对应课程详情。
## 补充设备结果
- 模拟器重启后:未重新打开 APP,系统仍送出已安排的测试通知;启动正式 APP 后,空课表协调取消残留自有通知。
- 已重建并安装 lib/main.dart 正式入口 APK,核实包名、0.1.0+1、API 24/36 和三种 Android 架构。示例课表、我的、周日、末节、设置和 200% 字体已有设备截图;验证 APK 不包含测试入口。
- 已完成一次独立代码审查:无 Critical/Minor,三项 Important 均用回归测试修复。58 项测试全通过,未进行重复审查。
## 仍需本人手机验证
- 本人学校登录、验证码、二次验证、真实研究生课表解析。目前解析器明确提示待联调,保护已有课表;见 school-integration-status.md。
- 真实手机的厂商省电、锁屏通知、长时间后台与真机重启行为;模拟器结果不能代替真机。
- 不同手机日历应用与云账户对自定义归属标记的同步行为。
- iOS 尚未配置及验证。
## 本轮修复和设备异常
日历设备测试复现 Calendar Provider 不允许“事件单条 URI + selection”操作;改为集合 URI 加事件 ID、应用包和实例标记条件,原设备测试随后通过。首次设备界面测试时模拟器连接中断;重新启动并使用软件图形模式后同项测试通过。首次权限检查未通过来自测试框架重装应用;本轮单独授权后完成系统日历测试。
通知初始化暂时失败后可重试,日历服务异常在设置中解释并保持未启用。首次同步中断、尚无保存 ID 时,通过选定日历的专属标记恢复清理;清理失败持久保存待重试状态。
补充:系统文件选择器已从 Downloads 选择随附 JSON,正常显示 3 条课程预览和示例来源;飞行模式下正式 APP 重开后页面正常,示例体验未写成真实课程。
审查修复:设置改为按字段在最新状态上修改;首次日历写入前保存未完成状态,清理成功才允许更换目标;没有可用备份时提供确认恢复入口,真实文件测试验证损坏主文件和备份均先归档。
最终 APK:229736269 字节,SHA-256 FC61132FB0CD2881A300BB1A2D7ABB9994EDF54E369C65E1FB9E285FB42AA114。修复后重新构建、安装、启动,原生页面同时存在课表和我的两个标签入口;受影响 app_flow 设备测试重新通过。
本地合并:已按用户选择将源码合并回桌面项目 master,合并后重新运行 58 项测试全部通过,flutter analyze 无问题;临时开发目录已归档,开发分支已清理。
+49
View File
@@ -0,0 +1,49 @@
# Android 环境配置结果
验证日期:2026-10-12(Asia/Shanghai)。本轮完成开发环境配置,业务代码与 APK 尚未生成。
## 已完成
|组件|结果|
|---|---|
|Flutter / Dart|Flutter 3.47.7 stable,Dart 3.13.5,版本命令通过|
|Java|Microsoft OpenJDK 17.0.20.1,已由 Flutter 指定为构建 JDK|
|Android 平台|API 36,Build Tools 36.0.0,Platform Tools 37.0.1|
|Android 命令行工具|22.0;新版官方 Android CLI 已初始化,用于 SDK 下载|
|原生构建工具|NDK 28.2.13676358、CMake 3.22.1,与当前 Flutter 配套|
|SDK 许可|所需 Android SDK 许可均已接受|
|Flutter Android 检查|`flutter doctor -v` 的 Android toolchain 项通过|
|模拟器|`hepan_everyday`,Pixel 7,Android 16 / API 36,Google APIs x86_64|
|硬件加速|WHPX 已安装且可用|
|模拟器启动|系统启动成功,`sys.boot_completed=1`;读取系统版本为 16、SDK 为 36|
|构建仓库|成功下载 Google 官方仓库的 Android Gradle Plugin 9.1.0 描述文件|
Flutter、Java、Android 命令行工具初始安装包均通过 SHA-256 校验。SDK 组件通过 Google 官方 Android CLI 安装。项目工具、Gradle/Pub 缓存、AVD 数据主要放在项目 `.tools/` 内,已从版本控制排除;Flutter 保存了指向这些目录的用户级路径配置。
## 使用
在项目目录打开 PowerShell,激活本终端的开发环境:
```powershell
. ./scripts/android-env.ps1
flutter doctor -v
```
启动已创建的模拟器:
```powershell
. ./scripts/android-env.ps1
emulator -avd hepan_everyday
```
模拟器验证结束后已关闭;下次使用时手动启动即可。当前没有连接 Android 真机。
## 已观察到的其他检查结果
完整 Flutter doctor 还提示未安装 Windows 桌面开发所需的 Visual Studio;本项目现阶段以 Android 为目标。
Flutter doctor 对 `https://maven.google.com/` 的连通性探测超时。直接从 Android 构建实际使用的 `https://dl.google.com/dl/android/maven2/` 成功下载了 AGP 9.1.0 描述文件。后续仍需以真实 APK 构建验证全部 Gradle 依赖下载,不能据此宣称 APK 已能成功构建。
首次创建 AVD 时,旧 avdmanager 提示系统镜像目录的可选 `devices.xml` 不存在;Pixel 7 内置设备配置仍成功创建,随后通过了实际系统启动验证。
完整本地诊断日志位于 `.tools/flutter-doctor.txt`、`.tools/android-sdk-install.txt`、`.tools/emulator-stdout.txt` 和 `.tools/emulator-stderr.txt`。
+7
View File
@@ -0,0 +1,7 @@
# 0.2.1 透明蓝色Dock和课程条长度
2026-10-13:Dock底座及选中游标改成低透明度白色/蓝色玻璃,移除黑灰色渐变和黑色投影;底座与游标使用极低填充透明度和轻微背景模糊;通过双层光学边缘、方向性高光与游标拖动形变表现玻璃厚度,避免磨砂填充。保留图标点击和游标拖动。
新增「我的 → 课表设置 → 课程条长度」:默认125%,可调80%–200%,松开滑块后保存,一键恢复默认。统一调整节次行高,相同节数课程条等长;老师和教室的完整显示优先于过短的设置。提醒时间和日历日程不因外观调节而重新生成。
新增测试覆盖旧数据默认值迁移、非法长度拒绝、实际文件保存后重读、滑块调节/控制器重开、条形实际变长且等长、七天同屏及不触发通知/日历重排。完整79项测试通过,静态检查无问题。安卓外观与设置保存验证截图位于dist/screenshots。
+37
View File
@@ -0,0 +1,37 @@
# SDD ledger — plan: docs/superpowers/plans/2026-10-12-hepan-everyday-android.md
Execution: inline, approved by user. Base: 36d18f0.
Pre-flight: Tasks 1→2→3 share domain, snapshot and controller; names consistent.
Pre-flight: Tasks 2→4 import drafts; unknown school pages fail closed, demo stays isolated.
Pre-flight: Tasks 2→5 NotificationService; permission request separate from reconciliation.
Pre-flight: Tasks 2→6 CalendarService; ownership and retry mapping required.
Pre-flight: Tasks 3→7 UI/integration; file fixture uses explicit demo flag.
Ruling: PowerShell ledger and extracted briefs replace Bash helper scripts — only WSL shim exists and native Windows execution is available — tracking remains equivalent; cost if wrong: missing process record, not product behavior.
Task 1: in_progress (base 36d18f0).
Task 1: complete (base 36d18f0; RED 7 expected failures; GREEN flutter test 8/8; analyze clean).
Task 2: in_progress (base ae8e347).
Task 2: complete (RED 13 expected failures; GREEN flutter test 21/21; analyze clean).
Task 3: in_progress (base 808af00).
Correction: Task 2 analyze had 9 style infos; dart fix applied and Task 3 verifies current analyzer.
Ruling: Task 3 emulator visual review is grouped with Task 7 after real platform adapters exist — avoids a production placeholder adapter — cost if wrong: visual issues are found later, before delivery.
Task 3: complete (RED navigation 4, overlap 1, recovery 2; GREEN full suite 28/28; device visual deferred to Task 7 by ruling).
Task 4: in_progress (base 8a98d49).
Task 4: complete (RED unsafe domain/layout 3, concurrent read 1, edge recovery/frame 2; GREEN full suite 34/34; analyze clean. Real authenticated timetable parser remains explicitly pending per spec.)
Task 5: in_progress (base ffd3920).
Task 5/6: Ruling: native notification and calendar checks consolidated in Task 7 after real startup assembly — avoids temporary production adapters — cost if wrong: native failures found later.
Task 6: Ruling: per-event save/delete gateway replaces native syncEvents batch — keeps reconciliation and recovery testable in Dart, native checks ownership for every mutation — cost if wrong: extra platform calls for large terms.
Task 5: complete (local notification tests 6 passed, combined suite 48 passed; analyzer clean; native checks deferred by ruling).
Task 6: complete (calendar and controller RED witnessed; combined suite 48 passed; analyzer clean; native verification remains Task 7).
Correction: previous commit attempts failed due to missing local author identity. Tasks 5 and 6 committed together using existing Codex <codex@local> author; analyzer confirmed No issues found after completion labels.
Task 7: in_progress; real startup assembled, startup notification-tap RED→GREEN, initialization retry and calendar UI error RED→GREEN. First actual APK built and installed successfully; native device verification underway.
Task 7: complete (51 tests pass; analyze clean; Android UI flow and native calendar integration pass; background notification, tap details, reboot restoration and startup cleanup observed; formal main APK rebuilt and installed, badging verified; review follows).
Final: Ruling: updateSettings queues field transformations instead of complete settings values — fixes stale queued values undoing previous choices — cost if wrong: consumers must use the new callback signature.
Final: Ruling: real school parser remains conditional on unavailable authenticated fixture — approved spec protects old timetable and explicitly marks pending — cost if wrong: automatic school import is unavailable in this MVP.
Final: Ruling: iOS packaging remains deferred — user selected Android first — cost if wrong: cannot install on iPhone yet.
Final: Ruling: physical-device power management and cloud calendar marker behavior remain device-validation work — emulator cannot establish those results — cost if wrong: notifications or calendar synchronization may vary on specific phones/accounts.
Final review: fresh gpt-6-astra reviewer; no Critical or Minor; three Important findings regraded as Important and fixed in one pass. No re-review required; regression tests govern fix completion.
Final: fixed queued whole settings undoing reminder disable — queued distinct-field regression RED→GREEN, suite 58/58.
Final: fixed inaccessible partial calendar recovery and destination loss — partial sync/reopen/new calendar regression RED→GREEN; working cleanup button tested, suite 58/58.
Final: fixed no-backup corrupt storage lockout — missing/corrupt backup confirmation UI RED→GREEN; real filesystem archive/save tests pass, suite 58/58.
Final verification: affected Android app_flow integration rerun passes after review fixes; full suite 58/58 and analyze No issues found.
Delivery: rebuilt final main APK after fixes; native install smoke and APK badging passed. No deferred minors. Managed worktree and named branch preserved pending integration choice.
Local integration completed on 2026-10-13 at user request: master fast-forwarded to e4d8238; merged checkout passed all 58 tests and flutter analyze. Managed worktree archived with a recoverable snapshot; merged feature branch removed. No remote configured, so local integration required no fetch or push. APK hashes matched before cleanup.
+23
View File
@@ -0,0 +1,23 @@
# Riverside Schedule 0.2.0+2 改版验证
日期:2026-10-13。Android 包名保持 com.example.hepan_everyday,显示名称 Riverside Schedule。
## 自动化验证
- 72项单元及组件测试通过;静态检查无问题。
- 360px屏幕的七天同屏、100%和200%字体完整老师/教室、相同节数卡片等长、课程详情完整字段。
- 320px和200%字体下5条冲突安排使用全宽明确节次列表,文字不裁切、列表不互相覆盖。
- Dock图标点击、选中玻璃游标双向拖动、取消拖动恢复、页面横向滑动不换页。
- 实际提供HTML作为本机验证输入:7门课程、11条安排;周次合并、晚间9–11节、多教师。
- 不完整表格、错误星期/节次/周次、空页面拒绝成功导入;文件来源不冒充实时学校登录;读取取消和来源边界。
- 原有存储恢复、设置队列、通知及日历服务回归测试保留。
## 安卓人工验证
API36模拟器:选择学校HTML → 11条安排预览 → 确认导入 → 设置测试学期开始日 → 七列课表。点击省略课程名的详情显示全称、全部老师、教室、节次及周次。Dock双向拖动切页,页面横向滑动保持当前页。
测试用日期2026-10-12仅用于模拟器展示,不是推断的真实学期开始日。安装包中不预置本人课程或这个测试日期。
Windows模拟器默认图形路径出现qemu进程访问冲突;验证时仅通过启动参数关闭Impeller并使用软件渲染。交付APK未改变手机默认渲染配置,仍需实际安卓手机外观和实时学校登录验收。
截图位于交付目录dist/screenshots。最终APK、SHA256与源码包见dist/README.md。
+34
View File
@@ -0,0 +1,34 @@
# RSchedule 0.3.0+4 验证记录
日期:2026-10-13。沿用原包名和调试签名,支持Android 7.0及以上。
## 改动
1. 专用研究生SSO入口,移除自绘账号密码表单及凭证注入。保持学校网页验证,返回研究生系统后查找课表并自动保存。
2. 修复学校专用入口的实际HTTP身份认证跳转:升级为HTTPS,保留研究生service参数。Android只为准确的研究生域名允许HTTP回调。
3. 新增考试查询和HTML导入,单独存储考试列表。真实附件8门课程,所有排考字段为空,显示“尚未安排”。
4. 应用名RSchedule;三个图标标签;切页短距离移动与淡入淡出,保留页面状态,没有页面横向切换手势。
5. 透明蓝色玻璃Dock;选中滑块按压放大、拖动拉伸、弹簧回弹。课表和考试页向下滚动隐藏,向上恢复。
6. 旧读取、旧网络错误、旧保存确认均校验当前会话。退出与新打开串行处理,清理失败在个人页显示并保留退出重试按钮。
## 自动验证
- 全套93项测试通过,包含两份实际学校HTML的本机可选验证;flutter analyze无问题。
- 回归覆盖:service重定向、HTTP身份认证升级、验证码期间等待、认证返回后读取、过期网络错误、退出与重开竞争、原生退出失败恢复、失败提示与重试按钮、旧保存确认不清除新草稿、残缺考试表拒绝导入。
- 七天同屏、老师和教室完整显示、等节数条长一致、长度设置保存、字体放大、Dock拖动与取消、页面横向手势不切页等原有检查继续通过。
## Android实测
- API36测试设备覆盖安装成功,显示RSchedule,versionName0.3.0/versionCode4。
- 已从系统文件选择器导入真实考试HTML:8门课程、2026-2027秋,无排考信息;重启后8条记录仍在,原11条课程安排和125%长度设置保留。
- 向下滚动后Dock图标从可访问界面中消失;向上滚动后三个图标恢复。点击可切换三页。
- 专用入口实际302响应已用HTTP客户端核对。安卓WebView成功显示学校原身份认证页面、账号/密码栏和登录按钮,未填写个人凭据。
- 截图位于dist/screenshots/rschedule-week.png、rschedule-exams.png、rschedule-school-login.png。
## 边界
真实本人账号、验证码、二次验证及登录后的真实自动导入未进行账号端到端验证;仅学校入口实测、真实HTML和会话回调测试已验证。请在手机上完成学校认证后检查导入结果。
玻璃视觉由Flutter背景模糊、透明渐变、双层高光边缘和弹簧形变实现,是参考视频的跨平台效果,不是苹果原生液态玻璃接口或物理折射实现。Windows模拟器测试时使用软件渲染并关闭Impeller,交付APK使用手机默认渲染。
原始HTML、视频、学号和姓名没有写入源码或安装包,个人数据仅用于本机附件验证。
+38
View File
@@ -0,0 +1,38 @@
# RSchedule 0.3.1+5 · 自动读取与玻璃动画修正
2026-10-13。
## 登录自动读取
原判断假设真实研究生系统存在href为/pyxx/home/exit的退出链接。两个实际附件均没有该链接;实际使用window.logout()函数,且有navbar与main-container页面节点。旧版因此未触发自动跳转。
新流程在学生完成统一认证后检测真实研究生页面结构和logout函数,自动进入课表查询、等待表格就绪并导入。支持/pyxx根首页、HTTP研究生回调、异步表格和同源内嵌页面;每750ms检测,45秒内有界等待。学生只需完成学校登录和验证码,不需要手动进入课表。读取当前页面仅为恢复入口。
只读取可信学校文档中的选中学期和已识别表格片段,排除密码页和跨源文档,保持原严格解析器。旧导航、旧读取、旧网络失败和关闭页面后的结果均失效;已有数据不会被残缺表格覆盖。
## 外观与交互
- 三个页面统一蓝色主色、浅蓝背景和深蓝文本。课表标题左边距8+14=22,与其他两页一致;七天网格宽度保留。
- Dock宽度280→264,高度68,静止滑块边距3。选中态浅灰,未选中部分透明玻璃。
- 按压、拖动时滑块放大约18%–20%,随速度拉伸,并用弹簧回弹;滑块能在交互时高于Dock,不被底座裁切。
- 滑块交互时提高透明感,实时背景滤镜弯曲边缘采样、轻微放大中部并模拟色散和反光。图标位于滤镜上方。
- 向下隐藏使用360ms缓入缓出滑出屏幕底部,取消过早淡出;向上滚动滑回。页面仍不支持横向滑动切页。
设计参考:[苹果iOS27](https://www.apple.com/os/ios/)、[苹果液态玻璃指南](https://developer.apple.com/documentation/technologyoverviews/adopting-liquid-glass)、[苹果Tab bar指南](https://developer.apple.com/design/human-interface-guidelines/tab-bars),以及用户视频。
## 验证
- 合并后的107项Flutter测试通过,flutter analyze无问题,安卓构建成功。
- 新回归覆盖:无退出链接但有logout函数时自动跳转;异步表格等待;嵌入表格读取;退出/关闭/重新打开的旧结果隔离;HTTP同源子页面;自动导航失败不影响新草稿。
- Node独立执行原生产网页检测脚本,验证真实选中学期序列化、根首页、嵌套同源文档、密码排除与源校验。
- 两个真实附件的解析仍正确:11条课程安排、8门未排考课程。
- 动画测试检查按压放大、松手恢复、隐藏过程仍可见且位置向下移动;折射像素测试用实际图像纹理验证边缘像素改变且中心保持稳定。
- Android兼容渲染模式实际截图保存在dist/screenshots/rschedule-week.png、rschedule-exams.png、rschedule-profile.png、rschedule-dock-held.png。
## 验证范围
学生真实账户和验证码未由代理填写,真实认证后的端到端流程仍需学生本人验证。已修复确定的错误选择器,并通过真实HTML、网页脚本及会话流程回归验证。
实时折射使用Flutter ImageFilter.shader,要求支持Impeller。其他模式保留透明玻璃高光与轻微模糊。本机Android模拟器GPU运行会导致宿主显卡进程退出;OpenGL测试模式同样无法运行,因此实际截图和原生动画复核使用兼容模式。折射算法已有真实像素测试,但真实手机GPU下的背景定位与视觉效果尚未实测。[Flutter接口限制](https://api.flutter.dev/flutter/dart-ui/ImageFilter/ImageFilter.shader.html)
测试用渲染配置已恢复,交付APK保留默认手机配置。原始HTML、视频及个人学号姓名不在源码或APK中。
+9
View File
@@ -0,0 +1,9 @@
# RSchedule 0.3.2+6 · 蓝色图标
2026-10-13。
保留灰色玻璃选中滑块,将选中图标从灰色改为完整蓝色(#007AFF)。考试图标改用实心样式;三个图标均采用填充图形,未选中图标继续使用较淡蓝色。
Dock尺寸、边距、放大回弹、折射、滑出隐藏以及学校自动读取流程沿用0.3.1。
107项回归测试通过,flutter analyze无问题,Android构建和覆盖安装成功。已目视检查实际考试页的实心蓝色图标,并更新三页及按压截图。交付包版本0.3.2/code6;原包名和签名兼容。历史详情:[0.3.1](rschedule-0.3.1-validation.md)。
+34
View File
@@ -0,0 +1,34 @@
# RSchedule Android 0.4.0+7 验证
日期:2026-10-13。沿用 Android 包名与签名,可覆盖安装;最低 Android 7 / API24。
## 修改与原因
- 点按 Dock 原先在选择回调及父组件更新各启动一次回弹,还将移动速度用于形变。现在点按采用单次320ms平滑移动,拖动松手保留弹性;只有已选滑块按住时放大,宽高增幅约30%。灰色静止玻璃、蓝色实心图标保留。
- 底座和滑块各自采用独立实时背景折射滤镜;不支持的渲染方式使用兼容玻璃效果。
- 课表页右上角刷新与考试更新共用本机保存的 SchoolTerm。首次在线导入先认证,再选择学期;已连接的“更新学期”立即显示选择,未连接时先认证。
- 学期编号73为2026-2027年秋,72为2025-2026年春,71为2025-2026年秋。课表使用路径编号,考试使用xq查询参数;认证后自动跳转目标。实际HTML选中学期不符时拒绝保存。网址目标判断同时比较xq,避免默认考试页阻止跳转。
- 成功导入另一学期会清除另一个页面的旧学期数据和旧教学开始日;同学期刷新保留。选择确认后仅发出请求,读取和首次写入成功后才持久化,取消或失败不替换旧数据。
- 学校学期与教学日期独立保存。HTML及JSON导入带入实际学期,JSON兼容旧学校课程编号;拒绝混合学期。旧保存文件可从学校课程编号/考试termId恢复学期。
- 课表页标题为课表、考试页为考试并保留考试安排标题;个人页保持RSchedule。缺少开始日时显示已保存课程数量和设置按钮,按钮定位学期日期区域。
- 离线HTML只包含保存时的学期,确认页明确说明;查询其他学期使用在线连接。
## 自动验证
- 完整 Flutter 测试130项通过,含原始课表/考试附件解析。附件仅由本机环境变量提供,不加入源码或APK。
- 静态分析:No issues found。
- 精确生产WebView脚本的Node测试通过:身份认证证据、活跃下拉选项、受信源、内嵌表格与排除密码页,并检查学期选项。
- 新回归覆盖逐帧点按位置单调且不改变尺寸、未选图标按住不放大、选中滑块放大超过25%、底座与滑块两个折射表面、减弱动画、学期路径及查询编号、持久化、旧数据升级、同学期刷新、跨学期隔离、写入失败、选择前不导入、认证后选择、取消、HTML学期不一致与考试默认页跳转。
- Android debug构建成功。APK显示versionName0.4.0/versionCode7,minSdk24/targetSdk36。
## 安卓实际检查
API36 Pixel7模拟器成功覆盖安装,原11条课程、8条考试和课程条长度设置保留。课表七列、课表与考试标题、刷新按钮、个人设置更新学期入口已查看。暂时将模拟器测试数据的开始日清空,查看首页提示并点击直接进入日期设置,随后恢复备份。较长拖动时拍到明显放大、变透明的滑块。
截图在dist/screenshots:rschedule-week.png、rschedule-exams.png、rschedule-profile.png、rschedule-start-date.png、rschedule-date-settings.png、rschedule-dock-held.png。截图中教学日期是验证用日期,不代表学校实际开学日期,也不预置到交付包。
本机模拟器GPU驱动限制仍存在,检查时仅以启动参数启用软件渲染并关闭Impeller,交付APK保留手机默认渲染配置。实时折射算法继续通过实际图像像素测试;本次模拟器截图展示兼容玻璃效果,不能作为手机GPU折射验证。
未获取用户真实登录凭据,因此未完成真实学生SSO/验证码及多学期在线查询的端到端验证;这部分仍需用户本人手机测试。
APK SHA256:A1B581A4FC8B286BA5AE22A1383701C2D762FD0AC6BCAEFF0249BD11518C655D。
+47
View File
@@ -0,0 +1,47 @@
# 学校接入状态 · TPSchedule 0.7.5
0.7.8将可选HTML反馈扩展到本科和研究生课表/考试,研究生自动和手动读取在解析前保留安全片段,见[0.7.8记录](tpschedule-0.7.8-validation.md)。反馈服务器需同步更新研究生结构白名单,代理请求体至少24MiB。0.7.5的本科考场未发布兼容、具体课表字段错误码继续保留;0.7.4两例课表失败仍需实际页面定位,不宣称已解决。
0.7.4将本科重复登录的准确学校HTTP继续链接升级为HTTPS,并解决完整页恢复后History API改变实际文档地址、原生历史地址未同步导致的“页面证据无效”。现按实际文档地址抓取且保留同源及精确校验,见[0.7.4记录](tpschedule-0.7.4-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)。
## 本科接入
学校列表现有电子科技大学(研究生)、电子科技大学(本科生),连接目标使用稳定标识并保存本机。研究生沿用专用SSO及自动读取;本科使用独立连接器,学校浏览器均延迟到真正访问时创建。
本科课表从 `https://eams.uestc.edu.cn/eams/` 登录,学生自行打开课表、选择学期,再点击「导入当前课表」。同源框架及实际学期字段用于解析;停课周不生成课程,完整教师/教室、跨周变更与备注保留。新课表学期需设置开始日;同学校同学期更新保留日期和偏好。本科12节,第12节22:00–22:45。
本科考试访问 `https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id={id}&examType.id={type}`。523为2026–2027第一学期,相邻学期编号差20;1期末、2期中、3补考/缓考。选择一个学期和至少一种类型,全部成功后一次保存;失败、取消、超时保留旧数据。重新认证后全部类型重读,验证码等待不算超时。成功选项用于刷新;历史本科考试不改变课表及开始日。
真实本科课表附件已验证10门课程、18段安排、3段含第12节;考试附件4条属于503,未被默认523污染。仓库只保留脱敏样例。真实账号在线认证、回调和验证码仍需本人验证;附件和模拟回调不替代这项确认。
## 研究生接入说明(沿用原0.3.1流程)
专用入口:https://yjsjy.uestc.edu.cn/pyxx/jzsso/login。
课表查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1。
考试查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index。
## 登录问题的原因与修复
旧版先打开课表或通用身份认证页面,缺少研究生系统专用 `service` 交接。2026-10-13直接核对学校入口HTTP响应,HTTPS专用入口302指向 `http://idas.uestc.edu.cn/authserver/login?service=http%3A%2F%2Fyjsjy.uestc.edu.cn%2Fpyxx%2Fjzsso%2Flogin`。安卓地址检查原先会拦截这一HTTP跳转。
现在从专用入口开始,把准确的HTTP身份认证入口升级为HTTPS,保留完整原始查询参数。移除自绘账号密码表单及JavaScript凭证注入,学生在学校WebView中完成认证和验证码。旧版错误地查找不存在的退出链接;真实页面使用window.logout()函数。现在根据该函数及真实研究生页面结构识别登录完成,自动打开目标页面并保存完整数据,不需要学生手动进入课表。读取仅在认证页面活跃时每750ms检查一次、最多45秒,支持延迟加载和同源内嵌课表。手动读取按钮只作为故障恢复,读取当前页而非重新认证。研究生域名的HTTP回调在Android网络配置中单独放行。其他HTTP身份认证地址不直接加载。
导航、重新打开、退出会让旧读取失效。保存确认同时检查原页面版本和原草稿,旧结果不会删除新草稿或关闭新路由。退出操作与下一次打开串行交接,原生清理失败显示可恢复信息;迟到的网络错误必须匹配当前页面。
## 数据验证
- 真实课表HTML:7门课程、11条每周安排;合并分散周次,保留全部老师、教室和晚上9–11节。
- 真实考试HTML:2026-2027秋学期8门课程,考核方式、考生编号、考场顺序号、考场、座位号、考试时间全部为空。目前只能读取课程列表,不能得出具体排考信息。
- 考试解析不保存页面里的学号、姓名。原始附件、视频和脚本不放进源码或APK;测试使用合成数据,真实附件仅作为本机可选验证输入。
- 不完整或未知表格拒绝导入;考试空列表需要明确无数据占位,课表空页面不作为清空指令。
- HTML文件导入不修改学校认证状态。课程和考试数据分开保存;升级兼容旧快照。
## 验证边界
没有使用本人账户填写密码、验证码或进行二次验证,因此不能宣称已完成真实账户端到端登录。已核对实际学校专用入口重定向,测试真实HTML识别以及会话回调、错误恢复和数据保存。
学校HTML不含学期开始日,需自行填写真实日期和周数;应用不会猜测或改写学期日期。后续学校表格发生变化时,未知结构会提示重试并保留已有数据。
@@ -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,221 @@
# 河畔 Everyday Android Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付可安装的 Android 首版,实现离线周课表、玻璃悬浮 Dock、我的与设置、学校认证入口、经过确认的课程导入、课前提醒和手机日历同步。
**Architecture:** Flutter UI 与纯 Dart 学期/课程计算分离,由一个 ChangeNotifier 应用控制器管理本地快照、导入与平台服务。认证使用设备 WebView 的学校会话;通知采用 Flutter 插件;日历通过独立 Kotlin Calendar Provider 桥接,保留未来 iOS 适配接口。不设自建账号或服务器。
**Tech Stack:** 已安装 Flutter 3.47.7 / Dart 3.13.5 / Java 17 / Android API 36;依赖固定为 webview_flutter 4.14.1、flutter_local_notifications 22.3.2、timezone 0.11.1、path_provider 2.1.6、file_picker 13.1.0;Flutter SDK 自带 flutter_test、integration_test。提交 pubspec.lock。应用业务位于 `mobile/`,已有工具与文档继续位于仓库根目录。
**Spec:** `docs/superpowers/specs/2026-10-12-hepan-everyday-design.md`(用户已确认)。当前环境结果见 `docs/android-setup-status.md`。
## Global Constraints
- 首版只有课表和我的两个标签页;底部图标不显示文字,保留无障碍标签;玻璃效果采用模糊、渐变、细边框和高光。
- 学校课表使用 Asia/Shanghai;11 节起止时间完全采用设计文件表格;连续多节课程只提醒一次。
- 第 1 周为学期开始日所在的周一至周日;开始日前不生成实例;支持明确周次集合、单双周与不连续周次。
- 学期周数 1–30,初始建议 20,首次需确认真实开始日;提前量关闭或 5/10/15/30/60 分钟,默认 15。
- 示例显著标注,不写入真实课表、不默认排提醒/日历;学校认证成功和导入成功分别显示。
- 密码不存盘、不进入日志、不发送到自建后端;仅向验证过的学校 HTTPS 表单提交;验证码使用学校原页面。
- 未验证的真实课表结构不能用猜测解析器声称接入;错误或空解析不得覆盖旧课表。明确无课程需单独确认。
- 日历默认关闭;只管理本 APP 创建的事件;不开启重复的日历提醒;关闭时选择保留或删除自己的事件。
- Android 先交付,minSdk 24、compile/targetSdk 36;测试 applicationId `com.example.hepan_everyday`,显示名称严格为“河畔Everyday”。
- 本轮计划不生成产品代码;完成计划审阅并选择执行方式后才实施。执行时检查隔离工作区;共享现有 .tools,避免重复下载。
## Review Focus
1. 开始日不是周一,以及手机位于其他时区:课程日期和 08:30 上课时间仍正确(Task 1)。
2. 本地文件损坏或保存失败:保留旧课表和设置,显示恢复/保存失败状态(Task 2)。
3. 超长中文、200% 字体、同日课程重叠及周日:文字可查看,所有天数可达,Dock 不遮挡末节(Task 3)。
4. 非学校域名、认证过期与重复点击同步:不注入密码、不重复提交、不误报成功(Task 4)。
5. 权限被撤销、系统日历手工删除事件、同步途中退出:后续能恢复,避免复制/误删日程(Task 5/6)。
## 文件与边界
|目录/文件|责任|
|---|---|
|mobile/lib/domain/{course,semester,app_settings,app_snapshot,course_occurrence,lesson_times}.dart|不可变值对象、验证和快照序列化|
|mobile/lib/domain/schedule_engine.dart|周次、实例、时区与冲突计算|
|mobile/lib/data/{snapshot_store,file_snapshot_store,demo_schedule,json_schedule_import}.dart|持久化、显著标记的示例、结构化导入|
|mobile/lib/app/{app_controller,hepan_app,app_theme}.dart|状态、服务协调与主题|
|mobile/lib/features/timetable/{timetable_page,week_grid,course_details}.dart|周课表与详情|
|mobile/lib/features/profile/{profile_page,settings_page,import_preview}.dart|我的、设置、确认导入|
|mobile/lib/features/school/{school_auth_page,school_session,school_page_adapter,school_navigation_policy}.dart|学校 WebView、认证和已验证页面适配边界|
|mobile/lib/services/{notification_service,local_notification_service,calendar_service,android_calendar_service}.dart|平台服务接口与适配|
|mobile/lib/widgets/glass_dock.dart|两个纯图标的 Dock|
|mobile/android/app/src/main/kotlin/com/example/hepan_everyday/{MainActivity,CalendarBridge}.kt|Android MethodChannel 和日历权限/事件操作|
|mobile/test/、mobile/integration_test/|业务测试、关键交互与设备验证|
|scripts/android-env.ps1、scripts/build-android.ps1|复用本地工具、构建并导出 APK|
所有命令先激活根目录 `scripts/android-env.ps1`;进入 `mobile/` 后执行 Flutter 命令。隔离工作区通过 `HEPAN_TOOLCHAIN_ROOT` 指向已安装的 `C:/Users/david/Desktop/app/.tools`;环境脚本添加此可选参数,默认行为仍使用本目录 .tools。APK 与日志不进入 Git。
### Task 1: 可运行项目与准确的课程计算
**Files:** 创建 `mobile/` Flutter Android 工程、上述 domain 文件、`mobile/lib/main.dart`、`mobile/lib/app/hepan_app.dart`、`mobile/test/domain/schedule_engine_test.dart`;修改 `scripts/android-env.ps1`、根 `.gitignore`。
**Interfaces:**
- `Course({required String id, required String name, required String teacher, required String room, required int weekday, required int startPeriod, required int endPeriod, required Set<int> weeks, required CourseSource source})`;`CourseSource` 为 school/file/demo。
- `Semester({required DateTime startDate, required int totalWeeks})`;startDate 的年月日代表学校当地日期,totalWeeks 限制 1–30。
- `AppSettings({Semester? semester, bool remindersEnabled=true, int reminderMinutes=15, ColorMode colorMode=ColorMode.multicolor, int singleColorArgb=0xFF348F91, bool calendarEnabled=false, String? calendarId})`;定义 `ColorMode.single/multicolor`。
- `LessonSlot(int period, int startMinute, int endMinute)`;`lessonSlots` 为 11 项常量。
- `CourseOccurrence({required String key, required Course course, required int week, required tz.TZDateTime start, required tz.TZDateTime end})`;key = `course.id/YYYY-MM-DD`,上课时区固定。
- `ScheduleEngine.weekNumber(Semester semester, DateTime schoolDate) -> int?`;学期外返回 null。
- `ScheduleEngine.occurrences(List<Course> courses, Semester semester) -> List<CourseOccurrence>`,按开始时间/ID 排序。
- `ScheduleEngine.overlaps(CourseOccurrence a, CourseOccurrence b) -> bool`。
- [ ] 建立隔离执行目录并复用已安装工具;执行 `flutter create --platforms=android --org=com.example --project-name=hepan_everyday mobile`。仅添加本计划依赖和 SDK 测试依赖,保存锁文件;将 Android APP 名称设置为“河畔Everyday”。
- [ ] 先写失败测试:11 节开始分钟 `[510,560,620,670,870,920,980,1030,1170,1220,1270]`、结束分钟 `[555,605,665,715,915,965,1025,1075,1215,1265,1315]`;2026-09-09 开始的学期中,9 月 7 日不生成实例,9 月 9 日属于第 1 周,9 月 14 日属于第 2 周;周日课程可达;2–3 节实例结束为 11:05。
- [ ] 增加失败测试:周次 `{1,3,5}` 只生成 3 个实例;节次 0/12、星期 8、非法周次拒绝;相邻但不重叠返回 false;把 `2026-09-09T00:30Z` 转为学校日期后,上课仍为学校时间 08:30,开始 UTC 为 00:30。
```dart
test('morning break before period 3 is fifteen minutes', () {
expect(lessonSlots[2].startMinute - lessonSlots[1].endMinute, 15);
expect(lessonSlots.last.endMinute, 1315);
});
```
- [ ] 运行 `flutter test test/domain/schedule_engine_test.dart`,确认因缺少实现失败;实现上述类型与计算,初始化 timezone 数据并固定 Asia/Shanghai。clock 的瞬间先转换为上海时间;日期选择器的年月日仅解释为学校当地日期,不能用手机当前时区重新偏移。
- [ ] 运行同一测试并确认全通过;让基础 APP 能启动,默认无学期、无真实课程,无权限弹窗;`flutter analyze` 无问题。提交本任务。
### Task 2: 持久化、导入和单一应用状态
**Files:** 创建 data 文件、`mobile/lib/domain/app_snapshot.dart`、`mobile/lib/app/app_controller.dart`、`mobile/lib/services/notification_service.dart`、`mobile/lib/services/calendar_service.dart`、`mobile/test/data/{snapshot_store_test,json_schedule_import_test}.dart`、`mobile/test/app/app_controller_test.dart`、`mobile/test/support/fakes.dart`。
**Interfaces:**
- 消费 Task 1 类型。`ImportMetadata({required CourseSource source, required DateTime importedAt, required String parserVersion})`。
- `AppSnapshot({required AppSettings settings, required List<Course> courses, ImportMetadata? importMetadata, Map<String,String> calendarEventIds=const {}})`;schemaVersion=1,不包含密码/Cookie/认证令牌。
- `SnapshotStore.load() -> Future<AppSnapshot?>`,`SnapshotStore.save(AppSnapshot snapshot) -> Future<void>`;`FileSnapshotStore(Directory directory)`,使用 path_provider Application Support 的目录。
- `ImportDraft({required List<Course> courses, required bool explicitlyEmpty, required CourseSource source, required String parserVersion})`;`JsonScheduleImport.parse(String text) -> ImportDraft`,只接受 schemaVersion 1、结构化字段和最大 2 MiB UTF-8 数据。根字段为 `schemaVersion`、`isDemo`、`explicitlyEmpty`、`courses`;课程字段为 `id/name/teacher/room/weekday/startPeriod/endPeriod/weeks`,weeks 是整数数组。文件来源仅可为 file 或 demo,不能冒充 school;isDemo=true 进入示例体验。
- `NotificationService.reconcile(List<CourseOccurrence> occurrences, AppSettings settings, DateTime now) -> Future<ReminderResult>`,`requestPermissions() -> Future<void>`、`cancelOwned() -> Future<void>`;reconcile 仅检查权限,不自行弹窗。`ReminderResult` 的 status 为 off/permissionDenied/exact/inexact/error,包含 scheduledCount 与说明。
- `CalendarService.requestAccess() -> Future<bool>`,`listWritableCalendars() -> Future<List<DeviceCalendar>>`,`sync(List<CourseOccurrence> occurrences, String calendarId, Map<String,String> knownEventIds) -> Future<Map<String,String>>`,`deleteOwned(Map<String,String> eventIds) -> Future<void>`;`DeviceCalendar(id,name)`。
- `AppController(SnapshotStore store, NotificationService notifications, CalendarService calendar, DateTime Function() clock)` extends ChangeNotifier;暴露持久化 `snapshot`、显示用 `visibleCourses/visibleSettings`、`isDemo`、`lastError`、`reminderResult`、`calendarSyncError`。`initialize()`、`showDemo({List<Course>? courses})`、`leaveDemo()`、`updateSettings(AppSettings value)`、`applyImport(ImportDraft draft,{required bool confirmedEmpty})`、`clearCourses()`、`disableCalendar({required bool deleteEvents})`、`enableCalendarFromUserAction(String calendarId)`、`enableRemindersFromUserAction()` 均返回 Future<void>。示例有单独临时数据和“示例”学期,以 clock 所在周为第 1 周,不改变真实快照。clearCourses 始终清理对应通知和自有日历事件;仅关闭日历的保留选择由 disableCalendar 处理。
- [ ] 写失败测试:有效 JSON 转为确定周次集合;部分记录非法使整个草稿失败;空数组且 explicitlyEmpty=false 不得覆盖;重复 ID 拒绝;schemaVersion 未知和超过 2 MiB 拒绝;isDemo=true 导入仅改变显示数据,不改真实快照、不调用平台服务。
- [ ] 写失败测试:临时目录保存、重新打开恢复所有课程/设置;损坏文件报错且不返回“空课表”;模拟保存异常后控制器仍保留旧 snapshot;showDemo 不调用通知或日历;快速并发两次更新按顺序保留最后一份设置;移除课程后只清理本 APP 的副作用。
```dart
test('unknown import version is rejected', () {
expect(() => JsonScheduleImport.parse('{"schemaVersion":2,"courses":[]}'),
throwsFormatException);
});
```
- [ ] 运行 `flutter test test/data test/app` 确认失败;实现 JSON 文件 `.tmp` 写入并 flush、rename 替换,保存前保留最近有效 `.bak`;损坏时提示可恢复备份,不静默丢弃。用串行队列处理持久化和服务协调。
- [ ] 实现控制器:只有确认非 demo 草稿才保存真实快照;保存成功后分别协调服务,一项失败不阻止另一项,记录状态供重试;初始化离线恢复后也执行幂等协调。认证连接状态仅由学校会话确认,不能用存在课程推断已登录。
- [ ] 运行同一测试全通过并提交。演示课程与真实快照分开,demo 学期仅供展示,名称/教师标记“示例”。
### Task 3: 两个页面、玻璃 Dock 与所有设置
**Files:** 创建 app_theme、widgets 和 timetable/profile 文件;修改 hepan_app/main;创建 `mobile/test/widgets/{navigation_test,timetable_test,settings_test,import_preview_test}.dart`。
**Interfaces:** 消费 `AppController`。`HepanApp({required AppController controller})`;`GlassDock({required int selectedIndex, required ValueChanged<int> onSelected})`;`TimetablePage({required AppController controller})`、`ProfilePage({required AppController controller})`、`SettingsPage({required AppController controller})`。`ImportPreview({required ImportDraft draft, required AppController controller})` 返回用户确认结果;所有平台能力经控制器接口调用。
- [ ] 写失败交互测试:点击日历/我的图标切换两页,没有第三个标签或文字 Dock;屏幕 360×800、底部安全区 34 时,9–11 节可滚动到且不被 Dock 遮挡;周日可横向滚动到。
- [ ] 写失败测试:200% 字体下超长名称/教师/教室无渲染溢出,详情可完整阅读;跨 1–4 节课程仅一个条;重叠课程同时可见并标冲突;当前周跳转边界不能越过配置学期。
- [ ] 写失败测试:颜色切换、提前 15→30 分钟、关闭提醒、周数 0/31 拒绝、确认开始日后保存;未配置学期不生成提醒;取消导入保留旧课程;示例标签常驻且不能开启示例提醒/日历。
```dart
testWidgets('dock opens profile', (tester) async {
// 本测试 setup 用 Fake 服务创建并挂载 HepanApp。
await tester.tap(find.bySemanticsLabel('我的'));
await tester.pumpAndSettle();
expect(find.byType(ProfilePage), findsOneWidget);
});
```
- [ ] 运行 `flutter test test/widgets` 确认失败;实现浅色蓝绿主题、上午/下午/晚上分组、同步横向滚动日期头、课程详情;周次按钮、今天列与日期范围明确。Dock 用 BackdropFilter+渐变高光,图标提供 Semantics。
- [ ] 实现我的未登录状态、最近真实导入状态、示例体验、JSON 文件选择/预览/确认;file_picker 13 使用 `pickFile()` 和有 2 MiB 上限的 `readAsByteStream()`,未知长度也必须有累计字节限制,非法 UTF-8 拒绝,取消不报失败。设置默认日历关闭、真实开始日必选;日历开启准备权限/选择入口,Task 6 接入。
- [ ] 同一测试通过、模拟器目视检查两个页面和大字体、截图保存到 `artifacts/screenshots/`;提交。本任务交付本地完整体验,学校入口暂明确“待接入”,不显示虚假同步成功。
### Task 4: 学校认证和真实导入适配边界
**Files:** 创建 school 文件、`mobile/test/school/{navigation_policy_test,school_page_adapter_test}.dart`、`mobile/test/fixtures/school/README.md`;修改 profile_page 与 import_preview。
**Interfaces:** `SchoolNavigationPolicy.canInjectCredentials(Uri uri) -> bool` 仅允许 `https://idas.uestc.edu.cn/authserver/login`;`isTimetable(Uri uri) -> bool` 精确匹配 `https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1`。`SchoolPageAdapter.extract(String html, Uri source) -> ImportDraft`;没有经确认的页面匹配器时抛 `UnsupportedSchoolLayout`。`SchoolSession` 包装 WebViewController,暴露 `connectionState`(signedOut/authenticating/connected/expired/unknown)、`busy`、`open()`、`readTimetable() -> Future<ImportDraft>`、`logout() -> Future<void>`。`SchoolAuthPage({required SchoolSession session, required AppController controller})`。
- [ ] 写失败测试:`idas.uestc.edu.cn.evil.test`、HTTP、userinfo 欺骗 URL、其他路径均不能注入凭据;只加载登录页不构成认证成功;课表标题/合法目标和验证结构满足时才解析;未知结构、验证码页和空 HTML 都不得覆盖旧课表;重复同步在 busy 时仅执行一次。
```dart
test('lookalike auth host cannot receive credentials', () {
expect(SchoolNavigationPolicy.canInjectCredentials(
Uri.parse('https://idas.uestc.edu.cn.evil.test/authserver/login')), isFalse);
});
```
- [ ] 运行 `flutter test test/school` 确认失败;实现 URI 精确校验、导航提示、仅主文档错误状态、认证过期保留离线课表。同一 WebView 管理会话,密码字段 submit/cancel/dispose 后清空。
- [ ] 在公开认证页面核对表单字段、提交脚本和动态验证机制;仅能核实时接通自定义输入向官方表单交接。无法验证时显示学校原页面进行认证,自定义界面明确当前方式,不能假装原生账号登录已经联通。
- [ ] 使用目标 URL 的学校实际跳转流程;没有本人会话/页面样本时,只交付认证入口与 fail-closed 解析边界,UI 明确“课表识别待联调”;fixture README 明确没有真实样本。不得用人工编造表格作为已验证学校 fixture。
- [ ] 若用户本人设备取得去标识化页面样本,加入对应 fixture、精确字段匹配器和周次解析测试;真实字段缺失时整体拒绝;真实明确无课程仍必须二次确认。无样本则把该联调状态记录在交付说明。
- [ ] 同一测试通过;退出登录清除 Cookie、WebView 缓存与 localStorage,选择保留时不清课程,选择清除时调用 clearCourses。提交;文案分别表示认证状态与课表导入状态。
### Task 5: Android 课前提醒
**Files:** 创建 local_notification_service、`mobile/test/services/notification_service_test.dart`;修改 `mobile/android/app/build.gradle.kts`、`mobile/android/app/src/main/AndroidManifest.xml`、`mobile/android/app/src/main/res/drawable/ic_stat_calendar.xml`、settings_page/main。
**Interfaces:** `LocalNotificationService` implements Task 2 NotificationService;可注入 `ReminderGateway`,其 `permissions({required bool request}) -> Future<ReminderPermissions>`、`pendingIds() -> Future<Set<int>>`、`schedule(ReminderRequest value) -> Future<void>`、`cancel(int id) -> Future<void>`。`ReminderRequest(id, occurrenceKey, fireAt, title, body, exact)`;`ReminderPermissions(notifications,exactAlarm)`。`notificationId(String occurrenceKey) -> int` 使用 FNV-1a 32-bit 后取正 31-bit;本次集合碰撞按排序后线性探测解决,不能依赖 Dart hashCode。ID 变化时先取消旧计划再重排,不能覆盖碰撞课程。
- [ ] 写失败测试:08:30 课程默认 08:15 触发;已过提醒时间不排任务;只排实际上课周、每连续课程一次;demo、无学期、提醒关闭不排;重复 reconcile 无重复;删除课程取消旧 ID;通知权限拒绝返回 permissionDenied;精确权限拒绝可用 inexact 并显示说明;撤销权限后不显示已生效。
- [ ] 写失败测试:500 条以上未来实例时仅保留最早 400 条并显示“后续提醒在再次打开时补充”;初始化/设置更改补充未来任务,Android 重启恢复已安排的任务。用 FakeReminderGateway 检验相同实例保留稳定 ID。
```dart
test('notification identity is stable', () {
expect(notificationId('math/2026-09-09'), 1794407394);
});
```
- [ ] 运行 `flutter test test/services/notification_service_test.dart` 确认失败;实现时区通知、点击进入课表详情。设置中启用/授权按钮调用 enableRemindersFromUserAction,再请求通知和精确闹钟能力;启动与后台协调只读权限状态,不自动弹窗。
- [ ] 按插件 22.3.2 官方 README 配置 Java 17、core library desugaring 2.1.4、SDK 36、RECEIVE_BOOT_COMPLETED、SCHEDULE_EXACT_ALARM、通知接收器与小图标;使用所需最小权限,不使用 USE_EXACT_ALARM。
- [ ] 同一测试通过;在模拟器安排短时验证通知并退到后台,核实收到/点击/关闭后取消;重启验证恢复。记录厂商省电限制仍需真机检查。提交。
### Task 6: 手机日历同步与精确清理
**Files:** 创建 android_calendar_service、CalendarBridge.kt、`mobile/test/services/calendar_service_test.dart`、`mobile/integration_test/calendar_bridge_test.dart`;修改 MainActivity.kt、Manifest、app_controller、settings_page。
**Interfaces:** AndroidCalendarService 实现 Task 2 CalendarService;MethodChannel `hepan_everyday/calendar` 的方法为 `requestAccess`、`listCalendars`、`syncEvents`、`deleteOwnedEvents`。CalendarBridge 用 CalendarContract 查询可写日历,按 occurrenceKey 和当前 applicationId 设置并查验 CUSTOM_APP_URI/CUSTOM_APP_PACKAGE;Dart 保存 key→eventId 的映射。
- [ ] 写失败测试:重复同步仅一个事件;教师/教室改变更新原事件;用户手工删除日历事件后下一次恢复一条;事件已被改成非本 APP 的标记时不修改/删除;同步到一半失败后重试通过 app URI 找回已创建事件。
- [ ] 写失败测试:权限拒绝/撤销、只读日历、无可写日历都保持未启用状态;关闭选择保留时不删除;选择清除仅删除拥有标记的事件;删除整张课表也不影响其他 APP 日程;不创建 CalendarContract.Reminders。
```dart
test('denied calendar permission keeps calendar off', () async {
// setup 中 Fake CalendarService.requestAccess 返回 false。
await controller.enableCalendarFromUserAction('selected-calendar');
expect(controller.snapshot.settings.calendarEnabled, isFalse);
expect(controller.calendarSyncError, isNotNull);
});
```
- [ ] 运行 `flutter test test/services/calendar_service_test.dart` 确认失败;实现 READ_CALENDAR/WRITE_CALENDAR 运行时权限。enableCalendarFromUserAction 检查授权和可写日历,成功同步后才开启设置;写入 TZ 实例对应的 UTC 毫秒与 EVENT_TIMEZONE=Asia/Shanghai。
- [ ] 实现 Kotlin 桥接和 Dart 重试逻辑。系统写入与快照无法组成跨系统事务,先凭 app URI 查询去重再写;每次已完成映射及时持久化,重启后可恢复。仅操作用户选定日历,无法校验拥有标记时跳过;disableCalendar 保留时关闭后续自动同步,删除时先清理自有事件。清理失败保留待重试映射,不丢失追踪。
- [ ] 同一测试通过;`flutter test integration_test/calendar_bridge_test.dart -d emulator-5554` 验证创建、更新、删除和权限结果。没有可写日历时设备测试明确跳过并报告;仅使用测试专用日历,不向用户其他日历写测试数据。提交。
### Task 7: 联调、Android 测试包与交付
**Files:** 创建 `mobile/integration_test/app_flow_test.dart`、`scripts/build-android.ps1`、`docs/android-mvp-validation.md`、`docs/school-integration-status.md`、`examples/schedule.schema-v1.json`;修改 README;输出 `dist/hepan-everyday-debug.apk`,截图放 artifacts。
**Interfaces:** 应用启动组装真实 SnapshotStore、通知、日历和学校服务;集成测试注入临时存储与显式 fake 平台服务,学校实际会话不在测试中模拟为真实导入。build-android.ps1 激活已安装环境,在 mobile 执行 debug 构建,成功才复制 APK;失败不得复制旧产物冒充本次结果。
- [ ] 写端到端测试:首次启动→我的设置真实开始日/周数→导入结构化示例文件→确认→七天课表→切换多彩/单色→重开应用恢复;进入示例体验不会修改此前真实快照;错误导入不会擦除课程。
```dart
testWidgets('installed app exposes exactly the two tabs', (tester) async {
// setup 挂载应用并完成初始化,权限不自动弹窗。
expect(find.bySemanticsLabel('课表'), findsOneWidget);
expect(find.bySemanticsLabel('我的'), findsOneWidget);
});
```
- [ ] 运行该测试确认未完成连接时失败;接通初始化、服务状态和所有按钮,不留无响应控件或假的成功提示;返回前台复核权限变化并重试尚未成功的通知/日历协调。
- [ ] 完成 `flutter analyze`、`flutter test`、`flutter test integration_test/app_flow_test.dart -d emulator-5554`;全通过后运行 `flutter build apk --debug`。检查新 APK 的名称、包 ID、版本和时间戳,复制本次产物到 dist。
- [ ] 在 Android 16 模拟器安装本次 APK 并启动;目视核实两页、滚动末节、周日、大字体、离线重启与登录错误状态,保存关键截图。通知后台/重启、日历设备结果独立记录;不能把单元测试通过当成系统验证。
- [ ] 在验证报告区分“已验证”“需真机验证”“学校页面待联调”,写清没有本人会话时尚未完成真实学校自动导入;提供 isDemo=true 的 JSON 样例字段说明和启动/构建方式,真实数据文件使用 isDemo=false,不展示学校密码。
- [ ] 对照设计逐项自查并完成所选执行方式要求的独立审查;修复可重现问题后,只重跑受影响检查。保存源码、报告与 APK 的可点击入口;提交交付文档,不发布到应用商店。
## 核查来源与执行交接
依赖版本及 Android 配置于 2026-10-12 核查:[WebView 官方包](https://pub.dev/packages/webview_flutter)、[通知插件维护者文档](https://pub.dev/packages/flutter_local_notifications)、[时区包](https://pub.dev/packages/timezone)、[路径插件](https://pub.dev/packages/path_provider)、[文件选择器](https://pub.dev/packages/file_picker)、[Android Calendar Provider](https://developer.android.com/identity/providers/calendar-provider)。执行时以锁定版本的实际 API 为准;若依赖解析或构建证明不兼容,记录证据并作最小必要版本调整。
自查:设计的页面、时间、设置、学校接入边界、数据保存、提醒、日历和设备验收均有对应任务;五项 Review Focus 均分配了测试;副作用失败不改变已保存课表;模块接口名称一致。当前状态:计划已写成并自查,等待用户审核及选择执行方式。
建议由主助手在本聊天逐项实现,最后独立审查:七项任务共享相同模型与控制器接口,顺序实现可以减少交接成本。用户也可选择每项交给独立助手实现和审查的方式;选择后按相应技能执行。
@@ -0,0 +1,12 @@
# RSchedule 学期导入与 Dock 修订
依据用户明确提出的现有功能修改,继续本地 master 的交付流程。
1. 独立修复 Dock 点按重复动画与速度形变;拖动保留弹性、加大光学镜片,底座也使用独立折射滤镜。用逐帧位置、快速切换、拖动尺寸及减弱动态效果测试验证。
2. 增加 SchoolTerm(学校学期编号、名称、两个查询地址),作为本机保存的可选字段,兼容旧保存文件。学校 HTML 导入携带实际选中学期,考试导入使用 termId。
3. 第一次连接或更新学期:先完成官方网页身份认证,再选择学期并跳转指定地址;已连接可立即选择。刷新复用已保存学期,读取结果必须匹配目标学期。
4. 成功导入其他学期时清除旧学期另一类数据及开始日;失败保留原数据。同学期刷新保留日期。重新协调课程通知和手机日历。
5. 课表页增加刷新和已导入但缺日期的提示/设置按钮;标题改为课表/考试,个人页保留 RSchedule 并增加更新学期。
6. 测试模型持久化、旧文件兼容、切换学期隔离、保存失败、WebView 选择/刷新/过期认证及页面提示。运行完整测试、静态分析与 Android 构建,检查原生画面,更新 APK、源码和验证记录。
离线 HTML 只有保存时那个学期的数据,确认导入时说明其实际学期;其他学期必须通过学校在线查询获取,避免给原有数据改标签。
@@ -0,0 +1,11 @@
# TPSchedule 品牌与玻璃材质修订
用户已确认正式安卓包名 com.tpschedule.app。显示名称 TPSchedule,图标严格依据用户 E:/工作室logo/APP.svg,玻璃材质参考两张上传图片;保持原签名密钥。
1. 保存旧 APK 签名证书摘要作为基准;不生成或替换密钥、不修改签名配置、不将私钥放入源码归档。
2. 图标转换为原生向量及自适应图标,保留蓝色渐变、黄色日历框与白色笔画;提供匹配色彩的启动画面,兼容旧安卓及 Android12+ 系统启动屏。
3. 更改安卓 namespace/applicationId、Kotlin包声明与启动入口;内部 Dart 包名/原生通道与数据格式保持兼容。展示字符串、手机日历标识文案及分发文件名更新 TPSchedule。
4. 启动初始化期间显示匹配图标的 Flutter 启动画面,准备就绪后短淡出,无人为停留时长。测试异步准备期间不展示未初始化课表、完成后能进入应用。
5. Dock 底座与滑块保留光线弯曲、色散与高光,加入明显的背景柔化模糊,减少灰色不透明覆盖。保持蓝色图标、较大按压镜片和已经修复的点按动画。
6. 验证图标/启动颜色、玻璃真实图像像素、页面操作、全套测试及安卓构建。安装新包并查看原生启动/桌面图标/Dock;保留旧包并列安装。
7. 对比新旧证书摘要及公钥,导出备案可用的公开签名信息,明确调试签名的现状。新包不能直接升级旧应用,旧数据需重新导入。交付安装包、源码与说明。
@@ -0,0 +1,14 @@
# TPSchedule 0.6.0 实施记录
用户授权:白色 Dock 光晕;修复 Android 16 未收到课前提醒;左右换周增加过渡;我的页面增加问题建议及参与开发反馈,图片视频可选,腾讯云服务器通过飞书 SMTP 发信。
1. 保留 0.5.4 登记冻结和 V1.0 材料,保留原 Android 证书及包名。
2. 将 Dock 柔光改成白色;换周使用短距离滑动、淡入淡出,快速操作只保留当前与最新目标,减少动画时直接切换。
3. 将待发送与已显示通知分开查询,后者失败不阻断定时安排;安排后核对保存结果,保留安全错误码。
4. 增加立即和 10 秒后台通知测试、权限/通道/后台状态检查及可复制诊断。测试提醒与正式课程独立。
5. 制作两种反馈表单,校验必填字段;最多 3 个附件、单个 8 MiB、合计 20 MiB。开发参与不携带隐藏的问题内容或附件。
6. 客户端 HTTPS multipart 对接 https://app.glowtps.cn/v1/feedback,稳定请求标识用于失败重试;只有服务器确认发信后提示成功。
7. Ubuntu/1Panel 部署反馈服务,authors@glowtps.cn 发信,tps-david@glowtps.cn 收信。SMTP 密码只在服务器环境配置。
8. 应用测试、分析、真实 Android 后台测试、后端假 SMTP 测试、独立代码审阅、APK 原证书检查及文档交付。
限制:开发环境未连接用户 vivo 真机;没有腾讯云管理会话、SSH 凭据或 SMTP 密码,不能声称已完成线上部署或真机送达验证。
@@ -0,0 +1,286 @@
# TPSchedule 本科生接入 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为电子科技大学本科生提供网页登录后手动导入课表、按学期及多种考试类型自动导入考试,并保持现有研究生数据和流程兼容。
**Architecture:** 保留研究生连接器,新建本科解析器、受限页面捕获和独立连接器。学校标识及学期作为共用领域对象;本科考试保存在独立于课表学期的数据中,整批成功后一次写入。沿用 Flutter 页面、原子存储、课程提醒及日历服务。
**Tech Stack:** 现有 Flutter 3.47.7 / Dart 3.13.5、`html`、`webview_flutter`、Flutter Test、Android API 36;不增加产品依赖。
**Spec:** `F:/program/app/docs/superpowers/specs/2026-10-14-uestc-undergraduate-design.md`(已确认;执行者必须同时阅读)。
## Global Constraints
- 工程根目录 `F:/program/app`;应用 TPSchedule,包名 `com.tpschedule.app`;发布版本计划 `0.7.0+18`,当前 `0.6.2+17`。
- 不修改密钥、签名配置及0.5.4登记冻结材料、V1.0软著材料;签名证书SHA256必须保持 `AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A`。
- 学校稳定标识 `uestc_graduate`、`uestc_undergraduate`;保存格式仍为 `schemaVersion: 1`,旧字段缺少学校标识时默认研究生。
- 本科课表登录地址 `https://eams.uestc.edu.cn/eams/`;只有点击「导入当前课表」才捕获和解析课表。
- 本科考试523对应2026–2027第一学期,相邻学期差20;考试类型1期末、2期中、3补考/缓考;至少选一种,首次默认523及期末。
- 本科12节,研究生及示例11节;第12节22:00–22:45,前11节作息不变;七天同屏、相同节数课程条等长、教师和教室完整。
- 周次1–30;考试合并记录≤500;单文件及一次捕获全部片段合计≤2MB;同源文档遍历最多4层、16个文档。超限拒绝,不能截断。
- 读取单类型45秒、整批120秒,均不计算人工认证时间;任何失败、取消或过期回调不得写入临时结果。重新认证后全部类型重新读取。
- 本科考试不清课表、不改课表学期/开始日、不触发课程变更协调;现有回到前台服务检查保持。普通启动不创建学校WebView。
- 界面沿用蓝色、现有Dock及换周动画;不新增考试提醒、服务端功能或自制密码界面;不执行附件脚本、考试申请或取消操作。
## Review Focus
1. 页面初始化默认学期523、实际结果503:使用实际结果证据,不能把历史考试写到新学期(任务4、5)。
2. 验证码等待超过120秒及登录后切换账号:等待不超时,重新认证丢弃旧片段整批重读(任务6)。
3. 多类型读取的最后一项失败、返回旧页面或重复点击:保留旧考试及查询选项,过期结果不得保存(任务6、7)。
4. 第12节仅出现在其他周、多人教师及教室变化:整学期保持12行,教师/教室完整,停课周不生成提醒(任务3、8)。
5. 学校选择保存失败、损坏旧文件及本科考试与研究生课表共存:不错误导航/迁移/清空,保留恢复入口(任务1、2、9)。
## 执行约定与文件职责
所有下列路径相对工程根目录;测试命令在 `mobile` 目录运行。执行前读取适用的 `AGENTS.md`(若存在)、检查工作树和基线,不覆盖用户的 `output/` 材料。需要隔离时按 using-git-worktrees 技能创建工作树;尚未获得计划执行确认时只允许文档及只读检查。
新文件:
| 文件 | 职责 |
| --- | --- |
| `mobile/lib/domain/school_identity.dart` | 共用学校枚举、稳定标识和名称 |
| `mobile/lib/domain/undergraduate_exam_query.dart` | 本科考试类型、有效查询、标签及URL |
| `mobile/lib/features/school/undergraduate/activity_data.dart` | 受限活动字面量/索引解析,不执行JS |
| `mobile/lib/features/school/undergraduate/timetable_adapter.dart` | 本科课表HTML到ImportDraft |
| `mobile/lib/features/school/undergraduate/exam_adapter.dart` | 本科考试HTML到ExamSchedule及整批合并 |
| `mobile/lib/features/school/undergraduate/navigation_policy.dart` | 本科官方导航与数据来源检查 |
| `mobile/lib/features/school/undergraduate/page_probe.dart` | 状态/捕获脚本与证据解码 |
| `mobile/lib/features/school/undergraduate/session.dart` | 延迟WebView、手动课表、自动考试批次 |
| `mobile/lib/features/school/undergraduate/auth_page.dart` | 本科浏览器、进度、导入确认与保存 |
| `mobile/lib/features/school/undergraduate/exam_query_picker.dart` | 学期单选、类型多选及取消 |
| `mobile/lib/data/exam_file_import.dart` | 本科/研究生考试文件分派 |
既有领域模型负责兼容序列化;`AppController`负责串行原子提交;`HepanApp`和`main.dart`负责连接器分派/生命周期;课表、考试、我的页面负责数据来源及入口。每个任务先写测试并观察预期失败,再实现、验证、提交;不把通用文件整段重写。
测试初始化(同一个PowerShell会话):
```powershell
$env:HEPAN_TOOLCHAIN_ROOT = 'F:/program/app/.tools'
. ./scripts/android-env.ps1
Set-Location mobile
```
Flutter命令使用已有工具链;此环境如出现SDK执行权限限制,使用授权后的执行方式重试,不能把环境失败算成业务测试失败。Git提交只暂存该任务文件;沿用已有身份,无全局身份时使用命令局部 `-c user.name=Codex -c user.email=codex@local`。
---
### Task 1: 学校、学期与兼容数据模型
**Files:** Create `domain/school_identity.dart`、`domain/undergraduate_exam_query.dart`;Modify `domain/school_term.dart`、`domain/course.dart`、`domain/exam_schedule.dart`、`domain/app_snapshot.dart`、`features/school/school_selection_page.dart`(均位于`mobile/lib`)。Test `mobile/test/app/school_term_test.dart`、新建 `mobile/test/domain/undergraduate_models_test.dart`、既有 `mobile/test/data/snapshot_store_test.dart`。
**Interfaces:**
- `enum SupportedSchool`移动到领域文件,保留 `uestcGraduate`,新增 `uestcUndergraduate`;字段 `String id, label, description`,`static SupportedSchool fromId(String id)`未知值抛FormatException。学校选择文件重导出枚举,兼容既有调用。
- `SchoolTerm(int id, {SupportedSchool school = SupportedSchool.uestcGraduate})`,字段 `int id; SupportedSchool school`;JSON增 `schoolId`,缺失默认研究生;相等/hash同时使用二者。原 `choices`、研究生URI保持;本科调用研究生URI getter必须抛StateError。
- `static List<SchoolTerm> undergraduateChoices(DateTime now, {SchoolTerm? saved})`:按设计编号公式,2013第一学期至设备当前年份下一学年的第二学期,降序并包含有效saved。
- `enum UndergraduateExamType`含 `finalExam(1,'期末考试')`、`midterm(2,'期中考试')`、`makeup(3,'补考/缓考')`,字段 `int id; String label`。
- `UndergraduateExamQuery({required SchoolTerm term, required Set<UndergraduateExamType> types})`:不可变、仅本科学期、至少一种;`List<UndergraduateExamType> get orderedTypes`、`Uri uriFor(UndergraduateExamType type)`、`static UndergraduateExamQuery? fromSchedule(ExamSchedule? schedule)`。
- `Course`新增兼容可选 `String courseCode = '', notes = ''`,通用节次允许12。学校对应的11/12节合法性在ImportDraft/快照边界验证;未标本科的第12节数据不得保存。
- `ExamRecord`新增 `String date = '', status = '', notes = ''`、`int? examTypeId`;原有字段语义保持,保存完整安排于 `time`。`ExamSchedule`新增 `SupportedSchool school = SupportedSchool.uestcGraduate`、`Set<int> requestedExamTypes = const {}`;本科必须有效学期及非空1–3类型集合,记录类型须属于该集合,按复合字段去重;研究生保留原code唯一规则。
- `AppSnapshot`新增 `SupportedSchool? selectedSchool`及copyWith,JSON字段名为 `selectedSchoolId`(稳定字符串或null);`ExamSchedule`的学校JSON字段名为 `schoolId`,类型字段为 `requestedExamTypes`(排序的整数数组)。旧数据按可靠学校来源恢复,不能将本科考试编号推断为研究生课表学期。新字段缺失保持旧默认,有明确未知标识则拒绝。
- [x] **Step 1 — 写失败测试。** 验证本科483/503/523/543/563标签、523期末URL精确等于用户地址、空类型/编号504拒绝、学校不同编号相同不相等、`{'id':73}`仍为研究生;本科同课程期中/期末均保留,研究生重复code仍拒绝;旧快照round-trip兼容,未知学校/损坏类型拒绝;本科12合法、未标本科12非法,旧课程新增字段为空。
测试名 `undergraduate semester 523 uses the final exam URL` 的核心断言:
```dart
final term = SchoolTerm(523, school: SupportedSchool.uestcUndergraduate);
final query = UndergraduateExamQuery(term: term, types: {UndergraduateExamType.finalExam});
expect(term.label, '2026-2027第一学期');
expect(query.uriFor(UndergraduateExamType.finalExam).toString(),
'https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id=523&examType.id=1');
expect(SchoolTerm.fromJson({'id': 73}).school, SupportedSchool.uestcGraduate);
```
- [x] **Step 2 — 验证失败。** `flutter.bat test --no-pub test/app/school_term_test.dart test/domain/undergraduate_models_test.dart test/data/snapshot_store_test.dart`,预期缺少新接口/行为失败,不能只因找不到工具链失败。
- [x] **Step 3 — 实现上述接口及序列化边界。** 不更改旧枚举CourseSource及schemaVersion;本科年份用 `(id-3)~/20` 推导,位于模型中的验证不依赖界面。
- [x] **Step 4 — 重跑上述测试。** 预期全部PASS,包括既有研究生测试。
- [x] **Step 5 — 提交。** `feat: model undergraduate schools terms and exam queries`,只暂存本任务文件。
### Task 2: 原子保存与课表/考试学期分离
**Files:** Modify `mobile/lib/app/app_controller.dart`、`mobile/lib/data/json_schedule_import.dart`;Test 新建 `mobile/test/app/undergraduate_controller_test.dart`、既有 `mobile/test/app/app_controller_test.dart`、`mobile/test/data/json_schedule_import_test.dart`。复用 `mobile/test/support/fakes.dart`。
**Interfaces:** Consumes任务1类型。新增 `Future<bool> AppController.selectSchool(SupportedSchool school)`:通过既有串行队列保存,成功true;失败false且lastError可见,不提前更新内存。保留 `applyImport(ImportDraft,{required bool confirmedEmpty})`、`applyExams(ExamSchedule)`签名。
- [x] **Step 1 — 写失败测试。** 本科503考试替换考试列表后原研究生73课表、日期、颜色、提醒偏好及calendarEventIds相同;通知/日历fake调用数不增加。首次本科导课清开始日但保留偏好和独立考试;同本科同学期保留开始日;旧研究生跨学期规则仍成立。存储失败保存前后JSON相同、selectSchool返回false;本科JSON缺少/矛盾学校、混学期、非法12拒绝;旧版本1JSON正常。
测试名 `historical undergraduate exams preserve the course semester`,使用既有controller/fake初始化后:
```dart
final before = controller.snapshot;
await controller.applyExams(ExamSchedule(
semesterLabel: '2025-2026第二学期', termId: '503', records: [],
school: SupportedSchool.uestcUndergraduate, requestedExamTypes: {1}));
expect(controller.snapshot.courses, before.courses);
expect(controller.snapshot.schoolTerm, before.schoolTerm);
expect(controller.snapshot.settings.semester, before.settings.semester);
expect(controller.snapshot.examSchedule!.termId, '503');
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/app/undergraduate_controller_test.dart test/app/app_controller_test.dart test/data/json_schedule_import_test.dart`;预期当前applyExams清课表等断言失败。
- [x] **Step 3 — 实现分支。** 本科applyExams仅更新examSchedule,本科applyImport不清独立考试;新课表来源/学期才清开始日。研究生分支只接收研究生SchoolTerm。JSON显式schoolTerm用于本科来源,不由本科考试推断课表来源。保存成功后才更新内存及协调课程服务;偏好保存不协调服务。
- [x] **Step 4 — 重跑。** 上述测试PASS;恢复备份/损坏存储既有测试不得退化。
- [x] **Step 5 — 提交。** `feat: keep undergraduate exams independent of course terms`。
### Task 3: 本科课表解析和离线课表导入
**Files:** Create本科 `activity_data.dart`、`timetable_adapter.dart`及 `mobile/test/fixtures/school/undergraduate-timetable.html`(脱敏);Modify `mobile/lib/data/schedule_file_import.dart`。Test 新建 `mobile/test/school/undergraduate_timetable_adapter_test.dart`、既有 `mobile/test/school/import_boundaries_test.dart`。
**Interfaces:** `UndergraduateTimetableAdapter.extract(String html, Uri uri, {CourseSource source = CourseSource.school}) -> ImportDraft`。内部 `parseActivityData(String script,{required int unitCount}) -> List<UndergraduateActivity>`;`UndergraduateActivity`保存teacher/name/courseCode/room、`Set<int> indices, weeks`及是否停课,只供适配器使用。
- [x] **Step 1 — 写脱敏等价fixture及失败测试。** 保留附件10门课程、19条活动/18上课+1停课、3条涉及12节、周位串及索引结构;assert每门课程名称/教师/房间/周次/星期/节次映射,停课周6不产生对应occurrence。另加转义引号/Unicode字符串、同课程不同教室和周、多教师、连续节次分组、重复去重、含中文括号名称、名单备注保留;非法索引、缺教师/房间、超范围周、登录页/加载空表/学期矛盾报错;明确空课提示才explicitlyEmpty。
测试名 `undergraduate activity data preserves period twelve` 的核心断言:
```dart
final draft = UndergraduateTimetableAdapter.extract(
File('test/fixtures/school/undergraduate-timetable.html').readAsStringSync(),
Uri.parse('https://eams.uestc.edu.cn/eams/courseTableForStd.action'));
expect(draft.schoolTerm, SchoolTerm(523, school: SupportedSchool.uestcUndergraduate));
expect(draft.courses.map((c) => c.courseCode).toSet(), hasLength(10));
expect(draft.courses.where((c) => c.endPeriod == 12), hasLength(3));
expect(draft.courses.any((c) => c.room == '停课'), isFalse);
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/school/undergraduate_timetable_adapter_test.dart test/school/import_boundaries_test.dart`,新解析器尚不存在,预期FAIL。
- [x] **Step 3 — 实现。** 用HTML DOM寻找manualArrangeCourseTable及课程名单表,解析实际semester.id;受限字符串tokenizer处理TaskActivity参数及 `index = 常量 * unitCount + 常量`、对应activities赋值。按相同教师/房间/周次的连续索引分组,停课按课程序号/星期/节次扣周;稳定ID用完整规范化安排的确定性摘要,不包含学生姓名/学号。名单补课程序号/备注,不能执行任何脚本。页面和文件均检查UTF8字节总量≤2MB。
- [x] **Step 4 — 分派并验证。** ScheduleFileImport按本科课表明确特征分派,其余保持旧graduate/JSON;重跑任务测试PASS。直接读取用户原附件的本地验证只记录数量/映射结果,不将原文件或个人信息提交。
- [x] **Step 5 — 提交。** `feat: parse undergraduate timetable activity data`,含脱敏fixture及测试,不含原附件。
### Task 4: 本科考试解析及文件分派
**Files:** Create本科 `exam_adapter.dart`、`mobile/lib/data/exam_file_import.dart`、`mobile/test/fixtures/school/undergraduate-exams.html`(脱敏)、`mobile/test/school/undergraduate_exam_adapter_test.dart`;既有 `mobile/test/school/exam_adapter_test.dart`保持回归。
**Interfaces:** `UndergraduateExamAdapter.extract(String html,{SchoolTerm? expectedTerm, UndergraduateExamType? expectedType}) -> ExamSchedule`;`merge(UndergraduateExamQuery query,List<ExamSchedule> parts) -> ExamSchedule`;`ExamFileImport.parse(String html) -> ExamSchedule`。extract离线从实际结果字段取学期及类型;merge要求所选类型恰好全部成功、同school/term并去完全重复。
- [x] **Step 1 — 写失败测试。** fixture4条考试归503,外层默认523不能污染;日期、完整时间、地点、座位、情况、类型、说明round-trip。期中及期末同课程均保留;全重复仅一次;缺一类型/mismatched term/type/超500报错;九列表头+有效查询证据+明确无考试提示为空成功,只有表头/登录页不能当空;异常字段不猜时间。ExamFileImport明确分派本科/研究生,未知HTML拒绝。
测试名 `actual result term wins over the outer default` 的核心断言:
```dart
final exams = UndergraduateExamAdapter.extract(
File('test/fixtures/school/undergraduate-exams.html').readAsStringSync());
expect(exams.termId, '503');
expect(exams.records, hasLength(4));
expect(exams.requestedExamTypes, {1});
expect(exams.records.first.date, '2026-06-22');
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/school/undergraduate_exam_adapter_test.dart test/school/exam_adapter_test.dart`,预期FAIL。
- [x] **Step 3 — 实现。** 按九列表头定位实际排考表,读取表单隐藏字段和结果params;两者矛盾拒绝,忽略控件初始化脚本。通过任务1复合身份合并、类型集合保存于ExamSchedule;不给空日期生成伪造日期。
- [x] **Step 4 — 重跑。** 上述测试PASS;用户原考试附件本地验证4条且503,无原HTML进仓库。
- [x] **Step 5 — 提交。** `feat: parse and merge undergraduate exam schedules`。
### Task 5: 本科导航、认证证据与按需捕获
**Files:** Create本科 `navigation_policy.dart`、`page_probe.dart`;Test `mobile/test/school/undergraduate_page_probe_test.dart`、`mobile/test/school/undergraduate_page_probe_js_test.cjs`。复用既有Node JS probe测试的DOM模拟模式。
**Interfaces:** `UndergraduateNavigationPolicy.canNavigate(Uri uri,{required bool isMainFrame}) -> bool`、`isDataPage(Uri uri) -> bool`、`isLogout(Uri uri) -> bool`、`secureIdentityRedirect(Uri uri) -> Uri?`。`undergraduateStatusProbeScript`只返回认证状态证据;`undergraduateCaptureScript`返回限定数据片段。`UndergraduatePageEvidence.decode(Object raw,Uri current) -> UndergraduatePageEvidence`字段 `bool authenticated; List<UndergraduateFragment> pages`;fragment字段 `String kind, html; Uri url`,kind仅timetable/exams;异常/超限抛FormatException。
- [x] **Step 1 — 写失败测试。** exact HTTPS eams/idas/eportal导航、HTTP idas指定登录地址升级;用户信息/自定义端口/相似恶意域名拒绝。状态脚本不包含课表HTML、密码或验证码。capture同源iframe/AJAX DOM实际value523/503覆盖旧attribute;跨源/登录文档不捕获;总量超2MB、深度>4、文档>16拒绝而非部分导入;decode顶层URL不一致/跨源片段拒绝;未知认证回调给明确错误。
测试名 `capture accepts only undergraduate data pages` 的核心断言:
```dart
expect(UndergraduateNavigationPolicy.isDataPage(
Uri.parse('https://eams.uestc.edu.cn/eams/')), isTrue);
expect(UndergraduateNavigationPolicy.isDataPage(
Uri.parse('https://idas.uestc.edu.cn/authserver/login')), isFalse);
expect(UndergraduateNavigationPolicy.canNavigate(
Uri.parse('https://eams.uestc.edu.cn.attacker.test/eams/'), isMainFrame: true), isFalse);
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/school/undergraduate_page_probe_test.dart` 和 `node --test test/school/undergraduate_page_probe_js_test.cjs`,预期缺接口FAIL。
- [x] **Step 3 — 实现。** 同步JS返回JSON字符串,不用WebView不会等待的Promise;认证证据需学校系统页面的已登录菜单/退出入口,不能仅依据host。捕获仅识别表、actual输入字段、名单和必要活动数据,含frame位置;结果参数与实际隐藏值保留。只序列化数据相关节点,禁止整个登录HTML;页内JS只读取文本/DOM,不执行原脚本。
- [x] **Step 4 — 重跑。** 两种测试PASS;既有研究生probe/policy测试保持。
- [x] **Step 5 — 提交。** `feat: capture undergraduate pages with scoped evidence`。
### Task 6: 手动课表连接及自动考试批次
**Files:** Create本科 `session.dart`;Test `mobile/test/school/undergraduate_session_test.dart`、`mobile/test/school/undergraduate_exam_batch_test.dart`,复用/按需扩展 `mobile/test/support/fake_school_web.dart`。
**Interfaces:** `UndergraduateSession extends ChangeNotifier`,延迟 `WebViewController get web`;`Future<void> openTimetable()`、`openExams(UndergraduateExamQuery query)`、`readCurrentTimetable()`、`reauthenticate()`、`logout()`;`void setReadingActive(bool active)`、`invalidateAuthentication()`、`bool saved({required int generation})`。只读状态 `hasOpened, canLogout, busy, awaitingAuthentication, statusLabel, message, pageGeneration`,`ImportDraft? pendingDraft`、`ExamSchedule? pendingExams`及当前query。session不依赖AppController、不自行存储。
- [x] **Step 1 — 写失败测试。** 构造session不创建原生WebView;openTimetable只载eams根,登录状态probe不捕获课表,点击才capture并产生pendingDraft;切换页面时旧capture被丢弃。考试选择1+2+3时逐项精确GET,前两项不产生pendingExams,完成才一个合并结果;第三项失败/显式取消/加载错误保留旧状态无pending;单项45秒/整批120秒边界。等待登录模拟超过120秒不失败;过期认证丢弃已读片段,认证后从类型1重新读取。重复点击、旧finished回调、route关闭、网页logout、dispose不能推进旧批次;fake Cookie清理失败保持可重试退出状态。
测试名 `closing the route invalidates pending batch results`,fake驱动已打开批次后:
```dart
final openedGeneration = session.pageGeneration;
session.setReadingActive(false);
expect(session.pageGeneration, greaterThan(openedGeneration));
expect(session.pendingExams, isNull);
expect(session.saved(generation: openedGeneration), isFalse);
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/school/undergraduate_session_test.dart test/school/undergraduate_exam_batch_test.dart`,预期FAIL。计时使用测试虚拟时间,不真实等120秒。
- [x] **Step 3 — 实现。** 为每次打开/查询分配generation,任何await后校验generation和routeActive;start/finish匹配当前URL及同源证据。自动GET采用loadRequest,finished后有限轮询DOM直到可识别结果;计时及轮询在离页/登录等待/取消时清除。认证过期停读,重新认证启动新generation重读全部;全部完成调用任务4merge一次。manual capture只有readCurrentTimetable调用。saved只消费匹配generation结果;旧结果不能消费新pending。
- [x] **Step 4 — 重跑。** 新session测试及 `test/school/school_session_flow_test.dart`、`test/school/lazy_school_session_test.dart`均PASS。
- [x] **Step 5 — 提交。** `feat: connect undergraduate manual timetable and exam batches`。
### Task 7: 本科浏览器页面、查询选项和导入确认
**Files:** Create本科 `auth_page.dart`、`exam_query_picker.dart`;Modify `mobile/lib/features/profile/import_preview.dart`;Test `mobile/test/widgets/undergraduate_auth_test.dart`、`mobile/test/widgets/undergraduate_exam_picker_test.dart`。必要fake平台widget只为浏览器容器提供可测替代,不绕过真实session。
**Interfaces:** `UndergraduateAuthPage({required UndergraduateSession session,required AppController controller})`;`Future<UndergraduateExamQuery?> showUndergraduateExamQueryPicker(BuildContext context,{UndergraduateExamQuery? initial,required DateTime now})`。返回null取消,返回validquery才登录;跨school/term课表替换和空课表使用ImportPreview确认,已导入同本科同学期非空课表可直接保存。
- [x] **Step 1 — 写失败测试。** picker一个学期、三种类型多选、禁止空选择、cancel不请求、沿用503及已选类型。手动页显示导入按钮,登录后不自动保存;跨学校/学期取消保留快照;空课表未勾确认不能保存;save失败仍在页面且pending可重试。成功返回课表;考试整批完成保存一次并返回,partial不保存;关页后旧回调不pop其他页面;双击导入只处理一次。
Widget测试名 `the manual importer waits for the import button`,登录后fake页面加载并pump后:
```dart
expect(find.text('导入当前课表'), findsOneWidget);
expect(session.pendingDraft, isNull);
expect(controller.snapshot.courses, isEmpty);
```
- [x] **Step 2 — 运行。** `flutter.bat test --no-pub test/widgets/undergraduate_auth_test.dart test/widgets/undergraduate_exam_picker_test.dart`,预期FAIL。
- [x] **Step 3 — 实现。** init/dispose启停读取并监听session;UI操作和保存用busy保护+mounted/generation检查。课表复制当前generation的draft,确认后调用applyImport并检查lastError;考试调用applyExams后检查错误,成功才saved/pop。替换确认说明学校/学期及开始日需要重设;本科网页选学期文案不引导研究生编号选择器。读失败显示类型、重试、修改查询选项;认证等待提示原网页操作。
- [x] **Step 4 — 重跑。** Widget测试PASS,无未处理异步错误/overflow。
- [x] **Step 5 — 提交。** `feat: add undergraduate import and exam selection screens`。
### Task 8: 接入共用页面、12节及服务计算
**Files:** Modify `mobile/lib/app/hepan_app.dart`、`mobile/lib/main.dart`、`mobile/lib/features/school/school_session.dart`(只增加失效协调方法)、`mobile/lib/features/school/school_selection_page.dart`、`mobile/lib/features/profile/profile_page.dart`、`mobile/lib/features/timetable/timetable_page.dart`、`mobile/lib/features/timetable/week_grid.dart`、`mobile/lib/features/timetable/course_details.dart`、`mobile/lib/features/exams/exams_page.dart`、`mobile/lib/domain/lesson_times.dart`。Test `mobile/test/widgets/school_selection_test.dart`、`mobile/test/widgets/undergraduate_navigation_test.dart`、`mobile/test/widgets/undergraduate_grid_test.dart`、`mobile/test/domain/schedule_engine_test.dart`、`mobile/test/services/notification_service_test.dart`、`mobile/test/services/calendar_service_test.dart`。
**Interfaces:** `HepanApp`新增可选本科session/builder参数,保留原 `school`、`schoolBuilder`测试接口。`WeekGrid`新增 `int periodCount = 11`,只接受11/12;`lessonSlots`末尾加 `LessonSlot(12,1320,1365)`。研究生 `void invalidateAuthentication()`仅取消读取并清连接状态/待导入结果,不创建WebView。考试页新增可选查询选项回调,文件入口改任务4的ExamFileImport。
- [x] **Step 1 — 写失败测试。** 两学校都出现,cancel无副作用;本科选择保存失败不进入浏览器,重启恢复选择。课表刷新/更新学期仍manual,考试refresh复用上次query、查询选项可改;不同学校的term不能进入gradURL。共享退出使两连接失效但未使用WebView不创建,旧数据按用户原退出选项处理。source不同banner准确。
- [x] **Step 2 — 写显示及时间失败测试。** 本科无课当前周也12行,研究生/示例11;周切换后第12节可见,七天固定宽,教师/教室全量且同节数卡片等高;详情显示全称、courseCode、notes。第12节单节occurrence22:00–22:45,9–12连排19:30–22:45,多提醒offset及calendar结束时间均正确、停课周无occurrence。使用360dp及大字体检查溢出。
Widget测试名 `graduate rows stay at eleven after adding period twelve`,分别pump已导入学校的页面后断言:
```dart
expect(tester.widget<WeekGrid>(find.byType(WeekGrid)).periodCount, 11); // graduate
// 再pump本科快照:
expect(tester.widget<WeekGrid>(find.byType(WeekGrid)).periodCount, 12);
expect(lessonSlots.last.startMinute, 1320);
expect(lessonSlots.last.endMinute, 1365);
```
- [x] **Step 3 — 运行。** `flutter.bat test --no-pub test/widgets/school_selection_test.dart test/widgets/undergraduate_navigation_test.dart test/widgets/undergraduate_grid_test.dart test/domain/schedule_engine_test.dart test/services/notification_service_test.dart test/services/calendar_service_test.dart`,预期新导航/12节相关FAIL。
- [x] **Step 4 — 实现。** main只构造Dart session,浏览器延迟;Shell先await selectSchool成功,再分派,取消/失败保持状态。本科考试首次显示picker,refresh用ExamSchedule成功选项;历史课程schoolTerm不被考试改写。退出仅操作已有连接并协调失效,失败保留重试入口。课表periodCount来源于实际schoolTerm,demo强制11;所有布局循环使用截取的slot数量而非新增全局长度。考试页显示自身school/term/types/date/status/notes,空结果文案按设计;任务3/4文件导入沿用preview。保留前台服务检查、Dock/手势及动画。
- [x] **Step 5 — 重跑。** 上述测试PASS;追加运行已有 `test/widgets/complete_metadata_test.dart test/widgets/conflict_metadata_test.dart test/widgets/course_length_test.dart test/widgets/week_swipe_test.dart test/widgets/week_transition_test.dart test/widgets/startup_slow_test.dart test/widgets/logout_recovery_test.dart`,确保教师全量、动画、启动和退出恢复不退化。
- [x] **Step 6 — 提交。** `feat: integrate undergraduate school workflows and period twelve`。
### Task 9: 全量验证、发布与签名/登记备份核对
**Files:** Modify `mobile/pubspec.yaml`、`mobile/lib/app/app_version.dart`、`README.md`、`mobile/README.md`、`docs/school-integration-status.md`、`docs/android-mvp-validation.md`、`dist/README.md`、`dist/TPSchedule-备案信息.txt`;生成发布/调试APK、源码ZIP、脱敏截图及 `.tools/`验证日志。不改用户 `output/` 和登记备份。
**Interfaces:** 上述全部任务完成且回归通过;复用 `scripts/build-android.ps1`、`scripts/signing-info.py`及已有Android16模拟器/发布流程,不重建密钥、不新增依赖。
- [x] **Step 1 — 升版本并完整验证。** 两个版本位置统一0.7.0+18;在mobile运行 `flutter.bat test --no-pub --enable-impeller --reporter expanded`、`flutter.bat analyze --no-pub`,预期全量PASS、No issues found。运行本科及既有研究生Node probe测试。失败追根定位、只在新增变更或失败修复后重跑必要检查,不能跳过。
- [x] **Step 2 — 构建与身份验证。** 先保留当前0.6.2 APK及摘要再构建;根目录运行 `./scripts/build-android.ps1 -ToolchainRoot 'F:/program/app/.tools' -Mode release` 和同命令 `-Mode debug`;构建返回0且APK存在。使用signing-info脚本比较旧/新签名,证书必须等于Global Constraints值;读取包名及versionCode18。校验 `dist/backups/TPSchedule-0.5.4-registration-20261014/SHA256清单.json`全部18项内容摘要不变。
- [x] **Step 3 — Android16/API36检查。** 在模拟器覆盖安装,核对旧数据/选择学校/取消/本科登录按钮/考试多选入口。脱敏离线fixture导入检查12行、课程详情、四条503考试、历史考试后课表不变、重启持久化、换周动画/Dock及无新增启动黑屏;抓关键页面截图。真实账号/验证码及学校回调未实测的部分在记录中明确,不能用模拟测试宣称真实导入通过。
- [x] **Step 4 — 记录和源码包。** 写真实测试总数、静态检查、APK SHA256及限制;更新学校接入说明、本科手动操作和12节作息。源码ZIP使用明确允许的源文件集合,排除原附件、用户个人HTML、`.tools`、密钥、构建缓存、`output`和登记材料;验证解包目录/版本及文件数。签名公开信息可保留,私钥不得进入包。
- [x] **Step 5 — 最终审查并交付。** 按 requesting-code-review 技能审查全量差异(只读),修复会导致错误导入/丢数据/提醒时间错误的发现后验证;核对git diff --check及仅预期改动,再提交 `feat: release undergraduate integration 0.7.0`。沿用已授权本地master集成方式;如果使用隔离工作树,合并前检查没有覆盖用户工作。最终提供APK/源码链接、操作说明和真实登录仍需账户本人验证的边界,不等待上线部署。
## 自审与交接
覆盖映射:设计1–3及7→任务5–8;设计4→任务1;设计5→任务3/8;设计6→任务4/6;设计8→任务1/2/8;设计9→任务3/4/8;设计10→任务9。Review Focus五项均已分配具体测试;本科自动考试不复用研究生自动课表状态机,学校身份不使用枚举index,保存入口全部走串行原子队列。
执行方式建议:**本会话直接执行**,原因是9项任务共享学校/学期及保存接口,顺序实施能减少接口交接成本;完成后做一次独立全量审查。另一选择是逐任务由子代理实施与独立审查,审查更频繁、上下文成本更高。
本文件供用户审阅;确认计划并选定执行方式后,才进入实现。
@@ -0,0 +1,49 @@
# 全学校HTML反馈 Implementation Plan
> **For agentic workers:** Use superpowers:executing-plans to implement this plan inline.
**Goal:** 本科和研究生均可将最近一次课表/考试页面作为可选反馈邮件附件。
**Architecture:** 共用SchoolPageDiagnostic,按学校收集已识别的表格与学期证据。扩展反馈服务的惰性HTML白名单,沿用原multipart和SMTP通道。
**Tech Stack:** Flutter/Dart、html、Android、Python/FastAPI。
**Spec:** ../specs/2026-10-24-all-school-html-feedback-design.md
## Global Constraints
- 0.7.8+26,com.tpschedule.app,原签名不变;备案号蜀ICP备2025169276号-5A。
- 默认关闭,日志+HTML独立同意,2MiB,10分钟内存留存。
- 不上传原始学生资料到仓库,不修改软著0.5.4备份。
## Review Focus
- 失败页面仍可排查,清洗后能重现原解析结果。
- 过期/学校切换/重认证/退出不能附带旧页面,离开导入页可反馈。
- 异步旧页面不能覆盖新页面证据。
- 服务端拒绝新的select/option标签中夹带事件、地址和无关字段。
- 版本与证书、公钥、备案信息和交付包一致。
### Task 1: 缓存、采集与反馈界面
Files: mobile/lib/services/school_page_diagnostic.dart、mobile/lib/features/school/school_session.dart、mobile/lib/features/feedback/feedback_page.dart;对应services、school_session_flow和feedback测试。
- [x] 添加并运行失败测试:研究生课表/考试清洗可重读、失败及HTTP可信页、认证剔除、缓存清理与研究生反馈提交。
- [x] 扩展受限片段采集,在研究生解析前留存;更新选项文案。
- [x] 运行针对性测试并核对旧本科行为。
### Task 2: 服务端结构兼容
Files: server/feedback/feedback_service.py、tests/test_feedback.py、tests/fixtures;部署说明。
- [x] 添加并运行失败测试:真实客户端生成的合成研究生附件可发送,活动内容与其他控件拒绝。
- [x] 按准确ID和学期选项扩展白名单;保留旧客户端兼容。
- [x] 运行反馈与更新服务完整回归。
### Task 3: 发布0.7.8
Files: mobile/pubspec.yaml、mobile/lib/app/app_version.dart、mobile/lib/features/profile/profile_page.dart、docs及dist交付。
- [x] 设置0.7.8+26及备案号,完整Flutter测试和静态分析通过。
- [x] 构建正式APK,核对包名、版本、签名MD5、原证书和用户指定RSA公钥。
- [x] 输出同签名APK、源码、反馈服务更新包和部署说明;核对密钥、旧APK及18项冻结软著文件。
@@ -0,0 +1,10 @@
# Dock 与平板实现计划
本会话直接实现。版本保留0.7.8,构建号递增29。旧APK、登记备份和密钥保持原样。
1. 先修改点按动画测试为新需求,增加连续反向点击、外层联动和减少动画断言,运行并确认失败。
2. 实现单一位置弹簧、短暂点按形变和外层独立布局形变,保留实时折射和稳定图标层。拖动速度和位移必须有界,父组件确认选中状态不能重启动画。
3. 增加平板/分屏布局测试,覆盖七天同屏、宽屏文字、内容限宽、横竖屏切换与操作。实现共用AdaptivePage和宽屏课表文字、Dock宽度适配,并覆盖设置和反馈。
4. 运行受影响测试、完整测试和静态分析;正式构建,检查Android模拟器手机/平板与动画关键帧,审查改动。保存版本化APK、源码、更新说明,校验签名/旧文件与登记备份。
审查重点:连续点击时位置连续;外层形变采样与实际布局一致;拖动取消无误切页;窗口缩放不重置标签/周;小窗口及大字体无溢出;减少动画无弹性。
@@ -0,0 +1,9 @@
# 0.7.10 导入问题定向修复
用户取消反馈学校选择:不增加学校选择步骤,保留现有日志 school 标记。用户已授权直接实现。
- [x] 本科日志定位为第 15 条活动 room_missing。用匿名样本复现空教室整表失败,保留完整周次/节次/教师,空教室显示“教室待定”,课程备注和导入提示解释来源。教师、课程号、周次和节次仍严格校验,不跳过错误课程。
- [x] 研究生日志仅有 UnsupportedSchoolLayout,没有 HTML,不能确定具体字段。为解析各阶段增加可公开的原因码和行列号,保留失败快照;修正解析失败最终被误报为网络超时。对纯数字课程号限制及空教室补充严格格式下的兼容测试,不能声称这些就是同学的确切原因。
- [x] 红绿回归、全量 Flutter 测试、静态分析、正式构建、独立审查、源码与 APK 发布完整性/原签名核对。本次作为 0.7.10+36,保留所有既有版本和登记冻结材料,不修改线上服务器。
验证重点:空教室不丢课程;正常教室/教师不变;畸形时间仍拒绝;未知研究生格式不清空旧课表;具体错误及行列码可见但不输出课程内容;换页后不能沿用旧错误原因。
@@ -0,0 +1,73 @@
# 河畔 Everyday 首版设计
日期:2026-10-12。用户已确认聊天中展示的设计,并指定优先配置 Android;用户随后明确确认了此设计文件。采用 Flutter,为后续 iOS 保留共用界面与业务代码。首轮交付 Android 可安装测试包,iOS 打包另行安排。
## 目标与范围
面向电子科技大学研究生,把学校课表导入手机,按周离线查看课程,并在课前收到提醒。首版只有课表和我的两个标签页。课程、设置和同步结果保存在设备上。示例课程必须显著标注,不能被当作学校导入结果,也不能默认生成真实提醒或日历事件。
## 界面
整体采用浅色、河水蓝绿色点缀、留白与圆角。底部 Dock 使用 Flutter 模糊滤镜、半透明渐变、细边框和高光模拟液态玻璃,包含日历与个人两个图标,不显示文字;保留无障碍标签。Dock 避开系统手势区域,不遮挡最后一节课程。
课表页顶部显示学期、本周日期范围和当前周次,支持上一周、下一周与回到本周。周一到周日以七列呈现;左侧显示节次和时间。小屏上课程列采用最低可读宽度并支持横向滚动,顶部日期与课程列同步。今天列高亮。晨间、午间与晚间分组,休息间隔不形成课程节次。连续多节课程呈现一个跨节次的课程条,显示课程名、教师和教室,点击详情展示完整内容及上课周次。同一时段冲突的课程并排呈现并提示冲突,不静默覆盖。
我的页:未认证时显示 APP 自有登录界面和学校认证说明;已认证时显示连接状态、最近成功导入时间、课程数量、重新同步和退出登录。学校认证成功与课表导入成功分开表示。没有验证用户姓名时不生成虚假个人资料。设置区在未登录时也可访问。
设置:单色/多彩课程配色,单色时可选择主题色;提醒可关闭或选择提前 5、10、15、30、60 分钟,默认 15 分钟;手机日历导入开关;学期开始日、学期总周数(1–30,首版初始值 20,需用户确认)。首次使用要求选择真实学期开始日,不推断学校校历。
## 节次时间
时间规则以用户提供的信息为准,采用 Asia/Shanghai。节次:
|节次|开始|结束|
|---|---|---|
|1|08:30|09:15|
|2|09:20|10:05|
|3|10:20|11:05|
|4|11:10|11:55|
|5|14:30|15:15|
|6|15:20|16:05|
|7|16:20|17:05|
|8|17:10|17:55|
|9|19:30|20:15|
|10|20:20|21:05|
|11|21:10|21:55|
学期开始日所在的周一至周日为第 1 周;首周开始日前不生成课程实例。课表需处理上课周范围、不连续周次与单双周。每条课表记录保存明确的上课周次集合,不把描述字符串直接用于提醒计算。
## 学校认证与导入
入口为 https://idas.uestc.edu.cn/authserver/login ,课表目标为 https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1 。已读取公开认证页面;目标课表页面尚未在认证会话中核实。不能宣称已经完成真实接入,也不编造学校接口或页面字段。
选定方案是设备本地 WebView 会话,搭配 Flutter 自有登录输入界面。只有在学校官方 HTTPS 页面且所需表单与提交逻辑经过验证时,才能把用户输入交给学校原有表单处理。密码仅在本次认证时使用,用后清除,不写入磁盘、日志、分析服务或自建后端。验证码、二次验证或页面变动时显示原学校认证页供用户完成认证;不绕过学校认证机制。学校跳转到新的认证域名时停止自动注入凭据并展示跳转信息。
登录后沿学校实际 SSO 路径进入课表页,同一 WebView 保留学校管理的会话 Cookie。只读取经确认的学校课表页面数据。解析层从页面提取名称、教师、教室、星期、节次、上课周次与学期信息,转换为结构化记录;将解析结果展示给用户确认后替换本地课表。若页面不是预期结构、字段缺失或课程数为零,保留旧课表并显示原因,不能用空结果覆盖。零课程仅在页面明确表示无课程且用户确认时接受。
在无法取得登录后页面样本时,首版完成认证入口与解析适配边界,明确标记真实课表识别尚待联调。提供示例体验和结构化文件导入以检验完整本地流程;它们不伪装成自动学校导入。后续需由用户在本人设备登录以核实学校页面,账号密码不要求在聊天中提供。
退出登录清理学校 WebView 会话,同时让用户选择是否保留已导入课表。保留课表时继续按本地设置工作;清除课表时一并取消对应通知与本 APP 创建的日历事件。
## 数据与模块
课程记录包含稳定 ID、名称、教师、教室、星期(1–7)、起止节次(1–11)、上课周次集合和来源。设置包含学期开始日、总周数、颜色模式与主题色、提醒提前量、日历导入状态。导入结果保存来源、成功时间和解析版本。
模块分为 UI(课表/我的/设置/认证)、纯 Dart 课程与学期计算、学校认证与解析适配器、本地存储、通知和日历服务。状态由单一应用状态层管理;平台服务用接口隔离,便于测试和后续 iOS 实现。暂不增加服务器、账号注册、社交功能或云同步。
## 提醒与手机日历
每次连续课程只在开始前提醒一次;通知内容包含课程名、教室和教师。只安排未来且在实际上课周次内的实例。修改课表、学期或提醒设置后,用稳定标识取消旧任务再重建,避免重复。设备重启后的恢复通过 Android 原生支持处理。只有用户开启提醒时申请通知权限;需要精确提醒时根据系统要求申请精确闹钟访问,若不获准则明确告知时间可能延迟。设备厂商省电设置仍需真机验证。
手机日历默认关闭。开启后请求日历权限并让用户选择日历;仅创建本 APP 课表对应的事件,保存事件 ID 以便同步修改和去重。关闭时询问是否删除本 APP 创建的事件,不影响其他日历事件。事件包含正确的日期、Asia/Shanghai 时区、连续节次起止时间、教师和教室。APP 通知与日历提醒分别管理,默认不为日历事件额外添加提醒,以免双重提醒。
## 异常处理与验收
网络不可达、校内网络限制、认证过期、验证码和解析失败均展示具体状态;已导入课表仍可离线查看。权限拒绝时对应功能显示未启用,不假装已生效。导入文件与网页数据都校验节次、日期、周次和必填字段。
验收包含:11 节时间计算;学期开始日前、学期外、单双周与不连续周次;课程冲突;跨节次显示;七天可达与 Dock 安全区域;设置重启后保持;导入失败保留旧数据;通知去重与关闭清理;日历去重与只清理本 APP 事件。真实认证、学校解析、系统后台提醒与日历写入必须在 Android 设备上核实,测试包编译成功不能替代这些验证。
## Android 环境与交付
Windows 上先配置项目本地 Flutter 稳定版、Android SDK、兼容 JDK。工具与缓存位于 .tools 并从版本控制排除;使用 scripts/android-env.ps1 激活当前终端环境。先确保 flutter doctor 的 Android 工具链检查通过,再进入产品实现和构建。没有连接真机或模拟器时,报告设备验证尚未完成。
交付源代码、启动与构建说明、测试结果及能够成功构建的 Android 测试 APK。未完成的学校联调以明确的状态和后续步骤记录,不包装为已经可用。
@@ -0,0 +1,198 @@
# TPSchedule:电子科技大学本科生接入设计
日期:2026-10-14。
接入方案已获用户确认。本文件供书面审阅,应用目前仍为0.6.2+17;计划实现后发布0.7.0+18。软件名称TPSchedule、包名 `com.tpschedule.app`、原签名及登记冻结材料保持。
## 1. 目标与已确认要求
学校列表增加「电子科技大学(本科生)」,与「电子科技大学(研究生)」分别连接各自系统,共用现有课表、考试、设置、课程提醒及日历功能。
本科课表采用手动导入:学生先在应用内网页登录本科教务系统,再自行打开课表、选择学校网页上的学期,最后点击应用的「导入当前课表」。不在登录后自动读取或导入课表。
本科考试采用自动导入:学生在应用内选择一个学期,并多选期末、期中、补考/缓考;应用自动依次读取这些类型,全部读取成功后一次更新考试列表。学校和成功查询的选项保存在本机,后续更新沿用。
本科增加第12节,22:00–22:45;其余作息与现有研究生一致。研究生课表继续显示11节。本科查询历史考试保留当前课表,读取失败保留旧数据。
本次不增加其他学校、考试提醒、账号密码表单、后台静默学校抓取或新的服务器接口。Dock和换周动画沿用现有实现。
## 2. 附件与解析依据
用户提供的本科课表附件为 `eams.uestc.edu.cn_eams_courseTableForStd.action.html`,考试附件为 `stdExamTable!examTable.htm`。原文件保存在用户桌面;仓库测试使用脱敏的等价结构,不提交包含学生个人资料的原始网页。
课表样本中:
- 当前学期的隐藏字段 `semester.id` 为523。
- 页面包含10门课程,19条 `TaskActivity` 数据。其中18条为上课安排,1条为「停课」;3条上课安排涉及第12节。
- `unitCount = 12`,`CourseTable(2026, 84)` 对应7天、每天12节。
- `TaskActivity` 的字段包含教师、课程标识和名称、教室、周次位串。随后 `index = day * unitCount + period` 表示星期和节次,二者从0开始。
- 周次位串的第0位为保留位,第1位对应第1周。样本还包含同一课程不同周次使用不同教室、多位教师及停课信息。
考试样本中:
- 排考结果实际属于503,即2025–2026第二学期,包含4条期末考试记录。
- 排考结果的隐藏学期字段和 `params` 均为503;外层学期控件的初始化脚本仍带默认值523。不能将这个默认值当成结果所属学期。
- 表头为「课程序号、课程名称、考试日期、考试安排、考试地点、座位号、考试情况、考试类型、其它说明」。
- 类型值1、2、3分别对应期末考试、期中考试、补缓考。
解析只读取这些数据结构,不执行附件中的脚本,也不调用考试申请或取消申请操作。
## 3. 用户流程
### 3.1 选择学校
「我的 → 选择学校」提供研究生和本科生两个选项,沿用蓝色界面。取消选择不改变学校、不开始登录、不修改已导入数据。
确定选项后保存连接目标。保存失败时留在原页面,显示失败信息,不切换到新学校浏览器。仅更换连接目标不会删除现有课表或考试;数据来源在各自页面显示,避免把旧学校数据误认为新导入结果。
### 3.2 本科课表
1. 打开 `https://eams.uestc.edu.cn/eams/`。
2. 学生在学校原网页完成登录、验证码或学校要求的其他认证。
3. 应用提示「请在学校网页打开课表并选择学期,然后点击导入当前课表」。学生自行操作学校页面。
4. 点击「导入当前课表」后,读取当前页面及其同源课表框架,校验课表和实际学期,解析安排。
5. 确认成功解析后保存课表,返回应用并展示实际导入的学期及课程。跨学校或跨课表学期替换时,先说明将替换现有课表并要求确认;取消确认保留原数据。
6. 新学校或新课表学期需要重新设置学期开始日,复用现有首页提示;同一学校、同一学期更新保留开始日及周数设置。
本科课表刷新与「我的 → 更新学期」均重新打开本科网页流程,仍由学生在网页上选择学期、点击导入。不能将本科课表刷新变成自动跳转查询或自动导入。
### 3.3 本科考试
首次本科考试导入展示一个学期选择器及三个可多选类型:期末考试、期中考试、补考/缓考。至少选择一项,默认期末。已保存过本科查询时默认使用上次成功的学期和类型。
确认后,未认证时先打开学校原网页认证;确认进入已登录的本科系统后自动读取排考结果。已连接时直接读取。按类型编号1、2、3顺序访问所选类型,并展示进度,例如「正在读取期中考试(2/3)」。
全部类型校验成功后合并并保存一次,显示所选学期、类型及考试记录数。已识别的某类型空结果属于成功,该类型没有记录;所有类型均为空时显示「所选考试类型暂未安排」。登录页、未知页面或未加载完毕的内容不能当作空结果。
考试页的刷新沿用上次成功查询。增加「查询选项」入口供学生重新选择学期和类型;取消或失败不覆盖已保存查询选项。换到新的学校连接目标时,先选择该学校的查询选项,不借用另一学校的学期编号。
本科考试导入只更新考试数据及查询选项,不清空课表、不改变课表学期或学期开始日。选择历史学期后,课表仍显示已导入的课表;考试页明确显示自己的学校和学期。
## 4. 学校与学期标识
将学校目录从界面文件移至独立的学校标识模块,使用稳定字符串标识 `uestc_graduate`、`uestc_undergraduate`,不以枚举序号持久化。
学期对象带学校标识和学校自己的编号。相等比较同时检查学校和编号,避免将不同系统的编号互换。研究生编号、标签、URL规则保持;旧 `{"id":73}` 默认解释为研究生学期。
本科按用户指定规则,以523为2026–2027第一学期,每向前一个学期减20,向后加20:
| 本科编号 | 学年学期 |
| --- | --- |
| 483 | 2025–2026第一学期 |
| 503 | 2025–2026第二学期 |
| 523 | 2026–2027第一学期 |
| 543 | 2026–2027第二学期 |
| 563 | 2027–2028第一学期 |
本科有效编号满足 `id >= 3` 且 `(id - 3) % 20 == 0`;沿用编号不超过9999的输入边界。令 `n = (id - 3) / 20`,起始学年为 `2013 + floor(n / 2)`,n为偶数时第一学期、奇数时第二学期。该形式避免向前计算时负数整除导致年份错误。
本科选择器默认已保存查询学期,否则523。列表从2013–2014第一学期起,列至设备当前年份下一学年的第二学期,并始终包含有效的已保存学期;显示标签,不要求学生填写数字。手动课表导入以页面实际选中的学期为准。
本科考试请求格式:
```text
https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id={编号}&examType.id={类型}
```
本科请求由本科查询对象构建,不调用研究生学期URL构建器。
## 5. 课表解析与显示
新增本科课表适配器,使用现有HTML解析依赖及受限的JavaScript数据字面量解析。只解析 `TaskActivity` 字符串参数及可识别的节次索引赋值,不使用eval或重新执行网页脚本。在线导入、离线本科HTML导入共用此适配器。
每条安排解析课程全称、课程序号、所有教师、教室、星期、起止节次和有效周次。课程名称末尾的课程序号可单独保存,不能误删课程名称内的中文括号。
按星期、连续节次、教师、教室和周次分组;相同的重复安排去重,不同周次、教师或教室的安排分别保留。停课记录不形成课程或提醒,并从同一课程序号、星期、节次的有效安排中扣除相应周次。
课程标识包含本科来源、学期及完整安排的稳定摘要,不使用学生姓名或学号。周次有效范围仍为1–30;位串其他位置必须为0,不能截掉异常周次后假装成功。节次范围1–12;未知索引表达式、缺失教师/教室、矛盾学期或无法识别的页面报错,保留旧课表。
零安排只有在已识别课表结构、实际学期及明确空课提示后,才能请求学生确认导入空课表;不能把加载中的表格当成无课。
课表依旧七列同屏、左右滑动换周、相同节数的课程条等长。教师和教室完整显示;多位教师使用现有共享行高测量,不能截断教师名单或单独拉长一张卡片。课程全称和备注在详情中保留,卡片名称允许省略。
研究生和示例课表显示11节。本科课表显示12节;显示行数依据已导入课表的学校,而不是刚选中的连接目标。课表内容仅部分周次排至第12节时,整学期也保持12行。
现有前11节时间保持,第12节为22:00–22:45。提醒和课程日历通过现有发生时间计算使用第12节结束时间;考试不加入课程提醒或手机日历。附件中的特殊上课时间备注作为详情信息保留,提醒按本次确认的节次作息计算。
## 6. 考试解析、合并与失败处理
新增本科考试适配器,按表头定位列,保存课程序号、名称、考试日期、完整考试安排、地点、座位、情况、类型及说明。没有公布的时间或地点显示「尚未安排」,不推算补齐。
在线解析绑定正在请求的学校、学期和考试类型。优先验证实际结果表单的隐藏字段、结果参数及记录类型;忽略外层控件初始化默认值。任何明确的结果学期或类型与请求矛盾时,拒绝当前批次。离线文件根据实际结果字段确定学期和类型,不将503样本归入523。
多类型考试使用复合身份去重,包括学校、学期、类型、课程序号、日期、时间、地点及座位。同一课程的期中和期末不能互相覆盖;完全重复的记录只保留一次。研究生记录继续按原方式验证,旧记录缺失新增字段时使用兼容默认值。
每次查询拥有独立批次号。依次导航至所选排考URL,等页面完成加载并出现可识别结果,再解析该类型;不在每个类型完成后逐次写入本机。合并后的记录总数上限500。每次页面捕获的所有片段合计不超过2MB,离线单文件不超过2MB;超限报错,不能截断后导入。
自动读取单类型等待上限45秒,整个读取批次上限120秒;计时从自动访问排考页面开始,不包含学生登录及输入验证码的时间。进入等待认证状态时停止读取计时,重新认证后启动新批次并重置计时。任一类型失败、超时、结果矛盾或用户取消,丢弃本次临时结果,保持已有考试和查询选项。界面指出失败类型,允许重试或修改查询选项。
认证过期时停止读取并让学生在学校网页重新登录。重新认证后从该批次的第一个类型重读,避免将认证前后的不同账号结果合并。更换查询选项、离开页面或退出学校登录使旧批次失效;过期的网页回调不能覆盖新数据。
## 7. 本科浏览器连接
本科使用独立连接器和浏览器页面,研究生保持现有连接器及其自动导入流程。两者共用学校目录、状态展示和退出协调,不将本科的手动模式塞入研究生的自动识别条件。
浏览器仅在打开该学校连接时创建,普通离线启动不创建本科WebView。认证仍使用学校原网页,不注入或读取密码及验证码。
本科导航允许精确的HTTPS官方域名 `eams.uestc.edu.cn`、`idas.uestc.edu.cn`、`eportal.uestc.edu.cn`。数据读取只允许 `eams.uestc.edu.cn` 的 `/eams/` 页面及相同来源框架;统一认证页面只用于登录,不能作为数据来源。统一身份认证的HTTP登录跳转沿用已有HTTPS升级规则,未覆盖的回调提示重试,不扩大到任意域名。
课表捕获只在点击「导入当前课表」后发生,取实际学期字段、课表容器、所需课程列表和活动数据。复制字段时读取当前DOM属性值,避免序列化过期的默认属性。同源文档最多遍历4层、16个文档,总捕获大小不超过2MB;超限报错。检测到密码表单或不属于本科来源的框架时不捕获内容。
登录状态检测只返回状态证据,不能触发课表保存。自动考试查询只访问排考GET地址,不执行考试申请、取消申请等表单。
浏览器页提供返回、重新认证、状态提示;手动课表模式提供明确的「导入当前课表」按钮,考试模式显示所选学期、类型和读取进度。离开页面取消相应读取及计时。识别本科网页的退出地址时使本科连接和当前批次失效;应用清除共享Cookie退出时同步使两套连接状态失效,避免另一页面仍显示已连接。退出协调不为未使用的学校创建浏览器。
## 8. 本地数据与兼容
现有保存格式的 `schemaVersion: 1` 保留,添加可选字段并提供旧数据默认值:
- 连接目标:可空的 `selectedSchoolId`。旧数据根据已识别的学校学期或考试来源恢复研究生选项;无学校数据时仍要求先选学校。
- 学校学期:既有 `schoolTerm` 增加 `schoolId`,缺失时默认研究生。本科该字段记录课表实际学期。
- 考试列表:`examSchedule` 增加学校和成功请求的类型集合;自身 `termId` 仍记录考试学期。新增考试字段为兼容可选项。
本科考试刷新使用考试列表保存的学期及类型,不改课表的 `schoolTerm`。研究生已有跨学期更新规则保持兼容。
成功导入本科课表时仅替换课表相关数据及来源;新学校或新课表学期清除旧学期开始日,保留提醒时间点、颜色和课程条长度等偏好。本科考试列表独立保留并显示其来源,不混入课表来源。
更换学校连接目标只保存偏好,不提前清空课程。课表和考试页始终展示各自保存数据的学校及学期;连接目标与旧课表不同期间,明确提示当前仍为旧学校课表。
未知学校标识、无效本科学期、空考试类型集合或损坏记录拒绝加载/导入,沿用现有备份恢复入口。所有导入和偏好保存使用现有串行、原子保存机制;保存失败保留原数据。课表变更成功后协调课程提醒和日历;本科考试保存不触发课表变更的协调流程,现有应用回到前台时的服务检查保持。
## 9. 文件导入与模块划分
离线 `.html/.htm` 导入根据明确的本科/研究生页面特征分派适配器,继续先预览或确认后保存。课表JSON沿用版本1,扩展学校标识;旧JSON不需要补填新字段。
实现分为以下职责:
| 职责 | 文件或模块 |
| --- | --- |
| 学校稳定标识及可用目录 | `domain/school_identity.dart`、现有学校选择页面 |
| 学期编号和标签 | `domain/school_term.dart` |
| 本科考试类型、查询参数与URL | `domain/undergraduate_exam_query.dart` |
| 本科课表和考试数据解析 | 新本科课表/考试适配器 |
| 登录、手动捕获、自动考试批次 | 新本科连接器及浏览器页面 |
| 查询选项 | 新本科考试选择组件 |
| 保存、旧数据默认值及来源展示 | 现有Snapshot、AppController、课表/考试/我的页面 |
| 第12节、显示行数与发生时间 | 现有LessonTimes、WeekGrid及ScheduleEngine |
不修改安卓签名配置,不重建密钥,不重新生成0.5.4登记冻结或V1.0软著材料。
## 10. 验收与交付
实现阶段先为新行为写回归并观察失败,再实现和运行完整Flutter测试、静态检查及Android构建。
验收至少覆盖:
1. 两个学校选项、取消和重新进入;学校选择保存失败不导航;离线启动保持浏览器延迟创建。
2. 本科学期483/503/523/543/563标签及URL;非法编号拒绝;本科编号不进入研究生请求。
3. 附件等价结构的10门课程、18段上课安排、停课周扣除、周次位串、星期和节次索引、跨周换教室、多人教师及第12节。
4. 手动导入前没有课表读取/保存;登录页、不完整页面、空结果确认、同源框架及过期回调处理。
5. 考试样本4条归属503,不被默认523污染;期中/期末同课程保留;重复去重及未排考显示。
6. 学期和多选类型校验、自动访问顺序、全部成功后一次保存、单项失败/超时/取消不覆盖、认证恢复后整批重读。
7. 重启后复用学校和考试选项;历史本科考试不清课表;旧研究生保存文件及JSON仍正常读取。
8. 本科12行、研究生和示例11行;教师/教室完整、同节数条长一致;第12节22:45结束,提醒和课程日历使用正确时间。
9. 保留旧版签名及登记备份摘要,覆盖安装保留用户数据。
交付计划包括发布APK、调试APK、更新源码包、验证记录及关键页面截图。真实本科账号的验证码及在线抓取需在学校原网页由账户本人完成验证;离线附件和模拟回调只能验证结构与流程。本次不要求用户在聊天中提供密码。
本文确认后编写实现计划,再按选定执行方式进入代码修改。
@@ -0,0 +1,15 @@
# 0.7.8:本科、研究生导入页面HTML反馈
用户要求反馈问题时两类学生均可选附带最近一次导入页面HTML;版本定为0.7.8,保留原签名,并展示备案号蜀ICP备2025169276号-5A。沿用现有反馈附件和邮件协议,不另建上传通道。
## 实现范围
- 使用现有共享SchoolPageDiagnostic内存缓存扩展研究生页面;研究生自动/手动读取课表、自动读取考试均在解析前留存已验证的页面片段,失败也可反馈。
- 本科保持原流程。研究生只接受学校现有导航策略中的精确域名、端口和pyxx路径。登录页、密码字段和认证材料不进入附件。
- 保留研究生课表/考试表格的受限ID、节次单元格ID、对应学期选择框;保留解析失败的内容以便排查。去除活动脚本、事件、表单和网络资源。服务端按同样的最小结构白名单校验。
- 选项改为“附带最近一次学校导入页面HTML”,说明适用于本科/研究生;默认关闭,需勾选日志并同意个人信息提示。HTML独立附件、上限2MiB、有效期10分钟,成功发送后清除。切换学校/重新认证/开始新查询/退出清除,离开导入页前往反馈不清除。
- APP版本0.7.8+26,包名com.tpschedule.app不变;证书MD5必须为78DE9119AD14727FA1C5E5E4E68145EE,RSA公钥与用户给出的十进制值核对。原密钥和0.5.4软著冻结文件不修改。
## 方案选择
共用现有HTML缓存和multipart字段diagnostic_html,扩展安全结构即可。另建研究生上传协议会重复客户端/服务端处理;把原始整页HTML加入普通日志会破坏既有隐私边界,因此均不采用。用户已明确要求直接修改,无额外审批步骤。
@@ -0,0 +1,13 @@
# Dock 弹性与 Android 平板适配
用户要求参考本地视频 20261028-104403.mp4:点按和拖动均有弹性落位,拖动滑块带动外层 Dock,玻璃下内容可辨,图标文字清楚;同时支持平板。沿用用户已授权直接执行的工作方式,不追加确认流程。
采用现有 Flutter 弹簧动画和实时背景折射,不引入浏览器或新玻璃组件库。相比整体放大变换,这样能通过实际布局更新玻璃区域,避免非等比变换造成采样坐标偏移;相比仅加大模糊,可保留背景颜色与轮廓。
- 滑块:点按短促伸缩和轻微弹簧落位;连续点击从当前位置、速度接续;拖动仍放大,松手回弹,取消不换页。
- 外层:按压与拖动引起小幅扩张,轻微向滑块所在侧延伸;文字和图标单独绘制,保持稳定清晰。静止仍为灰色滑块、浅白通透玻璃、蓝色图标白光。高度保持紧凑。
- 无障碍:减少动态效果时直接落位,不播放放大、抖动或外层形变。
- 平板:根据当前窗口宽度适配,兼容横竖屏和分屏。宽度不足600逻辑像素维持手机布局;宽屏课表最多1440,考试/个人页最多840,设置/反馈最多760,居中留边。课表保持七天同屏,并在宽列增大课程和时间文字。Dock 平板宽300、高58,手机264×58;不添加侧栏。
- Android 沿用可旋转/可调整大小的默认 Activity,不改变签名、权限、包名及持久化格式。不宣称已验证 iPad。
验证:点按弹性、快速反向点按、按住/拖动外层变化、取消、减弱动态效果;手机与800×1280、1280×800平板和分屏布局无溢出,七天可见,切页/周状态保留,设置/反馈可用;完整测试、分析、正式签名APK和模拟器视觉检查。
+50
View File
@@ -0,0 +1,50 @@
# TPSchedule Android 0.5.0+8 验证
日期:2026-10-13。用户确认显示名称TPSchedule,Android应用ID为com.tpschedule.app;原公钥、私钥与签名配置保持不变。
## 品牌与启动
原始图标E:/工作室logo/APP.svg复制至mobile/assets/branding/APP.svg。两者SHA256均为42263BAC3AE2C2843CA4304BACE3EB7835FF6263C08F94F232C966C0A3BBBE43。Flutter与原生向量保留蓝色渐变、黄色日历框、白色笔画和原几何数据。
Android24–25使用完整向量图标,26+使用自适应图标,33+提供主题图标。自适应及原生启动前景均按安全区域缩放,原生桌面查看图标完整。
旧版安卓原生启动屏采用蓝色渐变和标志,Android12+系统启动屏按平台约束使用同色纯蓝与标志;Flutter初始化期间使用原渐变背景、标志和TPSchedule文字。初始化完成即进入页面,仅淡出220ms,没有固定等待。原生录屏实际捕获Android12+系统启动屏,未为了截图增加停留时间。
参考:[Android应用ID](https://developer.android.com/build/configure-app-module#application-id)、[系统启动屏](https://developer.android.com/develop/ui/views/launch/splash-screen)、[自适应图标](https://developer.android.com/develop/ui/compose/system/icon_design_adaptive)。
## 玻璃材质
根据用户关于雾化过强的反馈,将底座模糊从sigma8降至2,滑块从6.5降至1.6,按住时渐变至1;滑块灰色覆盖从19–26%降至8–14%,按住时约2%。底座仅保留很淡的透明着色,并收窄柔化亮边,背景轮廓与颜色更清楚。保留实时光线弯曲、色散、附近背景光的边缘反射、蓝色实心图标、点按平滑切换、按住放大约30%、拖动回弹和滚动隐藏动画。
## 验证
- 完整Flutter测试139项通过,使用--enable-impeller,包含真实着色器与模糊组合、移动后折射位置、背景高频细节衰减、颜色透射、动态边缘反射和Dock操作。新材质在12px条纹样例中,底座与滑块的明暗对比均高于90;旧版模糊滑块约20。细小纹理仍被轻度柔化。
- 图标像素测试验证黄框、白色笔画、透明前景;启动背景覆盖浅深模式。GPU渐变量化存在1–2色阶差异,颜色断言允许3色阶容差。
- 初始化测试确认完成前显示品牌页、完成后直接显示应用,无固定等待。
- 静态分析No issues found;原生资源aapt2编译和链接通过。Debug和release APK构建成功。
- 原始学校HTML已不在先前桌面路径,本次未重跑附件解析;使用项目内脱敏样例验证导入、学期和认证流程。0.4.0附件验证记录保留。
- release构建需重新生成排除测试依赖的插件注册代码;仅--no-pub切换模式会保留旧注册代码。构建脚本先准备离线依赖,再让Flutter生成当前模式信息;必要的公开依赖信息刷新需联网。签名配置未修改。
## 安卓实际检查
API36模拟器成功安装com.tpschedule.app,与旧RSchedule并存。桌面名称TPSchedule、图标、原生启动、个人页名称以及Dock模糊/按压效果均已查看。为展示课程下方的玻璃效果,向新包手动复制了模拟器中的11条课程测试数据,不代表自动迁移功能,也不预置在分发包中。随后release包成功覆盖同包debug版并保留该测试数据。
实际截图及录屏位于dist/screenshots:tpschedule-logo.png(组件渲染)、tpschedule-launch.png、tpschedule-launch.mp4、tpschedule-launcher.png、tpschedule-week.png、tpschedule-profile.png、tpschedule-dock-held.png。
本机安卓模拟器GPU驱动仍有限制,原生检查使用启动参数开启软件渲染并关闭Impeller;截图展示模糊玻璃兼容效果。交付APK保留手机默认渲染方式。实时折射、模糊组合与背景反射已在支持该路径的Flutter测试渲染器中进行实际像素验证,仍需用户手机验证具体视觉与性能。
## 包名与签名
release APK:com.tpschedule.app,versionName0.5.0/versionCode8,minSdk24/targetSdk36,约55.1MiB,无debuggable标记。旧包com.example.hepan_everyday不能被新包覆盖,需重新导入课程和设置日期;旧包和数据保留。
apksigner验证通过。新debug、release与旧0.4.0 APK签名证书DER完全一致,因此证书及公钥未变:
- 证书SHA256:AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。
- 证书SHA1:DFC7E56F60C78CAC14E38519E89F46C615D456D7。
- 证书MD5:78DE9119AD14727FA1C5E5E4E68145EE。
dist/TPSchedule-signing-info.json、TPSchedule-signing-certificate.pem、TPSchedule-public-key.pem及TPSchedule-备案信息.txt只包含公开信息,未导出私钥。原签名文件.tools/android-user/debug.keystore保持原位置,不随源码归档分发。
当前保留证书Subject为CN=Android Debug,O=Android,C=US。release运行模式不代表更换为正式发行证书;本次遵照要求不换密钥,备案或商店是否接受须按实际平台核实。尚未提交备案或应用商店审核。
release APK SHA256:3EAD36A3270C45A0EBB50B33056BCE4244C8DC0024F23720D794BB272328E904。
+25
View File
@@ -0,0 +1,25 @@
# TPSchedule Android 0.5.1+9:启动闪烁修复
日期:2026-10-13。
0.5.0先显示Android原生启动屏,再通过FutureBuilder构建Flutter品牌页,最后AnimatedSwitcher淡出220ms。Future即使已完成,首次构建仍会进入等待分支;重复快速启动时,第二层品牌页容易表现为短暂闪屏。原实现的回归测试已复现这两个分支。
0.5.1恢复此前0.4.0的正常启动方式:先完成controller.initialize,再一次性runApp。原生启动屏保持到首个真正的应用帧,不再额外构建Flutter品牌页或淡出。后台返回同一实例不重新初始化;初始化失败仍可进入原有恢复界面。
连续三次冷启动后,录屏还复现后台返回时的无标志亮蓝色预览。已将NormalTheme窗口、状态栏和导航栏的底色改为与课表canvas一致的#F3F7FF,并使用深色系统图标;浅深资源均同步。仅改窗口底色仍不足以控制系统预览;日志确认还有Android SplashScreenView的退场动画。因此API31及以上使用官方setOnExitAnimationListener,在实际应用首帧完成后立即remove系统启动视图,去掉多余退场动画。LaunchTheme及Android12+系统启动屏保持原蓝色和标志,较旧安卓不调用新API。
验证期间曾尝试首帧延后/释放方案,但软件渲染模拟器实测需要额外触碰才进入应用;普通widget测试会主动pump且替换帧调度,不能代表原生表现。最终放弃此方案,沿用正常runApp引导所安排的真实引擎帧,不依赖warm-up帧呈现或额外用户操作。
自动测试覆盖等待真实初始化完成后启动、缓存数据首次绘制、后台返回、失败后的恢复界面。最终完整Flutter测试141项通过,使用--enable-impeller;静态分析无问题。
参考:[Android启动屏衔接](https://docs.flutter.dev/platform-integration/android/splash-screen)、[Flutter首帧延后API](https://api.flutter.dev/flutter/widgets/WidgetsBinding/deferFirstFrame.html)。已核对Flutter本地renderer、scheduler及Android embedding源代码。
应用ID仍为com.tpschedule.app,签名配置不变;图标、原生启动配色和通透Dock不改。0.5.0记录保留为历史验证。
## 原生检查
API36模拟器覆盖安装并进入保存的课表,无需触碰屏幕。最终发布包的冷启动和后台返回录屏位于dist/screenshots/tpschedule-startup-fixed.mp4。普通启动不再叠加Flutter品牌页;后台返回的系统预览由约0.3–0.4秒退场缩短为录屏中的约0.1秒,系统进入动画本身仍由Android控制,不能宣称每台手机完全没有任何系统预览帧。该检查使用软件渲染,具体手机效果还需实机确认。
发布包和调试包均构建成功并通过原证书指纹检查;新旧证书及公钥完全一致。源码审查确认初始化失败后的恢复界面与较旧安卓API兼容性保留。
发布APK SHA256:D8F4C15E9D3EF02AEF98DDB2BD032E2DF4B7A8166F79C34D524CCC7C08330D29。
+21
View File
@@ -0,0 +1,21 @@
# TPSchedule Android 0.5.2+10:参考图液态玻璃
日期:2026-10-14。用户参考图为Apple Music底部玻璃控件,要求静止滑块灰色,滑动时保留现有Q弹和放大效果。
底座折射强度从0.55调整为0.95,轻度模糊从2降至1.4,保留可辨认的背景色彩;边缘柔化范围收窄,清晰亮边加强。滑块静止时使用26–34%的中性灰覆盖,减少静止时的亮边,使其显得更像参考图中的灰色选中区域。按住时过渡到原有的约1–2.5%覆盖,恢复通透玻璃和亮边。
滑块的按压放大、拖动速度形变、弹簧参数、点击平滑切换、几何尺寸和选中图标不改。图标保留蓝色填充。
新增像素回归测试已在修改前失败:旧滑块相对白背景仅约4色阶差,无法形成明确灰色。修改后静止灰色相对底座超过20色阶,并在按住时变得明显更透明。完整142项Flutter测试通过,包括真实着色器、较大背景图案辨认、细节柔化、灰色/通透状态转换、放大、回弹和点击无抖动。较大纹理对比保留超过90;细小纹理至少衰减三分之二,避免改变模糊强度时使用只对旧参数有效的阈值。
测试使用--enable-impeller。默认受限环境的Flutter引擎无法解析自身程序路径,改用正常执行权限完成验证,没有修改工具链或运行时文件。组件渲染图片仅用于检查材质,测试环境图标字体为占位方框,实际图标以安卓界面为准。
启动闪烁修复、学校登录、课表与考试、学期选择、提醒和日历设置保留。应用ID仍为com.tpschedule.app,原签名配置不变。
## 安卓检查
API36模拟器覆盖安装成功,原有课表保留。已查看静止灰色及按压放大后的实际界面;图片位于dist/screenshots/tpschedule-week.png和tpschedule-dock-held.png。模拟器使用软件渲染,显示兼容玻璃效果;支持折射的渲染路径另有实际像素测试,具体手机视觉仍需实机确认。
发布APK名称TPSchedule,versionName0.5.2/versionCode10,minSdk24/targetSdk36,无debuggable标记。构建脚本验证原证书指纹后才复制安装包;公开信息提取也确认新旧证书DER完全一致,因此公钥未变。私钥未更换或导出。
发布APK SHA256:A307DDF0711A6AE38BA628246714FF2F059605F5D4DA12F0479E17FE5CD47557。
+17
View File
@@ -0,0 +1,17 @@
# TPSchedule Android 0.5.3+11:Dock名称与微光
日期:2026-10-14。
蓝色填充图标增加零偏移、6px模糊半径的轻微柔光,选中图标光晕透明度20%,其他图标8%。图标下方新增“课表、考试、我的”,字号11,单行显示;在固定尺寸内适配系统文字放大。个人页的无障碍说明仍为“个人设置”,触摸区域覆盖图标及名称。
Dock尺寸264×68、灰色静止滑块、通透按压效果、放大比例、弹簧参数与点击平滑切换保持。
142项完整Flutter测试通过,使用--enable-impeller,静态分析无问题。复用既有导航测试确认点击“我的”、双向拖动、页面不横向切换、滚动隐藏和放大字体;页面标题断言限定到页面内,避免与新增Dock文字混淆。玻璃像素采样避开名称和图标光晕区域,以继续检查玻璃材质。独立源码审查未发现问题。
应用名称及ID仍为TPSchedule/com.tpschedule.app,签名配置不变。
安卓API36模拟器覆盖安装通过,原课程保留。实际截图确认三项名称和轻微蓝色光晕可见,点击“我的”文字可进入个人页;按压截图保留放大玻璃效果。截图位于dist/screenshots/tpschedule-week.png、tpschedule-profile.png、tpschedule-dock-held.png。模拟器采用软件兼容渲染。
发布包versionName0.5.3/versionCode11,无debuggable标记。原证书指纹检查和新旧证书DER比较均通过,公钥与证书未变,私钥未更换或导出。
发布APK SHA256:391FBE08BD0DEC1671FB2AD4BF6C7C962B2FDE94422025817A93A644BFBE48E9。
+19
View File
@@ -0,0 +1,19 @@
# TPSchedule Android 0.5.4+12:常驻 Dock 与滑动换周
日期:2026-10-14。
Dock宽度保持264,高度由68降至58,约减少15%。图标由27调整为25,图标与文字间距由3调整为2;“课表、考试、我的”、蓝色微光、灰色静止滑块、按压放大与弹簧回位保留。静止触摸区高度52。
移除课表和考试滚动时隐藏Dock的监听与过渡容器,三个页面的Dock保持常驻。课表横向拖动释放后切换周次:向左下一周、向右上一周;48像素位移或400像素/秒速度触发,一次手势只换一周,限制在1至学期总周数。上下滚动、课程详情点击、箭头和“本周”按钮保留。未设置学期日期时保持设置提示。页面横向滑动不会切换标签页。
实现前的新测试复现旧行为:课表左右滑动不换周,滚动时Dock隐藏。实现后145项完整Flutter测试通过,使用--enable-impeller;静态分析无问题。新测试覆盖不同周课程、左右换周、首尾边界、一次换一周、返回本周、课程点击与未设置学期。既有测试覆盖Dock拖动、点击、尺寸、滚动常驻、系统字号和材质。
玻璃测试按58高度裁剪,预览导出保持3倍等比例尺寸,颜色采样避开图标与名称。蓝红对比下限按既有最强34%灰色覆盖与5%底座覆盖计算:原始210级对比保留约63%,下限130;产品材质参数未更改。精细纹理柔化与较大背景轮廓保留检查继续通过。
独立源码审查确认手势、边界、触摸尺寸与运动参数无功能问题,指出预览导出高度仍为旧值,已修正。
包名com.tpschedule.app及签名配置保留,私钥不更换或导出。
Android API36模拟器覆盖安装通过,原课程保留。实际左右滑动确认第1周→第2周→第1周;向下滚动后Dock三个触摸区域仍在原位置且可点击,点击“我的”进入TPSchedule个人页。课表、下一周、滚动与个人页截图位于dist/screenshots。模拟器使用软件兼容渲染;支持shader的折射效果由像素测试验证。
发布与调试包均构建成功。发布包versionName0.5.4/versionCode12,签名指纹检查通过,与0.5.3证书DER和公钥完全一致。发布APK SHA256:6707D68DBDAA352E4629994104C1D38450D48F9FB51B2458000B1EBA778AA3E2。
+21
View File
@@ -0,0 +1,21 @@
# TPSchedule Android 0.5.5+13:通知修复与多时间点提醒
日期:2026-10-14。用户测试设备为vivo X200 Pro、Android 16;本次原生验证使用Android API36模拟器。
0.5.4已先行冻结:安装包、调试包、源码、签名公开信息及登记材料复制到dist/backups/TPSchedule-0.5.4-registration-20261014,并建立Git标签tpschedule-0.5.4-registration,指向caf7abe0c01b3be9e7afed96f5c03f7cd59eaf55。所有原文件有SHA256清单,原私钥未导出。
用户随后要求登记版本改为V1.0。程序材料和使用说明书使用冻结源码重新生成,仍为5902个非空源码行、60页程序和9页说明书。功能描述扩写为882个字符、其中775个中文字符,未将0.5.5多选提醒写入V1.0冻结材料。V1.0材料另存并备份,原0.5.4冻结目录不改动。
通知根因:0.5.4发布包资源表没有ic_stat_calendar,调试包资源表存在该图标。通知插件按名称查找图标,缺失时initialize返回invalid_icon,导致真实发布包不能建立提醒。新增res/raw/keep.xml明确保留通知图标,0.5.5发布包资源表确认图标存在。
课前提醒改为多选5、10、15、30、60分钟,默认15,至少保留一个选项。新快照保存reminderOffsets不可变列表,兼容旧reminderMinutes字段并保留用户旧选择。每门课程每个时间点独立ID;15分钟保留原有ID,其他时间点使用包含分钟的标识。通知点击仍用原课程键进入详情。去掉的选项清理对应通知;过期时间点跳过,其他尚未来临的时间点继续安排。400次上限按全部提醒的实际触发时刻排序选取。
设置页显示已安排次数和最近提醒时刻;缺少学期日期、没有待提醒课程、精确提醒权限不足时显示对应提示。系统关闭课前提醒通知类别时按通知不可用处理,避免只检查总通知权限后显示成功。
新测试在修改前复现单时间点调度和缺失多选保存行为;之后158项完整Flutter测试通过,静态分析无问题。覆盖多次调度、取消选项、保留未来提醒、400次排序、旧文件迁移、非法设置拒绝、输入列表隔离、UI多选及保留最后选项、关闭通知类别。时间比较采用同一时刻语义,避免TZDateTime与DateTime的类型相等差异。独立源码审查无可操作问题。
发布与调试包构建通过。Android发布包覆盖安装后,使用隔离的14:30课程及5/15分钟配置;系统AlarmManager记录两个独立RTC_WAKEUP精确任务。将应用放到后台,在14:15和14:25分别收到“15分钟后上课”和“5分钟后上课”的课程通知。通知ID不同且同时保留,验证没有互相覆盖。截图位于dist/screenshots/tpschedule-reminder-first.png、tpschedule-reminder-both.png与tpschedule-reminder-settings.png。测试控制了模拟器时间以缩短等待,结束后恢复原课表和时间设置。
签名与公钥与冻结0.5.4完全一致。包名com.tpschedule.app,versionName0.5.5、versionCode13,最低Android API24。发布APK SHA256:62AF7B0B46BCF7DBBC3AFB7069D9C31B03A68B1615C70BB40D0556FB2547C1EA。
未直接连接用户vivo真机,设备厂商的后台、省电和通知设置仍需本人安装新版核验;不能把模拟器通过当作所有手机行为一致。
+13
View File
@@ -0,0 +1,13 @@
# TPSchedule Android 0.5.6+14:Dock图标光晕
日期:2026-10-14。
三个Dock图标增加三层零偏移光晕:8逻辑像素蓝色外光、2.8像素浅蓝白轮廓、1.4像素贴近图标的蓝色柔光。选中项略亮,未选中图标蓝色填充透明度从48%提高到72%,便于在玻璃和复杂背景上辨认。文字、Dock尺寸、灰色静止滑块、折射参数、按压放大和回弹保持。
此次为小范围视觉调整,复用现有验证:158项完整Flutter测试通过,包含玻璃颜色、背景透过、点击与拖动切页、按压放大、减少动画、文字放大和课前提醒;静态分析无问题。发布和调试安装包构建通过。
Android API36模拟器覆盖安装成功,原11门课程保留。实际检查课表和个人页的三个图标以及按压状态,轮廓清晰,标签点击正常。截图:dist/screenshots/tpschedule-week-halo-detail.png、tpschedule-profile-halo-detail.png、tpschedule-dock-held-halo-detail.png;模拟器采用软件兼容渲染。
名称TPSchedule、包名com.tpschedule.app保持。发布包versionName0.5.6/versionCode14。与0.5.5签名证书DER和公钥完全一致,私钥未更换或导出。0.5.4冻结备份及V1.0软著材料保持原样。
发布APK SHA256:D7C28FE28DE22D8AF826F2F7AAE923C2C1DA1CE625F914F04E229F68A3345985。
+30
View File
@@ -0,0 +1,30 @@
# TPSchedule 0.6.0+15 验证记录
2026-10-14。包名 com.tpschedule.app,Android 7+,保持原图标、原签名及登记冻结文件。
## 本次改动
- 蓝色图标周围的柔光改为白光,灰色静止滑块、常驻 Dock、按住放大与回弹保持。
- 换周采用280ms短距离移动和淡入淡出;快速请求合并到最新目标,过期层不接收触摸、焦点或无障碍操作,减少动画时直接切换。
- 待发送和已显示通知分开查询。已显示查询失败不再阻断未来提醒;安排后核对系统保存记录,失败不显示虚假的已安排数量。
- 课表设置增加立即测试、10秒后台测试、通知和后台设置入口、安全提醒诊断。测试与课程使用独立标识,诊断不含课程名、姓名、账号或原始异常。我的页面显示版本。
- 我的页面新增反馈与共建。问题建议必填,联系及图片视频选填;参与开发必须填写学校及联系。最多3个附件、单个8MiB、合计20MiB。失败保留当前页面内容和请求标识,不确定发信结果进入原提交恢复页面。
- HTTPS客户端与Ubuntu/1Panel服务配套,默认地址 https://app.glowtps.cn/v1/feedback。由authors@glowtps.cn发到tps-david@glowtps.cn。只有SMTP接受且持久记录后显示成功,密码留在服务器。
## 已执行验证
- 188项Flutter测试通过,静态分析无问题。已显示通知查询拒绝的回归先在旧实现失败,再在修复后通过。
- 49项Python服务测试通过,使用假SMTP,无真实发信。覆盖字段、媒体格式/限额、上传截止时间、去重/重启、确定失败和不确定结果、SSL/STARTTLS、固定收发地址与限流。
- release/debug APK构建通过。独立审阅通知、原生桥接、周次动画及客户端/服务端;上传时限和不确定错误提示问题已修复、复核。
- Android16/API36模拟器覆盖安装保留11门课程及8门未排考信息。向左滑动切到第2周,三个标签可点击,实际检查反馈两种表单与提醒诊断。
- 系统保存98次课前提醒。立即测试产生NotificationRecord(id=-1001,tag=hepan-test);10秒测试安排精确唤醒,按Home退到桌面后收到NotificationRecord(id=-1002,tag=hepan-test)和“TPSchedule 后台提醒测试”。均使用原课前提醒通道。诊断读到权限已允许和98条待发送。
- 证书与0.5.6一致:AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。0.5.4登记备份18个文件散列一致,V1.0材料未编辑。
- 发布APK为59,139,363字节。SHA256:086F72E3D8F75DBA1BD19EA23BD5C5C7DFAC83DE8640FD6DC11031E161D76E42。
## 验证范围
模拟器使用软件兼容渲染,不能替代vivo X200 Pro真机或手机玻璃渲染。vivo未收到提醒的具体原因仍需新版诊断确认,不能以模拟器成功声称真机已解决。可在我的→课表设置→测试与检查执行后台测试;如果未收到,复制提醒诊断继续排查。
没有服务器管理会话、SSH凭据或飞书授权密码,未部署线上服务、构建Docker镜像或验证真实收件。中文部署说明及配置模板已提供,HTTPS、代理、SMTP及收件仍需在1Panel配置核对。SQLite去重元数据保留7天,须保留卷及稳定密钥;成功表示SMTP接受,不表示邮件已进收件箱。
截图位于dist/screenshots/tpschedule-060-{week,profile,reminder-check,feedback-problem,feedback-development}.png。测试及原生日志位于本地.tools/tpschedule-060-*,不随源码分发。
+27
View File
@@ -0,0 +1,27 @@
# TPSchedule 0.6.1+16:启动衔接与过渡
2026-10-14。用户报告偶发开屏后黑屏2–3秒,希望增加进入首页的过渡。
## 调查与改动
旧入口在runApp之前等待完整controller.initialize,包括本地存储、通知及日历。延迟初始化回归在旧实现确认等待期间没有Flutter界面。另一个回归确认SchoolSession构造时就创建WebView,即使只查看本地课表也启动浏览器引擎。这两项均已先观察失败,再修复。
- 先正常runApp绘制轻量品牌帧,随后初始化时区并读取本地课表。没有使用deferFirstFrame/allowFirstFrame,也没有增加固定停留时间。
- 启动时使用initialize(reconcileServices:false),本地数据就绪后准备首页;通知与日历在首页场景提交后独立同步。默认initialize行为保留,其他业务调用不变。
- SchoolSession的WebViewController改为首次学校访问时创建并复用;导航限制、HTTPS升级、身份验证、读取、学期选择、退出清理逻辑保留。
- 首页先在不透明品牌覆盖层下布局和提交场景,再用300ms淡出品牌,首页轻微上移6逻辑像素。完成前禁止点击、焦点与首页无障碍操作;后台返回不重播该过渡,减少动画时直接显示。
- 首个Flutter品牌帧与原生启动屏衔接:统一#38B3FF底色,原图标居中,前景画布188.8dp。基础及drawable-v21启动资源都已同步。应用图标及原SVG不改。
- 引导阶段的插件异常可重试;保存文件读取失败仍进入原有恢复界面。没有用超时清空课表或重建保存文件。
不透明覆盖层确认首页完成布局并提交场景,不保证隐藏内容已经全部由GPU渲染;手机最终效果仍受设备渲染影响。
## 验证
- 197项Flutter测试通过,静态分析无问题。
- 覆盖持续等待仍显示品牌、逐步过渡/期间防误触、失败重试、减少动画、页面销毁后异步回调、后台返回不重播、数据先于慢提醒服务就绪、坏文件恢复、学校浏览器延迟创建和重用及导航策略。
- 独立代码审阅启动调度、状态保留、通知点击及学校登录逻辑。审阅指出Android24–30还使用drawable-v21覆盖文件,已同步修复;复查未发现其他需修改问题。
- 发布/调试APK构建并核对原证书。API36模拟器覆盖安装保留原课表,冷启动无需触碰自动进入首页;确认首页完成后返回桌面并再打开,检查过渡没有重播。
- 实际录屏在dist/screenshots/tpschedule-061-startup-warm.mp4,采样帧在tpschedule-061-startup-frames.png。工具输出在本地.tools/tpschedule-061-*,不随源码分发。模拟器采用软件兼容渲染,不能代替vivo X200 Pro真机复测,也不能将系统首屏计时解释为首页全部渲染耗时。
- 包名com.tpschedule.app、原签名、公私钥、0.5.4登记冻结及V1.0材料保持。反馈服务及雷池部署步骤不变。
参考:[Flutter原生启动屏衔接](https://docs.flutter.dev/platform-integration/android/splash-screen)。
+23
View File
@@ -0,0 +1,23 @@
# TPSchedule 0.6.2+17:学校选择入口
2026-10-14。将原来的直接学校连接改为选择学校,先提供「电子科技大学(研究生)」,为后续接入其他学校保留明确入口。
## 改动
- 「我的」学校卡片显示「学校课表」,按钮为「选择学校」。选择页面沿用蓝色主题,当前只列出「电子科技大学(研究生)」。已知学校在卡片和列表中标明。
- 选中学校后进入原有学校认证页面,沿用专用认证入口、验证码、学期选择及课表/考试读取流程。取消选择不会打开浏览器或开始认证,也不修改已有数据。
- 首次课表刷新、考试更新、更新学期共用选择入口。已连接学校或保存了学校学期时,刷新沿用原学校及学期;「我的」按钮始终允许再次打开学校列表。
- 原有导入的学校学期和会话均来自电子科技大学研究生系统,继续识别为该学校。首次选择、尚未导入时的选择仅保存在本次会话;保存学期后可以从既有数据识别学校。
- 学校目录集中在 `school_selection_page.dart`,入口按学校枚举明确分发。目录只列出已实现连接器的学校;增加其他学校仍需其认证和解析适配,当前不是通用的多学校数据模型。
- 防止重复点击同时打开多个选择/认证页面。保持学校浏览器延迟创建,普通离线启动不启动学校浏览器。
## 验证
- 四项新增界面测试覆盖取消不认证、选中后访问专用认证入口、首次考试更新保留考试流程、已连接刷新沿用原学期。新增流程先观察失败,再实现;全部201项Flutter测试通过,静态分析无问题。
- 独立代码审查涵盖取消、重复进入、旧学期识别、学校认证页面生命周期和延迟浏览器,未发现需要修改的问题。
- 发布和调试APK均构建通过,版本0.6.2、版本号17、包名 `com.tpschedule.app`。两个构建均由构建脚本验证原签名;发布包另与0.6.1证书逐字节比较,证书和公钥一致。原私有签名文件与迁移前摘要一致。
- API36(Android16)模拟器覆盖安装发布包,冷启动进入原有课表。「我的→选择学校」显示学校列表;取消返回后11条已保存课程仍在;选中学校后进入原有「学校认证」页面。本次未输入真实账户完成验证码及在线导入。
- 原0.5.4登记备份清单18项全部复核通过,登记冻结和V1.0材料未改动。保留 `dist/TPSchedule-0.6.1.apk` 作为上一版安装包。
- 本地构建仍有既有SDK XML版本提示,不影响此次构建。未升级工具链或第三方依赖。
截图:`dist/screenshots/tpschedule-062-profile.png`、`tpschedule-062-schools.png`、`tpschedule-062-auth-entry.png`。运行和构建日志在本地 `.tools/tpschedule-062-*`,不随源码分发。
+74
View File
@@ -0,0 +1,74 @@
# Final whole-branch review — undergraduate integration
Executor resolution (2026-10-15): all three Important findings and the record-type rendering failure (regraded Important) were reproduced RED then fixed GREEN; final Flutter suite249/249. Original independent report retained below. Live SSO remains unverified; all artifact checks and decisions are in tpschedule-0.7.0-validation.md.
Review range: `3f28e0a1b68a6a86d23c545ca143efb8e9431787..674df1ceb306fce13947c722e28b59188e8375ad`.
Date: 2026-10-15. This resumes the interrupted final review; it is not a second review or fix pass. Source, index, HEAD and branch state were not changed by this reviewer. Only this requested ignored review artifact was written inside the checkout. Temporary probes used the sandbox temp directory.
## Strengths
- Undergraduate imports have a separate connector, explicit manual timetable capture, and an all-types exam merge; the graduate workflow remains separately dispatched.
- School-qualified terms, stable persisted school IDs, period-12 source validation, per-page byte limits, narrow navigation origins, and inherited atomic persistence are coherent safeguards.
- Timetable cancellation subtraction and arrangement grouping preserve teachers, rooms and weeks. Undergraduate exam imports preserve the current timetable and date settings. Source labels and 11/12-row selection use saved data.
- The implementation includes focused parser, session, controller, widget and service regressions. The executor reports 241 Flutter tests, analysis and both Node probes green, native sample counts of 18 timetable arrangements and 4 examinations, and Android offline-flow checks. These are executor evidence, not independently rerun suite results in this review.
## Issues
### Critical
None identified in the reviewed range.
### Important
1. **Invalidate completed exam results when the browser starts a new document.**
- Location: `mobile/lib/features/school/undergraduate/session.dart:110–115`; related save guard at `mobile/lib/features/school/undergraduate/auth_page.dart:64–70`.
- `onPageStarted` clears only `pendingDraft`; in exam mode it leaves both `pendingExams` and `_generation` unchanged. `_complete` also stays true, so subsequent `_loaded` callbacks return before detecting an authentication change.
- Reproduction: finish an exam batch, then have a navigation to a different document (for example an authentication redirect) start before the deferred/queued save executes, or navigate after a failed save while the result remains pending. The captured generation and pending result identity still match. The page's `canApply` guard therefore accepts the obsolete exam result, and `session.saved(generation: oldGeneration)` also returns true. A fresh navigation or account transition cannot invalidate this completed result as required by the spec.
- Fix: separate document validity from intentional within-batch navigation, or explicitly invalidate completed/pending results on subsequent document starts; include document validity in the save guard. Keep normal type-to-type navigation working. Add a regression with a completed result, blocked controller queue, then new navigation before queue release.
- Evidence: direct control-flow inspection. A temporary external Flutter reproduction was prepared but did not execute assertions because the sandbox denied copying the compiled test artifact; no runtime pass is claimed for this issue.
2. **Preserve independently saved undergraduate exams when importing a new graduate timetable term.**
- Location: `mobile/lib/app/app_controller.dart:172–174`.
- The `clearExams` predicate checks the incoming timetable's school, but never checks the saved exam schedule's school. Whenever a graduate timetable changes term, it deletes the saved exam schedule even if that schedule is undergraduate data intentionally independent of the timetable.
- Reproduction: start with graduate timetable term 73; successfully import undergraduate exams for term 503; import graduate timetable term 72. `_differentTerm` is true and the incoming school is graduate, so `copyWith(clearExams: true)` persists removal of the undergraduate results and their successful query choices. The user requested a timetable update, not deletion of the independent exam query.
- Fix: preserve undergraduate exam schedules regardless of incoming timetable school; retain legacy graduate clearing only for graduate exam schedules to which that rule applies. Add this direction to the existing controller test, which currently tests only importing an undergraduate timetable after saving undergraduate exams.
- Evidence: direct evaluation of the persistence predicate and `copyWith` path. Same external Flutter test-run limitation as issue 1.
3. **Reject unconsumed activity/index syntax instead of silently importing a partial timetable.**
- Location: `mobile/lib/features/school/undergraduate/activity_data.dart:48–54` (also activity matching at lines 20–24).
- Completeness is measured by counting only already-recognized `index = ...;` and `tableN.activities[index][...] = activity;` statements. Unsupported additional activity writes are not included in either count, so they can silently disappear while the recognized subset passes validation. This violates the explicit unknown-index rejection and old-data-preservation requirement.
- Minimal reproduction: use a valid TaskActivity and the usual assignment at index `0*unitCount+0`, then append `table0.activities[1*unitCount+1][table0.activities[1*unitCount+1].length] = activity;`. The parser returns one activity with indices `{0}` and no error, dropping the Tuesday lesson. The timetable adapter has no subsequent full-script consumption validation; such a successful draft can replace a complete stored timetable and change reminders.
- Fix: tokenize/validate the complete relevant activity block, or detect every TaskActivity declaration and activities write and reject any unconsumed form. Supporting arbitrary JavaScript is unnecessary; rejecting unsupported syntax is sufficient. Cover a recognized statement followed by an unrecognized extra assignment, not only an unknown expression inside a recognized `index =` assignment.
- Evidence: an independent temporary Dart probe against the actual `parseActivityData` implementation ran successfully and printed `Parsed activities: 1; indices: {0}; direct second assignment silently omitted`. No private student data was used.
### Minor
4. **Validate the optional record-level exam type before accepting stored graduate records.**
- Location: `mobile/lib/domain/exam_schedule.dart:61` and `:116–120`; consumer `mobile/lib/features/exams/exams_page.dart:190` in the reviewed revision.
- `ExamRecord.fromJson` accepts any integer `examTypeId`. The schedule-level membership check runs only for undergraduate schedules, while the examination page calls `UndergraduateExamType.fromId` for every non-null record type, regardless of school.
- Reproduction: deserialize a graduate/default-school schedule with otherwise valid record fields and `examTypeId: 99`. Loading succeeds; opening its examination card throws `FormatException: 考试类型无效`. A damaged optional field therefore bypasses the intended invalid-save/backup recovery path and instead crashes the page rendering. This is lower likelihood because the current graduate adapter does not emit this field.
- Fix: reject non-null type IDs outside 1–3 at the model boundary, and/or require the field to be absent for graduate data. Keep old records with no field compatible.
- Evidence: independent Dart probe loaded type 99 successfully and reproduced the enum conversion exception used by the page.
## Verification and limitations
- Read the production diff, spec, plan, progress ledger, relevant existing tests, and validation record. Inspected the exact code paths above.
- Two pure-Dart reproduction cases ran against the implementation: unsupported activity assignment omission and invalid record-type acceptance/render conversion.
- Three additional external temporary Flutter probes were attempted for the controller, completed-session invalidation and full timetable adapter. Flutter failed before assertions with an output.dill/listener.dart.dill copy permission error in the sandbox temp directory. The process was stopped. This is a test-environment limitation, not a product test failure, and those probes are not counted as executed regressions.
- The semicolon-omission parser candidate was considered and rejected as a finding: the tested form threw a FormatException rather than silently importing. The confirmed parser finding above uses an unrecognized extra assignment instead.
## Recommendations
Fix issues 1–3 before shipping, adding narrow regressions for their triggers. Issue 4 is a small model-boundary fix consistent with the corrupt-data requirement. Run the relevant session/controller/parser/model tests and the usual final checks after fixes. Rebuild artifacts if shipped source changes. No second independent review pass is requested by this report.
## Declined to judge
- Actual undergraduate SSO, CAPTCHA and live school callback behavior: no authenticated school session was available; offline fixtures and fake WebView callbacks cannot establish successful real authentication. This remains a documented release validation boundary, not a claimed pass.
- Query-label/card spacing and the glass offset/shader changes made after `674df1c`: explicitly separate current user requests, outside this fixed review range, being handled by the executor.
- Frozen registration materials, private signing keys and current APK signature/ZIP contents: this is the requested source-branch safety review; artifact verification is recorded by the executor. No private key or original student HTML was opened or printed by this reviewer.
## Assessment
**Ready to merge: With fixes.**
The core separation and persistence design follows the plan, but document invalidation, cross-school exam retention and fail-closed activity parsing have reproducible gaps that can save obsolete data or remove valid data. Resolve the three Important findings before delivering the updated build; the Minor finding is also straightforward to address.
+46
View File
@@ -0,0 +1,46 @@
# TPSchedule 0.7.0+18:本科生接入验证
2026-10-15。工程位于F:/program/app,原应用名、包名com.tpschedule.app和签名保持。
## 实现
- 学校列表新增电子科技大学(本科生),学校选择使用稳定标识保存;取消或保存失败不开始登录。
- 本科课表在学校原网页登录、选择学期并打开课表后,点击「导入当前课表」。课表刷新和更新学期沿用手动流程。HTML/HTM离线导入也支持本科结构。
- 本科考试选择一个学期、多选期末/期中/补考缓考,按类型1→2→3读取,全部成功才保存;失败、取消、超时保留旧数据。认证恢复时丢弃旧片段、整批重读。成功查询选项持久保存供刷新使用。
- 523对应2026–2027第一学期,每相邻学期编号差20。考试学期独立保存,历史本科考试不改变当前课程、课表学期和开始日。
- 本科12节,第12节22:00–22:45;研究生及示例11节。七天同屏,教师和教室完整显示,相同节数课程条等长;课程详情保留课程代码和备注。提醒和日历结束时间覆盖第12节。
- 旧保存格式仍为schemaVersion1,新增字段兼容默认研究生;学校/学期损坏和本科第12节来源缺失拒绝保存或恢复。页面变动/取消时在保存队列内部再检查有效性,避免旧回调写入。
- Android实际检查发现网页加载前的一次状态探测可能读到旧空白文档;已补回归并改为加载完成后探测,再重建发布和调试包。
- 考试页“查询类型”与首张卡片增加16逻辑像素间隔。反馈问题和建议新增默认关闭的近10分钟日志附件,不占3个媒体附件名额。日志包含学校认证跳转、页面识别、读取/解析/保存和提醒状态,使用固定页面类别、错误类别及数字状态,不含密码、Cookie、原始地址、HTML、课程和反馈正文;重试冻结附件。重启时恢复仍在窗口内的记录,最多500条、256 KiB。
## 检查结果
- 完整Flutter套件249项通过;flutter analyze无问题。研究生及本科两个Node页面捕获脚本测试通过。反馈服务54项测试通过,覆盖日志与3个媒体附件共存、无效/超大日志拒绝及幂等重试。
- 原本科课表附件解析10门课程、18段上课安排,3段包含第12节;逐节逐周对照保留全部有效安排,停课周扣除。原考试附件4条属于503,外层默认523没有污染实际学期。仓库仅保存脱敏等价样例。
- 核心回归覆盖人工登录等待超过120秒、单项45秒/整批120秒超时、最后一项失败、整批重新认证、关闭页面、过期回调、保存失败、旧研究生导入、来源损坏、第12节提醒/日历、换周动画、完整教师、课程条长度及慢启动。
- Android16/API36模拟器覆盖安装后11条原课程仍在。随后通过安卓文件选择器导入脱敏本科HTML:预览18条、提示新学期开始日、设置2026-09-07后显示12行及22:00–22:45;多教师完整显示。历史503考试导入4条,课表523、18条课程及开始日保持;考试选项取消不改成功选项,重启保持学校与数据。
- 发布包及调试包构建成功;包名com.tpschedule.app、versionName0.7.0、versionCode18。新旧证书逐字节相同,原私有签名文件摘要相同,登记冻结清单18项复核无变化。
- 模拟器使用软件兼容图形模式;未修改安装包的默认手机渲染设置。既有SDK XML版本提示仍出现,构建成功。
## 交付与实际边界
发布APK:60024655字节,SHA256 `591E3961399D788C56D12154AC02F3AEC6FDFFB178D1AFD6B47E41DA01918FB2`。调试包、源码包及截图在dist中;保留TPSchedule-0.6.2.apk。
截图含学校列表、本科浏览器入口、考试选项、学期开始日提示、本科课表、12节及考试列表。真实本科账号、验证码、学校在线回调与自动查询尚未用本人账号验证;模拟器仅核对入口、离线数据与界面,不代表认证成功。需学生在学校原网页完成这部分验证,不要求在聊天中提供密码。
0.5.4登记冻结、V1.0材料和原签名保持。反馈服务新增可选日志字段,需要用户按 `server/feedback/diagnostic-log-update.md` 手动更新服务;原SMTP/域名/反代/密钥及数据卷保持,没有连接服务器或实发邮件。
玻璃偏移实验在Android软件兼容渲染下发现父图层平移后采样异常,尝试尚未验证通过;实验已撤回,本版保留既有玻璃实现,不能宣称玻璃偏移修复。后续原生模拟器进程退出,最后安装包只确认覆盖安装/冷启动成功,未把该次退出归因于真机APP崩溃。原本科文件导入流程的完整原生验证发生在先前构建,最终补充更改由249项测试、静态检查及重新构建覆盖。
日志使用有限的结构化记录,不等同于完整系统logcat。写入过程中若进程强制终止,上一段日志可能损坏而被跳过;当前没有保证进程崩溃时的持久性。
## 实现决策
1. 保存接口增加可选页面有效性检查,并在串行保存队列内部执行;仅在界面检查会留下排队期间页面已变动的窗口。若误判会要求重新导入,原有调用保持兼容。
2. 将无效可选考试类型的审查意见从Minor提高为Important:损坏字段会绕过恢复检查并导致考试页抛错。严格拒绝的代价是手工改坏的字段不能继续加载。
3. 真实SSO和验证码保持待本人验证;不能用模拟网页断言在线成功,代价是学生仍需在真实学校系统验证。
4. 按用户追加要求在本次加入考试间距和诊断反馈,原计划不新增服务端功能的范围由新请求扩展;代价是可选日志需要手动更新服务端,原无日志协议仍兼容。
5. 玻璃实验未通过后保留原实现,代价是已报告的偏移仍待进一步修复。
6. 签名、源码ZIP、冻结材料由执行者本地验证;审查者只看源码,代价是这些交付检查未由独立人员重复。
独立审查记录见 `tpschedule-0.7.0-review.md`;三项Important和一项升级后的Important全部有RED→GREEN回归。完成后新网页令待保存考试失效、研究生更新保留独立本科考试、未知活动写入拒绝整批、可选考试类型在模型层校验。新反馈功能另见 `tpschedule-diagnostic-review.md`,错误原因过于笼统和探测异常未记日志的P2已用登录诊断回归修复。没有延期的已分级Minor。
+37
View File
@@ -0,0 +1,37 @@
# 审查处理结果
独立审查无阻塞问题。唯一Minor已修复:缺少帧类型时记录unknown,不再推断为subresource。修复后完整255项Flutter测试通过,静态检查无问题。真实学校账号在线加载仍待用户复测。以下为原审查记录。
# Browser compatibility fresh review
Reviewed the current uncommitted changes against HEAD `46e26ec548a87db374e0a0bf7110f4078b01710f`, including the latest redirect retry fix, the new browser helper, unit tests, native fixture, feedback hint, and version changes. No application code was modified during review.
## Strengths
- Wide viewport and third-party cookie settings finish before initial school navigation. Both sessions reject navigation after their owning operation is invalidated; initialization is shared and can be retried after failure.
- Android changes are confined to the dedicated school WebView; the dependency version remains unchanged. Existing import capture and parsing are preserved.
- Resource diagnostics retain only fixed categories, protocol, status, and existing sanitized fields. Console text and URLs are not exported. Chromium version is parsed as numeric components.
- Undergraduate identity pages skip the data probe. A probe invalidated by a redirect schedules a retry on the current URL, including when the new finish callback arrived while the old probe held the lock.
- Regression coverage checks initialization ordering, cancellation, resource privacy, and redirect recovery; the native fixture exercises actual viewport behavior and console privacy.
## Important
None found in the reviewed changes. No concrete blocking cancellation, initialization, privacy, or import regression identified.
## Minor
1. **Unknown frame classification is reported as a subresource.** `mobile/lib/services/diagnostic_log.dart:117`, called by both sessions' `onHttpError` handlers (for example `mobile/lib/features/school/undergraduate/session.dart:163`).
- Reproducible trigger: the main school document responds with HTTP 500. `onHttpError` calls `resourceFailure` without `mainFrame`; the emitted resource event says `phase: subresource` even though the failed request may be the main document. Similarly, a WebResourceError whose native frame flag is null is classified as a subresource.
- Effect: diagnostic interpretation can confuse a main document failure with a dependency failure. Authentication and existing main-document failure handling are unaffected.
- Suggested small fix: represent null as `unknown`, reserving `subresource` for an explicit false frame flag. The pinned cross-platform HttpResponseError request exposes no frame flag, so an unknown category is appropriate without adding native machinery.
## Declined to judge
- Success with the real authenticated school SSO and its current resource responses: no authenticated session is available to this read-only review. A passing local Android fixture demonstrates the settings and privacy behavior, but cannot establish the cause or resolution of the user's live portal styling problem.
- No other changed behavior was set aside as outside the approved scope. The student's original HTML, screenshot, and keys were not opened.
## Verification and assessment
The implementation coordinator reports 255/255 unit tests passing, static analysis without issues, and the Android 16 native integration fixture passing on Chromium 133.0.6943.137. I inspected the relevant test code and pinned WebView API source; I did not repeat the already completed suites.
**Ready to proceed: Yes.** The bounded implementation meets the approved approach; the minor diagnostic label issue does not block it. Real authenticated school verification remains an explicit limit on any claim that the original live portal problem is resolved.
+39
View File
@@ -0,0 +1,39 @@
# TPSchedule 0.7.1+19:学校网页兼容性诊断与修复
2026-10-15,工程F:/program/app。用户确认先修现有WebView兼容配置并增加诊断,保留课表HTML导入能力;没有内置独立Chromium或改用Chrome Custom Tabs。
## 证据与本次处理
用户截图显示登录后的静态文字正常,但CSS、图片和菜单组件没有加载。所提供的0.7.0诊断文件仅13条事件,含认证跳转期间的FormatException,没有子资源错误。因此不能从旧日志断言所有组件失败的原因。
本地回归复现两个确定问题:页面跳转期间脚本返回另一页面的证据,会被当作永久读取失败;旧探测尚在进行时收到新页面完成通知,会因探测锁被忽略。本版忽略已过期的页面证据并重新检查当前页面;身份认证页不进行课表证据捕获,继续等待学校回调。
Android学校WebView在首次请求前启用宽视口及第三方Cookie,沿用插件已有的DOM存储和JavaScript设置。仅将已经使用的webview_flutter_android 4.14.1声明为直接依赖,没有引入新内核。初始化期间取消或关闭页面不会迟到地继续导航。获取Chromium版本并在学校页面提示区显示。
诊断新增主页面、嵌入页面和资源错误,区分脚本/CSS/图片/其他请求、HTTP或HTTPS及数字错误状态;控制台仅记录混合内容、CSP、跨域、资源拒绝、脚本错误等固定类别,不保存原始文字。不会保存密码、验证码、Cookie、完整URL、课表HTML或学生身份内容。
旧反馈服务器拒绝diagnostics字段时,APP将明确提示更新日志协议,避免误报为用户填写错误。服务端协议未再改变,0.7.0日志服务更新包继续适用;用户后来已提供日志,本次没有连接服务器或实发邮件。
## 验证
- Flutter完整套件255项通过;静态检查无问题。
- Android16/API36原生WebView集成测试1项通过。模拟器系统WebView为133.0.6943.137;同一原生页面的width=1200视口在配置前被忽略、配置后生效。Chromium版本有效,控制台测试敏感标记未进入导出日志。
- 针对性回归包含两所学校等待配置完成才导航、配置期间取消、子资源出错不终止整页登录、两种认证跳转竞态和旧反馈服务错误提示,均观察到RED→GREEN。
- 使用原com.tpschedule.app包名与原签名;0.7.0安装包单独保留为dist/TPSchedule-0.7.0.apk。登记0.5.4及V1.0资料不变。交付时复核构建、签名、18项冻结摘要和源码包。
- 独立审查没有阻塞问题;发现HTTP回调缺少帧类型时被标成子资源,本版已改为unknown,避免把主文档失败误标为子资源。原生插件未提供该HTTP帧标记,不能猜测。
## 实际边界
尚未在用户本人手机和真实学校账号下验证CSS/JS资源全部加载、登录回调和在线导入;本版修复已复现的登录识别中断,并补兼容设置和必要日志,不能宣称用户截图中的资源缺失已全部解决。若仍缺组件,新日志会提供旧版没有的加载错误类别和状态码。
没有放开所有域名、HTTP资源或SSL证书校验。若日志确认学校资源或回调被规则拦截,应按实际学校域名和路径调整。当前仍采用系统Chromium WebView,可通过设备系统更新该组件。
原有液态玻璃效果保持;本次不包含玻璃偏移修复。原始学生截图、HTML和诊断附件不进入仓库或安装包。
参考:[Android宽视口说明](https://developer.android.com/develop/ui/views/layout/webapps/targeting)、[Chromium Android WebView](https://www.chromium.org/developers/androidwebview/)、[Flutter WebView](https://pub.dev/packages/webview_flutter)。
## 最终构建与保存
发布与调试APK重新构建成功;最终包名com.tpschedule.app,versionName=0.7.1、versionCode=19,标签TPSchedule。发布包60090191字节,SHA256:D270A5E1B6450651D88B2A6631413C8FA4E293EBEADB9F4FB7ED081B4EB742D3。
与0.7.0证书逐字节比较一致,签名证书SHA256为AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A;私钥文件摘要未变,登记冻结18项及0.7.0/0.6.2备份摘要均一致。最终微小日志标签修复后再次执行255项测试及静态检查,均通过。独立审查及处理结果见tpschedule-0.7.1-review.md。
@@ -0,0 +1,36 @@
# 0.7.10:无 HTML 导入日志排查
日期:2026-10-28。版本 0.7.10+36,Android 包名 com.tpschedule.app。
## 日志结论
两份日志均标记为 TPSchedule 0.7.8+26,而非刚完成的 0.7.9。
- TPSchedule-diagnostics.txt:2026-10-25 北京时间 09:08 左右,school=undergraduate。网页完整内容恢复已应用,随后课表读取反复返回 room_missing、activityNumber=15。可确认第 15 条 TaskActivity 的教室字符串为空,被原解析器当作整表错误拒绝。
- TPSchedule-diagnostic5s.txt:2026-10-27 北京时间 09:02–09:03,school=graduate。认证后已自动访问课表,读到一个表格片段及学期 73;解析反复返回 UnsupportedSchoolLayout,最终 read_timeout。缺少 HTML 和细分原因码,无法判断究竟是字段、课程行、周次、表头还是空课表问题。不能把它归因于浏览器内核,也不能声称本轮已确定该同学的具体根因。
用户取消反馈中的学校选择:不增加选择步骤,继续依靠日志中的 school 标记。反馈界面、协议、服务器代码均不变。
## 修改
1. 本科课程的教室字符串为空时,保留全部课程、教师、周次和节次,显示“教室待定”,在课程备注中解释学校未提供教室。导入成功提示及既有导入预览会提醒用户。没有猜测实际教室,也没有跳过该课程;教师、课程标识、周次和节次异常继续拒绝整表,保留旧数据。
2. 研究生解析取消纯数字课程号的额外限制,接受首位为字母/数字、其后为字母/数字/点/下划线/连字符的课程标识;相同严格结构下,空教室也明确标为待定。空教师、无效周次、错位节次、缺失单元格和空表仍拒绝。这些是已通过匿名样本验证的兼容修复,尚不能证明命中原研究生样本。
3. 研究生拒绝原因细分为来源、大小、学期、表头、单元格、课程字段、周次、节次等常量原因码,并在适用时记录时段行号和星期列号。日志不包含课程名称、教师、教室、帐号、Cookie 或 HTML。
4. 自动读取和手动读取保留原始解析错误。读取期限或轮询次数耗尽时,若当前页面曾有具体解析错误,则显示并记录该原因;不再一概写成查找/网络超时。错误按页面代次隔离,换页后不继承旧原因;成功解析后清除旧原因。
## 验证
- 原始匿名复现有 4 项失败,分别为空教室、研究生代码/空教室、具体原因、错误被超时覆盖;修复后通过。
- Flutter 全量 337 项测试通过,静态分析无问题,正式 APK 构建通过;独立审查无需要修复的缺陷。
- 对比缺教室前后的全部 courseCode/teacher/weekday/period/week 集合,确认没有丢课、少周或少节。
- 本科导入界面的模拟流程验证课程入库及“部分课程教室待定”提示。
- 模拟 45 秒读取期限后仍保留 weeks_invalid;新页面不沿用旧周次错误;诊断不含课程值。
- 既有学校来源校验、学期隔离、失效页面保护、未知布局不清空旧课表及反馈功能测试保持通过。
本轮没有失败同学的 HTML,也未使用他们的帐号复测。请让同学先安装本修复版重试;研究生若仍失败,需新日志中的细分代码,最好自愿附带脱敏学校 HTML,才能继续定位实际页面差异。
## 交付与发布
TPSchedule-0.7.10.apk,同签名覆盖安装,构建号 36。原公钥、私钥、备案号和 0.5.4 的 18 项登记冻结材料保持,旧 APK/源码保持。
服务器无需更新。更新后台发布版本 0.7.10、构建号 36,下载链接使用上传后实际可访问的 HTTPS 地址;本地未执行线上发布。
+48
View File
@@ -0,0 +1,48 @@
# 审查问题处理
Important已修复:两所学校页面完成回调传入代次与生命周期有效性检查,异步URL读取及脚本返回后复核;保持同URL并让代次失效的回归测试观察到RED→GREEN。Minor已修复:beangle=null不再判断为库已加载,现有Node脚本测试覆盖null值。未要求再次审查;完整测试、静态检查与原生验证由主实现者完成并记录。以下为修复前独立审查及当时处理计划。
# Browser diagnostics 0.7.2 review
Reviewed the uncommitted mobile changes against `a4c2df838efe01f39fb27cc6062544a476d86949`, including the new diagnostics implementation, Dart tests, JavaScript guard test, and Android fixture. No original user logs or key files were opened. This is a read-only source review; no full tests were repeated and no application source was changed.
## Strengths
- Console messages become fixed category labels. The snapshot decoder exports only bounded integer counts and explicitly selected booleans, discarding unknown fields.
- The JavaScript reads neither page text nor resource URLs, cookies, storage, forms, or HTML. Password-page and URL guards fail closed; inspection exceptions are swallowed.
- Diagnostic capture is launched independently of the login/import callback and changes no navigation policy, import parser, or school security settings.
- Export headers identify app version/build and the UTC export time. The fixture checks real WebView behavior while asserting private fixture text is absent from exported diagnostics.
## Issues
### Critical
None found.
### Important
1. **URL equality cannot reject a snapshot from a replaced document with the same URL.** `mobile/lib/features/school/school_browser_diagnostics.dart:110-118`; callback sites `mobile/lib/features/school/school_session.dart:112-114` and `mobile/lib/features/school/undergraduate/session.dart:142-144`.
Capture awaits both native URL reads and JavaScript execution. If a portal document reloads at the same URL while one of these operations is pending, all three URL comparisons still pass. In particular, JavaScript may return the old document's counts, `onPageStarted` can advance the session document generation, and the final `currentUrl()` can still equal the original URL. Those old counts are then appended after the new navigation. This violates the requested different-document guard and can make the diagnostic evidence misleading even though it does not change imported data.
Pass a captured document generation plus a current-generation predicate (or equivalent) into diagnostics, and recheck after awaits and immediately before recording. The session already maintains `_pageGeneration` / `_document`. Include lifecycle validity where relevant. Add a focused asynchronous test that holds the JavaScript result, advances the document generation while keeping the URL unchanged, then verifies that no events are recorded. Existing tests exercise a different URL but do not cover this case.
### Minor
1. **A null Beangle global is reported as a loaded library.** `mobile/lib/features/school/school_browser_diagnostics.dart:40`.
JavaScript reports `typeof null === 'object'`, so `window.beangle = null` produces `beangle: true`. Require a non-null value in addition to the object type and cover the null case in the JavaScript test. This is a small diagnostic accuracy issue, not a login failure.
## Declined to judge
- The cause and resolution of the authenticated portal's styling/script failure: real affected-page evidence is absent from this change, and this release only refines diagnostic evidence.
- Whether the queued complete Dart suite or native fixture passes: the coordinator is running them; this review makes no independent runtime success claim.
- Untracked output artifacts: excluded from application-source review and not opened because they could include original user material.
## Assessment
**Ready to merge: With fixes.** Privacy boundaries and isolation from login/import behavior are sound in the inspected code. Address the document-generation gap before claiming that snapshots reject replaced documents; do not describe this diagnostic refinement as a fix for the portal rendering problem.
## Coordinator disposition
The coordinator accepted the Important finding and plans to pass an `isCurrent` predicate from each native page-finished callback: graduate capture will use `_pageGeneration` and route/activity guards, and undergraduate capture will use `_generation`, `_document`, and `_valid`. The predicate will be checked around asynchronous native capture. A focused regression will keep the URL unchanged while invalidating the generation during a pending JavaScript result. This is the planned disposition, not an independently verified fix; no second review was requested.
+35
View File
@@ -0,0 +1,35 @@
# TPSchedule 0.7.2+20:教务页面脚本诊断细化
2026-10-15,工程F:/program/app。继续已获用户确认的学校WebView兼容性诊断,范围为细化现有日志,不切换内核、不改变学校认证及导入流程。
## 新证据与结论边界
本次读取用户Downloads中的0.7.1日志,时间为北京时间12:36–12:37。浏览器配置完成,第二次启动记录Chromium156.0.8078.4。身份认证返回本科系统后,school_evidence.authenticated=true;随后多次school_console_error.code=script_error。身份认证阶段有图片HTTPS404。没有证据把该图片404认定为教务页面整体缺少组件的原因,也没有证据认定手机缺少Chromium内核。
0.7.1把所有未分类控制台错误压成script_error,丢失了下一步定位所需的异常类型。因此本版修复的是诊断过粗,尚未修复已证实的登录后页面资源根因。
公开入口只读请求对Chrome和WebView两类UA都返回202及脚本页面。另用Android16原生WebView正常进入学校身份认证页,观察到样式表和jQuery存在。该临时观察没有使用账号、未登录、未读取课表;不能替代用户手机登录后的验证,也不能据此宣称防护页面导致此次故障。临时测试已移除,原始附件不进入仓库和源码包。
## 改动
- 控制台新增固定错误分类:jQuery、beangle、require未定义,及ReferenceError、SyntaxError、TypeError、SecurityError、EvalError、RangeError。只存固定类别,保留原有混合内容、CSP、跨域及资源拒绝分类;不存原始报错文字。
- 两所学校原生页面加载完成时,异步采集脚本标签数、样式表数量、样式链接数量及具备sheet对象的链接数。本科额外记录已知基础库是否存在;统计性能记录中脚本/链接响应202的数量。202本身不代表故障,计数仅供后续结合其他事件分析。
- 只读页面状态,不改变DOM、发起额外资源请求或注入学校业务功能;跳转或出现密码框时跳过。脚本不读取正文、表单内容、Cookie、存储或资源URL。返回值严格校验类型及上限,诊断失败不影响登录读取。
- 导出头部新增当前应用版本、构建号和UTC导出时间,便于分辨旧附件。最近10分钟及用户自选附带规则不变;服务端协议不变,无需为本版重新更新服务器。
## 验证
- 固定分类及受限返回值回归测试观察到缺能力时失败、实现后通过。完整Flutter套件258项通过,静态检查无问题。
- 新Node脚本测试验证实际脚本计数、基础库状态、重定向与密码框保护,并把Cookie、正文和资源URL设为读取即抛错,仍通过。
- Android16/API36原生测试1项通过,系统Chromium133.0.6943.137。原生控制台固定分类、实际页面状态脚本与解码及敏感夹具文本不外泄均验证;离线data页面不满足生产学校域名要求,脚本/解码在原生夹具验证,域名与文档代次保护由独立回归覆盖。
- 独立审查发现同URL重新加载时旧诊断可能混入,已用两所学校现有页面代次和生命周期回调校验修复,并观察到回归RED→GREEN;beangle=null误判也已修复。最终258项测试及静态检查均通过。审查及处理见tpschedule-0.7.2-review.md。
## 后续复测
本版为诊断细化版。仍需用户覆盖安装、复现登录后缺组件、立即新建反馈附带日志,依据错误细类和页面依赖状态判断下一步修复。不得把本版发布或测试通过表述为真实教务页面已恢复。
## 最终交付核对
发布与调试APK构建成功;应用包名com.tpschedule.app,versionName=0.7.2、versionCode=20。发布包60106575字节,SHA256:24564624C9A1A908BE6A1A19EBB8C47089AE6ADF20D80E03873992D7F885F9DC。提供带版本名的TPSchedule-0.7.2.apk与稳定名称TPSchedule.apk,两者字节一致。
与0.7.1签名证书逐字节相同;证书SHA256为AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。原私钥及登记冻结18项校验未变;0.7.1、0.7.0和0.6.2安装包备份保留。源码包由最终Git提交生成,不含私钥、运行缓存或原始学生附件。
+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。源码包由最终提交生成,不含原始学生附件、私钥或运行缓存。登记冻结及旧版安装包在交付打包时重新校验。
+29
View File
@@ -0,0 +1,29 @@
# TPSchedule 0.7.4+22:重复登录继续链接与课表导入修复
2026-10-15,F:/program/app。用户0.7.3日志显示完整页恢复成功,学校页面已能显示;重复登录继续链接被HTTP导航策略拦截,恢复后状态读取出现FormatException,手动导入也提示“页面证据无效”。本记录不含原始HTML、原始URL查询或账号信息。
## 原因与修改
重复登录链接:原策略只将学校身份认证HTTP入口转为HTTPS,教务系统自身的HTTP继续链接被阻止。现对准确eams.uestc.edu.cn、默认HTTP端口、/eams系统路径的主页面导航升级到HTTPS,保留查询与片段;仍不直接加载HTTP,不接管POST提交,其他域名、用户信息和自定义端口不升级。
导入失败:原始学校依赖包含History API网址更新。原生模拟器复现:经现有loadDataWithBaseURL恢复页面,再调用history.replaceState,JavaScript的location.href已变为课表网址,而WebView.getUrl仍保留恢复时的地址。原实现把后者当作抓取脚本的确切地址,因而拒绝页面证据。
读取前现从实际文档取得location.href,先要求原生地址与文档地址均为准确学校HTTPS数据路径且同源,再将这一次实际地址与抓取结果严格比较。读取期间再次换网址仍拒绝;跨域、认证页、不安全地址、页面代次失效、体积上限和原数据保留规则不变。考试目标比较使用已验证的文档网址;实际文档与原生历史地址不一致时不重复执行完整页恢复。日志只记录history_state固定类别,不保存原始URL。
## 验证
先观察到重复登录链接未升级的回归失败;实现升级后通过。针对可见课表但原生历史地址未更新的会话测试,旧实现报“页面证据无效”,修复后识别18条测试课程并生成导入草稿。新测试继续确认跨域/HTTP/认证地址及抓取期间URL变化均拒绝。纯解码器保持精确地址校验。
完整Flutter套件271项通过,4个实际JavaScript脚本检查通过,静态检查无问题。Android JVM16项通过,API36实际WebView夹具检查通过,包含恢复后的中文/脚本/CSS/来源/会话/文档保护及History API使原生地址滞后的边界复现。模拟器Chromium133.0.6943.137,用户日志为156.0.8078.4。
独立复审未发现阻塞项:精确学校来源及抓取时网址校验保留,原完整页恢复原生文档保护不变。审查指出Android POST表单不会进入该主导航回调,升级范围符合点击继续链接的处理需求。
## 复测
覆盖安装0.7.4,校园网内登录本科系统。如出现重复登录提示,点击继续,确认进入完整学校页面;在网页打开课表、选择学期,再点“导入当前课表”应进入预览。线上账号与用户手机Chromium156仍需用户复测,模拟器133的复现和离线课程回归不能代替这一确认。
## 安装包
发布包com.tpschedule.app,versionName=0.7.4、versionCode=22,60155727字节。TPSchedule-0.7.4.apk与TPSchedule.apk一致,SHA256为5B529C611E625614905E972B857F31A3520F958188E5292FF1918C4903BFD3D8。与0.7.3签名证书逐字节一致,原签名文件保留。
源码包由最终提交生成,不包含原始学生HTML、私钥、运行缓存或登记材料;交付打包时重核0.5.4登记冻结18项及旧版安装包。
+43
View File
@@ -0,0 +1,43 @@
# TPSchedule 0.7.5+23:本科排考未发布兼容与HTML反馈排查
2026-10-16,F:/program/app。用户提供三份0.7.4+22日志与截图,均为本科流程,浏览器Chromium138.0.7204.179。前两例显示完整课表但点击导入时报“教师、教室或课程序号缺失”;第三例有日期和考试时间,但考场/座位显示“考试情况尚未发布”,读取报“考试记录字段不完整”。本记录不包含原始学生HTML、账号或日志正文。
## 定位与范围
考试解析器原先以9个物理单元格判断完整性。截图提示考场、座位被合并为一个尚未发布的单元格;构造该结构的回归用例在原实现中稳定失败。现仅接受位于考场列的colspan=2且文本为“考试情况尚未发布”的已知结构,恢复逻辑列映射,保留日期、时间、情况及考试类型;座位留空供界面显示尚未安排。其他异常合并、类型/学期不一致和损坏页面继续拒绝,旧数据不被部分结果覆盖。
课表的笼统错误由教师为空、教室为空或课程序号后缀格式不符三种分支共同产生。日志不含这些字段,测试同学无法提供新HTML,因此目前不能判断其中哪一种是实际原因,也不宣称修复了这两例课表导入。现区分固定原因码teacher_missing、room_missing、course_sequence_invalid及活动序号;日志中不写课程/教师/教室值。
## 用户确认的HTML反馈功能
用户选择通过反馈邮件发送,而不是新增本地导出。反馈问题页面在日志下新增默认关闭的“附带最近一次本科导入页面HTML”,须先选择日志并同意个人信息提示。加入开发的申请不携带HTML。
只保存来源校验通过后、解析前得到的本科课表/课程名单或考试表格片段,最大2MiB,单份内存缓存最多10分钟;退出登录、重新认证、开始新流程及会话销毁清除,不写本地日志/文件。离开学校浏览器返回反馈页面会保留尚未过期的缓存。提交时冻结附件及编号,失败重试字节不变;冻结副本的生命周期是当前反馈表单。过期或不存在时明确提示重新尝试导入或取消附件,保留填写内容。
片段移除账号页头、表单、事件/URL属性、网络资源、无关脚本和认证参数,只保留课程/考试表格、数字学期/类型以及课程声明与节次赋值;课程脚本使用不执行的类型,HTML带禁止网络/表单的CSP。扫描器不要求声明字段有效,保留null、未知节次表达式和注释后的声明,避免过滤掉错误证据。个人课表信息仍可能敏感,学生需主动同意。
客户端新增独立multipart字段diagnostic_html;服务端限制一份2MiB UTF-8 HTML,校验固定前缀、平衡结构、标签/属性白名单、精确CSP和惰性脚本类型。拒绝活动脚本、网络资源和事件属性,作为TPSchedule-school-page.html附在邮件中,不占媒体名额,参与原幂等摘要。旧客户端兼容。
## 验证证据
- 考试合并单元格用例先失败、修复后通过,异常跨度仍拒绝。
- 课表错误分别报告固定原因;失败前捕获证据,退出后清除。HTML过期、来源限制和认证资料过滤通过。
- 原过滤器遗漏异常声明、未知节次及注释前缀的回归先失败;修正后导出仍保留原解析错误,正常合成课表重放为18条安排。
- 反馈默认关闭、提示确认、不可变附件及失败重试通过;服务端验证第5个附件、独立限额、无效MIME/UTF-8、超限、开发申请拒绝和幂等冲突。
- 使用Dart实际过滤器生成9387字节合成样本,Python服务接收并生成逐字节一致的邮件附件;样本中均为测试课程、教师和教室,不是学生原HTML。
- 完整Flutter测试281项通过;flutter analyze无问题;4项实际JavaScript采集脚本检查通过;Python反馈服务66项测试通过。
- 独立审查两项Important发现(错误证据丢失、服务端活动HTML)修正后复核,无未解决Critical/Important项。审查未代替真实账号测试。
## 部署与复测
反馈服务需更新同一容器代码,保留.env、SMTP密码、IDEMPOTENCY_SECRET和数据库卷。OpenResty及雷池请求体放行至少24MiB,原域名和端口链路保持。详见[0.7.5服务器更新说明](../server/feedback/html-diagnostic-update.md)。本次没有操作线上服务器或发送真实邮件。
覆盖安装后在校园网复测考试,核对待发布考场不会阻断导入;课表再尝试导入,在10分钟内返回反馈页勾选日志与HTML发送,取得具体缺失字段及页面后继续修复。真实手机、真实学校页面和线上SMTP仍需用户确认。
## 构建及签名
正式版与调试版构建通过。首次发布构建因生成的注册文件残留integration_test而失败,按完整发布构建流程重新生成配置后通过;该文件不入源码,未修改应用依赖或签名。正式APK的applicationId=com.tpschedule.app、versionName=0.7.5、versionCode=23,minSdk=24、targetSdk=36,60254031字节。
TPSchedule-0.7.5.apk与TPSchedule.apk相同,SHA256为A9BBBB1A64F14661A91D049D868309138DEBDD54D486363090B9AECEEC68CEF5。apksigner验证通过,与0.7.4证书逐字节相同,证书SHA256仍为AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。
API36测试模拟器安装此正式包成功,冷启动返回Status=ok、LaunchState=COLD、TotalTime=1312ms,进程存在。Windows模拟器沿用测试专用的软件渲染启动参数,未修改手机安装包的默认渲染配置;这不是用户手机启动耗时结论。测试后关闭本任务创建的emulator-5556。
+41
View File
@@ -0,0 +1,41 @@
# TPSchedule 0.7.6+24:应用更新检查与发布后台
日期:2026-10-24。用户要求启动或手动检查服务器版本,提示更新说明并在浏览器下载;管理员通过app.glowtps.cn/update维护版本号、说明及链接。服务器已有Ubuntu/1Panel/雷池,反馈服务更新暂缓,因此新服务独立部署,不修改现有反馈功能或SMTP配置。本次没有登录服务器或执行公网部署。
## APP行为
Android首次显示首页后等待2秒再发起一次检查,最长约13秒;检查与启动初始化分离,不延迟本地课表显示。自动检查网络失败、服务器尚未部署或没有新版本时不弹错误。个人页面增加“检查更新”,查询期间显示进度、避免重复请求;手动查询明确提示最新或失败。
查询默认HTTPS地址https://app.glowtps.cn/v1/updates/android,校验schema_version=1和准确application_id,仅将大于当前构建号的发布记录视为新版本。读取容量64KiB,拒绝重定向、错误状态、HTML挑战页面、其他应用、无效版本和不安全下载地址。无需传递学校Cookie、用户身份、课表或设备标识。
发现新版显示版本与纯文本更新说明,支持稍后处理;立即更新通过安卓ACTION_VIEW打开公开HTTPS链接。浏览器不存在或打开失败时提供可复制链接。没有添加APK静默安装、安装来源权限或后台通知SDK。更新弹窗不覆盖当前其他路由的操作页面,手动入口始终可重新检查。较长说明可在小屏上滚动,操作按钮固定可用。
新增安卓MethodChannel只接受HTTPS、不含账户密码的链接,不接入学校WebView。包名仍为com.tpschedule.app,签名配置不变。APK实际构建与证书校验记录在交付目录说明。
## 更新服务器
独立FastAPI服务,Compose项目tpschedule-updates,本机127.0.0.1:18081→容器8000,使用自己的update-data持久卷。原反馈服务18080和.env、SMTP、登记文件不变。后台密码以scrypt哈希保存在服务器.env,服务器命令manage.py交互生成哈希和随机会话密钥;这些值不放入APP和交付源码。
后台使用安全Cookie、8小时会话、CSRF和准确Origin校验,登录限制每地址10次/15分钟及总体100次/15分钟,密码计算最多2个并发。请求体96KiB上限。页面输出转义、响应禁止缓存,表单限制在同一站点,禁用脚本和嵌入。管理员可编辑版本、构建号、说明、公开HTTPS链接;不上传APK、不在服务器访问该链接。
JSON文件通过同目录临时文件、fsync和原子替换写入。构建号和版本不能倒退,同一版本/构建允许改说明及链接;隐藏的修订号阻止旧页面覆盖其他发布。校验失败保留输入,旧发布记录不改;损坏存储返回503,不伪装成无更新。公开API不暴露管理员配置。
管理员页面实际模板已渲染并通过无界面Edge查看排版,预览只含合成版本和example.com链接,不代表已经部署。详细部署及日常发布操作见[服务器说明](../server/updates/README.md),包含适用于原雷池→OpenResty10080链路的三个location。
## 验证与限制
APP回归覆盖版本比较、并发去重、空发布、错误响应/应用标识/URL/容量、浏览器桥接参数、启动后检查、手动错误与最新提示、稍后、浏览器失败链接回退、销毁取消启动计时器,以及小屏长说明。
服务端18项测试覆盖首次未发布、匿名后台、登录、发布与重启持久化、字段限制、CSRF/Origin、Unicode恶意输入、错误密码限速、伪造Cookie、过大请求、构建号单调、旧表单冲突、说明转义、失败保留输入、退出及文件损坏。原反馈服务66项回归通过,合计84项。
Flutter静态分析无问题,完整套件291项通过。未新增APP依赖,沿用现有Flutter/Dart工具链及服务端已验证版本。没有验证公网代理、真实下载链接、管理员实际密码或生产容器构建;这些须按部署说明由用户操作后确认。
0.7.5及之前不含更新模块,第一轮仍需手动覆盖安装0.7.6。今后发布更高构建号后,已接入模块的APP才能获得提示;这不是关闭APP后的远程系统通知。后台版本信息必须对应真正构建并上传的同签名APK,不能只改数字而链接旧包。
## Android交付验证
正式版与调试版均构建通过,applicationId=com.tpschedule.app、versionName=0.7.6、versionCode=24,最低API24、目标API36。调试构建首次遇到本地Gradle不可变转换缓存校验失败;停止守护进程并将受影响缓存隔离到本工程.tools后重建成功,未修改源码、依赖或签名。
正式APK60270499字节,SHA256为0752E5E338B648611B7740926EFDECF7DCE15D5ABBB0E2BDBE90ACEF9AE1D542。apksigner验证通过,与0.7.5证书逐字节相同,证书SHA256仍为AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。私钥与登记冻结文件在打包阶段另做完整性校验。
API36专用模拟器安装正式包成功,软件渲染测试模式下冷启动Status=ok、LaunchState=COLD、TotalTime=3107ms,首页截图正常、进程持续存在且无AndroidRuntime启动异常。实际手机仍使用原默认渲染方式;模拟器数据不代表手机启动耗时。测试后已关闭本任务创建的emulator-5556。
+14
View File
@@ -0,0 +1,14 @@
# 0.7.8+32 点按放大、外溢与柔软玻璃
根据用户要求,点按像拖动一样先放大再弹跳,Dock随滑块运动,边缘滑块允许越出Dock;随后追加外壳需要更柔软的液态感。沿用用户授权直接执行的方式。
- 点按形变延长为620ms,增加宽高共同放大的脉冲,后半平滑衰减并挤压回弹;测试要求横向峰值大于静止的1.4倍,纵向大于1.25倍。点按弹簧阻尼20,连续点击延续已有尺寸与速度。
- 点按期间底座弹簧根据滑块速度改变目标,产生有先后顺序的方向跟随;脉冲结束且两轴稳定后停表。原持续拖动时钟和有限形变波保留。
- 放大时滑块可越出底座的左右、上下边缘,使用实际布局尺寸采样玻璃背景。溢出范围按真实父布局及安全区约束限制,屏幕/窄容器边缘保留8逻辑像素。
- 点按放大时光学状态渐变为较通透的玻璃,落位后恢复浅灰选中状态。柔化边缘光晕,降低硬描边和峰值白光,并稍增强外壳的呼吸形变;乳白层和轻薄高度沿用31。
先强化点按放大断言、新增底座方向跟随与窄屏外溢测试,确认旧版本失败,再实现;另新增带左侧安全留边的父布局约束测试,确认仅按整屏计算会越界,改按更窄的父约束后通过。
验证包括322项Flutter测试、静态分析、正式构建;原点按反向连续性、图标动画/释放衔接、减少动画、拖动、平板与分屏仍覆盖。检查右侧点按外溢、移动与收回的逐帧渲染预览。预览为Flutter渲染示意,本轮未在实体平板上测性能。
版本0.7.8、构建32。包名、签名和私钥保持;保留旧APK、旧源码及0.5.4登记冻结资料。服务器无需改代码;发布时填版本0.7.8、构建号32及对应APK的HTTPS下载链接。
+24
View File
@@ -0,0 +1,24 @@
# 0.7.8+31 拖动牵拉与轻薄玻璃
用户要求拖动过程中也有持续的Q弹形变,并减薄玻璃;随后提供飞书20261028-114548.mp4,参考其中滑块、底座前缘被牵动和反向回弹的效果。本轮按已授权的直接执行方式完成。
## 改动
- 滑块以680/28的弹簧轴追随手指目标,有少量弹性滞后;释放时仍按手指最终位置选择标签,避免视觉滞后选错。重新抓取与取消继承可见速度。
- 拖动输入唤醒620ms有限形变波,移动过程中横向伸缩、纵向挤压,后续输入可续发;暂停后自然结束,静止时不无限播放。按压、松手和减少动画状态调制形变强度。
- 整条Dock随方向移动约8逻辑像素,并沿前缘额外伸展约6逻辑像素;反向时沿弹簧回收。图标和文字随底座位置移动,保持清晰,原到位动画继续。
- 乳白层由38%/24%/30%减至29%/17%/23%;静止灰色滑块减轻,外缘柔光描边2.8降至1.8、内缘0.7降至0.45;阴影和折射高光减弱,保留轻模糊与折射。高度仍58,不改变点击区域及平板布局。
## 连续拖动的关键修正
逐帧预览发现,反复对同一控制器调用animateWith会在持续输入时重启计时,可能反复画出t=0,导致跟随几乎不移动。改为单一持续Ticker同时积分两个弹簧轴,触摸只更新目标。积分子步长不超过1/120秒,帧间隔封顶0.25秒;两个轴稳定后停表,减少动画及销毁时停止。无后台常驻动画。
新增匀速连续拖动的中心实际位移断言,修正前失败、修正后通过;不是仅检查有无放大。另有牵拉滞后、前缘不对称伸展、移动中横纵形变和变薄透光层的RED→GREEN验证。
## 验证与交付
320项Flutter测试通过,静态分析无问题。保留点按弹性、快速反向点按连续性、图标悬停及松手衔接、取消归位、减少动画和平板/分屏回归。
已逐帧检查右拖、左拖、反向、释放的渲染预览。独立审查未发现新的重要缺陷;持续Ticker的积分、生命周期与降动态路径另行复核通过。预览为Flutter渲染示意,本轮未作实体平板性能测试。
APK为0.7.8、构建31,原包名、签名、密钥及备案号保留。旧APK、旧源码和0.5.4登记冻结文件校验保持。服务器无需更新代码;更新后台发布版本0.7.8、构建号31和对应HTTPS下载地址。
+25
View File
@@ -0,0 +1,25 @@
# TPSchedule 0.7.8+27:乳白透光液态玻璃Dock
2026-10-24。用户指出参考图中的液态玻璃带白色材质与折射,要求优化现有Dock。此次只调整材质、着色器和发布构建号,沿用0.7.8版本名及原包名、签名、备案号。
## 视觉调整
- Dock基底由近乎透明的5%/0.8%/1.8%叠色改为24%/12%/18%的白色渐变,带柔和乳白透光层。
- 加强弧形内外亮边及方向反光,优化柔和悬浮阴影,表现边缘厚度;避免叠加的阴影被玻璃取样后使白色主体变灰。
- 折射边缘位移系数由0.20调至0.23,并加入掠射角白色反射;背景仍实时取样。保留原坐标、纹理方向和像素密度处理,不改折射定位。
- 基底轻度模糊1.5逻辑像素,滑块仍随按压减少模糊。静止灰色滑块、蓝色图标、白光晕和原放大/回弹保留。
- Dock尺寸264×58、常驻、图标文字及点击/拖动规则不变。当前页面导航、学校导入、通知与反馈服务未改。
## 验证范围
增加深色背景上的实际像素测试,要求白色材质能提亮背景且继续透出原色。调整旧透明材质的颜色预期为乳白材质,要求饱和背景至少保留一半色差;原细纹柔化、大轮廓透出、移动后折射对齐、点按不抖、放大回弹、取消拖动与减少动画测试继续执行。修改前深色背景测试红通道27,不满足乳白提亮要求;修改后通过。
着色器支持时使用实时背景折射;不支持的渲染方式沿用原轻度模糊、乳白叠色和边缘绘制,不伪称支持真实折射。
最终303项Flutter测试通过、静态分析无问题,15项材质/运动/导航测试通过,正式APK构建成功。Android16/API36模拟器使用软件渲染启动参数覆盖安装及冷启动成功,TotalTime2899ms,检查了实际首页与Dock,未见AndroidRuntime致命错误。本机默认GPU试启动时模拟器进程退出;此轮原生截图属于模糊回退路径,不能替代真机折射效果验证。支持着色器的像素测试覆盖动态取样位置、大轮廓透出和细纹柔化。独立代码审查未发现需修正的问题。
新APK包名com.tpschedule.app,versionName0.7.8、versionCode27;apksigner验证签名有效,证书MD5为78DE9119AD14727FA1C5E5E4E68145EE,与原构建26证书逐字节一致,RSA公钥与用户提供值一致。密钥与0.5.4登记冻结材料不改动。
## 发布
版本号0.7.8,构建号27;保留构建26的安装包。更新后台可使用相同版本名与更高构建号发布,链接须对应本次真实APK。反馈服务器不因此次Dock优化产生新部署步骤。
+24
View File
@@ -0,0 +1,24 @@
# 0.7.8+30 点按Q弹、方向弹动和乳白玻璃
根据构建29后的反馈继续调整,仍沿用Flutter和原签名,版本0.7.8、构建号30。用户授权直接执行;同一轮追加的方向跟随、图标动画和乳白折射参考图一并纳入。
## 行为与材质
- 点按560ms形变:前半段横向拉伸、轻微纵向挤压,后半段横向收窄并纵向回弹。独立点按位置弹簧350/22,拖动释放仍用原360/26。连续点击继承位置、速度和未结束的形变,避免尺寸重置跳动。
- 拖动时整个Dock(底座、滑块、图标、文字)随方向弹动,使用实际布局位置而非非等比缩放,幅度约8逻辑像素,松手/取消后弹簧回中。方向由拖动速度决定,与当前标签在左还是右无关。
- 玻璃接近图标中心时触发一次440ms图标弹起与缩放;点按和拖动悬停均生效,停留不重复播放,首次启动静止。文字保持稳定。减少动画时关闭这些效果。
- 对照用户提供的20261028-111754.jpg,乳白透光层从24%/12%/18%增加至38%/24%/30%,增强弧面边缘折射高光,基础折射强度1.14。保留原轻模糊半径,灰色静止滑块、蓝色实心图标和白光继续保留。
## 验证记录
先强化点按测试,确认旧实现达不到更明显的横向伸展、落位横向收窄和纵向回弹;修改后通过。新方向跟随和图标动画测试均先在旧实现失败再通过。
318项Flutter测试通过,静态分析无问题,正式APK构建成功。动画测试覆盖快速反向点击的位置/尺寸连续、右拖/左拖整条响应、取消/松手回中、图标触发后归位、拖动悬停触发、减少动画及途中开关、平板/分屏回归。材质测试覆盖更明显的乳白透光层、背景色彩保留、细节柔化、大轮廓可辨和移动时折射坐标对齐。
独立审查发现拖动悬停时松手切换选中项会因可变key重建图标动画。先新增同帧释放前后位置/尺寸连续性测试并确认失败,再让各标签的手势及动画子树使用稳定身份,将光标定位key移至不持有动画状态的图标节点;回归通过且无命中测试警告。
更厚的乳白层必然降低背景对比,材质断言改为保留至少40%的大轮廓对比;不是不透明白色填充。图标和文字单独位于玻璃上方,不参与背景折射或模糊。
已用Flutter测试渲染器生成并逐帧检查彩色背景上的动画预览;预览为渲染示意,不是实体手机录屏。本轮未在实体平板上检查性能,原平板布局保持。无服务器功能或数据格式变更。
原APK、源代码压缩包、18项0.5.4登记冻结文件和私钥保持原样。新APK证书与29逐字节一致;仍可覆盖安装。后台发布填写版本0.7.8、构建号30及新APK对应的HTTPS下载链接。
@@ -0,0 +1,28 @@
# 0.7.8+34:灰蓝导航与乳白透射
日期:2026-10-28。依据用户提供的平板应用商店截图及之前的拖动视频,增强乳白底座、实时透射和滑块经过图标时的折射感。
## 变化
- 未选中的图标、文字改为不透明中性灰,选中项为蓝色。颜色跟随实际选中的页面;拖动经过中间项时不会把尚未切换到的页面染蓝。保留白色光晕。
- 底座白色渐变从 .29/.17/.23 调整为 .41/.25/.34,仍透出下方颜色和较大的形状。增加曲面边缘折射范围、环境色反射和白色掠射高光;保留薄边及柔软轮廓。
- 在图标行上方增加一个不接收触摸的实时光学层,与滑块共用位置和尺寸。拖动、按住和点按回弹过程中,它采样已经绘制好的图标与文字,使滑块边缘实际弯折这些内容,而不只是缩放整个图标或改变背景。
- 上层折射强度最多 .52,不再追加模糊。逐帧预览后下调了初始强度,避免小字在玻璃边缘被过度拉碎。力度随按压/点按动画渐退,静止时移除额外光学层,恢复锐利图标。
- 既有点按膨胀、拖动持续 Q 弹、方向牵拉、图标到位动画、边缘外溢、减少动态效果及手机/平板尺寸继续保留。Dock 高度仍为 58。
## 验证
- Flutter 全量 329 项测试通过,静态分析无问题,正式 APK 构建通过。
- 新增测试覆盖:只有选中项为蓝色;点按动画中和拖动未提交时仍与真实页面一致;上层光学层在图标之后绘制、不遮挡交互,并在停止后移除。
- Impeller 像素测试用已绘制的蓝色日历形状作为输入,确认零模糊上层折射改变超过 20 个像素,同时保留超过 70% 的蓝色面积,证明内容确实参与透射。
- 材质像素测试继续检查深色背景下的乳白抬亮、白底不变灰、灰色静止滑块、细节柔化、大色块保留和移动时采样对齐。
- 对最新 Flutter 渲染的点按、按住、来回拖动和释放逐帧检查;安装包旁附动画与静态预览,属于渲染示意,并非真机性能测量。
- 独立审查未发现需要修复的缺陷,31 项聚焦测试通过。未新增真机或实体平板性能结论。
不支持 shader image filter 的渲染器沿用 Gaussian 降级;新增零模糊图标层在这类设备上不产生折射,实际观感较弱。
## 交付
版本 0.7.8+34,沿用包名 com.tpschedule.app、原签名/公钥/私钥和备案号。包括构建 33 的提醒缓存恢复修复及恢复前诊断,详见[提醒记录](tpschedule-0.7.8-reminder-idle.md)。vivo 几小时长待机漏提醒的根因仍需新版真机诊断确认。
保留旧版本产物及 0.5.4 软著登记冻结材料;不需要为本次变更更新服务器代码。更新后台填写版本 0.7.8、构建号 34。
+25
View File
@@ -0,0 +1,25 @@
# 0.7.8+29 Dock 弹性与 Android 平板适配
参考用户提供的 20261028-104403.mp4,保留浅白通透底座、灰色静止滑块、蓝色图标白光和课表/考试/我的文字。
点按改为弹簧移动,移动中短暂拉伸,落位有克制回弹。连续点按继承位置和速度,未结束的尺寸脉冲继续播放,避免重新开始导致边缘跳动。拖动滑块继续放大并变通透;底座随按压和拖动方向轻微扩张、偏移、回弹。通过调整玻璃实际布局范围实现,图标和文字保持独立,未对整个Dock做拉伸变换。沿用原折射着色器和轻模糊,不增加雾化程度。
当前窗口达到600逻辑像素时Dock宽300,高58;窄窗口保持264×58。课表区域最多1440宽,考试和我的最多840宽,设置和反馈最多760宽,居中留边。课表每列足够宽时使用更大字号,并用同样字号重新计算老师、教室的完整显示高度。仍为七天同屏和统一节次高度,课程名可省略并点开详情。横竖屏和分屏缩放保留当前标签、周次和滚动状态。
系统减少动画时直接落位,关闭形变;动画运行中开启该设置会清零尺寸脉冲,重新关闭也不会留下冻结形变。
## 验证
- 314项Flutter测试通过,静态分析无问题,正式APK构建成功。
- 新增/更新行为测试覆盖:点按弹性和有界回弹,快速连续反向点按(90ms、230ms,检查位置与尺寸连续),拖动外框联动、取消归位、减少动画即时/运行中切换、Dock常驻。
- 布局测试覆盖800×1280及1280×800,缩窄到480×800,七天可见、切换标签返回时周次不变、设置/反馈在1.5倍字号下无溢出并可输入。
- 旧冲突卡片测试在更大字号下出现2.84e-14的浮点边界差异,改为0.001逻辑像素容差;课程仍为上下相接,无可见重叠。
- Android16/API36测试模拟器安装正式APK成功;1920×1200和1200×1920物理尺寸、密度240分别对应1280×800和800×1280逻辑窗口。实际打开示例课表、切换页面、换周并改变屏幕方向,周次保留;恢复手机尺寸也正常。检查截图并录制点按/拖动动画,未发现Flutter或AndroidRuntime致命错误。
- 模拟器原生页面检查使用兼容软件渲染,验证布局和动画;支持着色器的Flutter测试另行通过,包含背景颜色、柔化、边缘折射和坐标对齐测试。未在实体平板或iPad上验证,不把兼容渲染截图当作实体设备折射效果的证明。
- 独立审查发现的两项动画边界问题均先用测试复现再修正:中途开关减少动画的冻结脉冲;连续点击重置脉冲/限速导致尺寸跳变。修正后全套314项测试通过。
## 交付
显示版本0.7.8,构建号29,包名com.tpschedule.app。APK、源码和截图按构建号保存于dist。签名MD5仍为78 DE 91 19 AD 14 72 7F A1 C5 E5 E4 E6 81 45 EE;证书和RSA公钥与28一致。密钥、旧版本安装包和0.5.4登记冻结资料保持原样。
这次只更新APP,不需要更新服务器。若使用更新后台推送,填写版本0.7.8、构建号29,并提供本次APK对应的HTTPS下载链接。
@@ -0,0 +1,32 @@
# TPSchedule 0.7.8+28:通知权限排查与通道初始化
日期2026-10-27。用户反馈vivo X200 Pro(V2405A)、Android16上后台没有课前通知,先前已允许精确闹钟及后台运行。本次先收集设备状态并用正式APK验证,未凭手机品牌归因。
## 已确认的直接原因及恢复
用户0.7.8+27首份诊断返回notifications=false、permissionDenied、scheduledCount=0和pendingCount=0;exactAlarm=true,backgroundRestricted=false,batteryOptimizationsIgnored=true、powerSaveMode=false、勿扰过滤正常。此时程序因为通知未获允许而没有安排课程提醒,不能归因精确闹钟或省电限制。
用户进入APP“通知设置”检查后,第二份诊断为notifications=true、exact、scheduledCount=23及pendingCount=23。随后重测“10秒后台测试”,用户明确确认“这次收到了”。因此现有构建27已经在用户手机恢复后台通知,本次没有证据认定Android16/厂商固件或Gson/R8本身存在故障。
同签名正式构建27也在独立Android16/API36测试设备完成原生验证:dumpsys alarm显示RTC_WAKEUP精确任务、约10秒后的请求时间、ScheduledNotificationReceiver;返回桌面12秒后dumpsys notification包含id=-1002、tag=hepan-test的通知记录。立即通知id=-1001也存在,未见接收器异常。测试设备使用软件渲染启动参数,不涉及真实学校帐号。
## 本次小修补
- 通知初始化成功后就创建hepan_lessons“课前提醒”类别,再查询权限/登记任务;此前仅在首次实际发出通知时才创建,诊断和设置中可能看不到类别。
- 使用原类别ID、原重要级别和配置,Android保留用户对已有类别的选择,不自动开启用户关闭的通知。
- 手动诊断增加appNotificationsEnabled、channelExists和courseChannelEnabled,分别显示通知总开关与提醒类别。导出的诊断使用相同显式字段白名单,不含课程、帐号或认证信息。
- 权限不足的提示明确说明当前没有安排课前提醒、应检查总开关与类别后重新检查。结构化日志记录允许/应用通知关闭/类别关闭三类结果。
- 原pendingNotificationRequests读取的是插件本机缓存,单凭pendingCount不能证明系统到点派发。本次以真实系统闹钟/通知记录和用户复测作为恢复证据;登记校验提示不再称为系统保存证明。
- 调度模式、课程时间、提醒多选、原receiver和权限清单不变,未引入系统闹钟兼容模式、常驻服务或新增省电豁免请求。
## 验证与交付
新增“首次检查之前已有类别”和“总开关/类别分别显示”回归,修改前均观察到失败;修改后通过,原拒绝类别、初始化重试及后台测试隔离等测试继续执行。独立检查发现Android7没有类别却显示缺失提示的问题,追加API24回归先失败后通过,旧系统不再显示该提示。
最终306项Flutter测试通过、静态分析无问题,正式APK构建成功。独立Android16/API36测试设备上安装构建28、清空该专用测试APP的数据并重新授予测试权限,首次进入APP即查到hepan_lessons类别、importance4、默认声音及振动,未先发送通知。随后精确登记后台测试并收到通知。
另一次正式包测试登记后回桌面,以测试设备root权限向已核对包名的APP进程发SIGKILL(不执行force-stop):原进程4158退出,10秒后系统创建接收器进程4461,通知记录出现id=-1002/tag=hepan-test,未见AndroidRuntime致命错误。证明该环境不依赖Flutter进程常驻即可投递;不是对所有厂商限制的无条件保证。测试设备使用软件渲染,通知触发仍为真实AlarmManager/receiver/native notify。
apksigner验证构建28签名有效,证书与构建27逐字节一致;MD5为78DE9119AD14727FA1C5E5E4E68145EE,RSA公钥与用户提供值一致。密钥、旧APK及0.5.4登记冻结材料保持。
版本名0.7.8、构建号28,com.tpschedule.app;原证书MD5、公钥、私钥和备案号保持。旧构建27保留。未改服务器或SMTP,未访问或修改用户手机配置,手机系统设置由用户自行操作完成。
+43
View File
@@ -0,0 +1,43 @@
# 0.7.8+33:长时间后台提醒排查与恢复
日期:2026-10-28。问题设备为 vivo X200 Pro / Android 16。用户确认通知、精确提醒、自启动、关联启动和后台高耗电均已允许;没有重启或划掉应用,只是几小时或半天未打开后漏提醒。此前通知总开关关闭的问题已恢复,本次不能沿用那个结论。
## 已复现并修复的程序问题
提醒协调原先只比较插件中的缓存 ID 和内存中的课程指纹。如果系统 PendingIntent 已丢失而插件缓存仍在,同一个应用进程会跳过重新登记。回归测试先复现“应重新登记但没有登记”,修复后通过:相同课程、时间、提醒内容下,只要系统触发信息缺失,也会重新安排。
新增只读原生检查,使用和 flutter_local_notifications 22.3.2 一致的显式 Receiver、ID 和 immutable 标记,通过 FLAG_NO_CREATE 查询,不创建替代触发任务。检查失败不阻断原有安排流程。PendingIntent 存在本身不证明 AlarmManager 中仍有有效任务,更不能证明已投递;不存在则可作为恢复依据。
## 诊断保留
- Activity 创建时,在 Dart 自动恢复前记录插件课程缓存数量、现存/缺失触发信息数量,以及超过预计提醒时间两分钟的缓存数量。
- 暖启动/返回前台时,先检查全部缓存,再删除已过期提醒;有异常的摘要保留在当前 Activity 中,供随后“检查提醒”复制。避免恢复动作覆盖现场。摘要不跨 Activity 重建持久保存。
- “检查提醒”先记录检查前的状态,再协调和读取检查后状态。
- 增加系统待机分组、Doze 状态、最近一次进程退出原因/时间、启动前停止状态和仍显示的最近课程通知时间。API 不支持或系统拒绝读取时保留未知值。
- 复制出的报告不包含课程名称、教师、教室、提醒 ID、帐号、密码、Cookie 或原始错误正文。诊断只在用户主动复制后对外提供。
进程退出原因不能单独证明强行停止;当前待机分组也可能已因打开应用恢复为活跃。已消失的通知无法用“仍显示的最近通知时间”判断是否曾投递。过期缓存也可能来自时间设置变更等情况,不能直接认定厂商丢弃任务。
## 验证
- Flutter 全量 326 项测试通过;静态分析无问题;正式 APK 构建通过。
- Android 原生 JUnit 两项通过:只读计数、去重、排除测试 ID 和空列表;不输出课程 ID。
- 新增回归覆盖:缓存和指纹相同但触发丢失时重建;暖启动过期记录在取消前检查;原生返回 ID 限定于请求范围;可选检查失败不阻断;诊断字段白名单与检查顺序。
- 正式 33 包安装至 Android 16 API 36 专用模拟器。仅向测试设备加入两条合成课程缓存,未登记对应系统任务,其中一条已过期。启动自动清理后,“检查提醒”仍显示缺失 2 条、过期 1 条,待发送 0 条,证明恢复前信息得以保留。
- 正式 33 包通过应用内“10 秒后台测试”,Home 后强制进入深度 Doze,并设置 rare 待机分组(40)。系统记录确认存在精确 RTC_WAKEUP,随后实际收到 id=-1002、tag=hepan-test 的通知。测试后恢复模拟器待机与电池状态。
- 前序正式 32 包还验证过普通进程被杀后由原生 Receiver 唤起投递,以及设备真正重启后未打开应用即恢复并投递。它们是基础原生路径的证据,不能替代 vivo 真机长待机复测。
- 独立审查未发现需要修复的缺陷;审查指出的暖启动证据缺失已补上并以回归验证。
本地验证日志位于 .tools/033-*.log、033-audit-result.xml、033-idle-*.txt;测试使用合成数据,不进入公开源码包。
## 使用与边界
同签名覆盖安装 0.7.8+33,打开一次以安排当前课程提醒。若之后几小时未打开又漏提醒,请进入“我的 → 课表设置 → 检查提醒 → 复制提醒诊断”,将报告和漏提醒的大致时间提供给开发者。可以正常打开应用,新诊断会尽量保留自动恢复前的证据。
本轮修复的是已复现的恢复判断错误,并增加取证能力。尚未在 vivo 真机复现几小时后的失效,不能声称已确定或彻底解决该手机上的根因。未添加常驻前台服务、持续后台计时器或厂商私有接口。
## 平台依据
[Android 定时提醒](https://developer.android.com/develop/background-work/services/alarms)说明系统闹钟可独立于进程生命周期运行;[Android 15 停止状态行为](https://developer.android.com/about/versions/15/behavior-changes-all)说明强行停止与 PendingIntent 取消的关系。[待机分组](https://developer.android.com/topic/performance/appstandby)及[电源资源限制](https://developer.android.com/topic/performance/power/power-details)用于解释诊断字段;不据此推断本机已经触发某项限制。
包名 com.tpschedule.app、原签名密钥/证书、公钥和备案号蜀ICP备2025169276号-5A保持。0.5.4 软著登记冻结材料和旧版本产物保持。用户的服务器无需为本次提醒修复更新代码;更新后台发布时使用版本 0.7.8、构建号 33。
+25
View File
@@ -0,0 +1,25 @@
# TPSchedule 0.7.8+26:本科、研究生HTML反馈
2026-10-24,F:/program/app。用户要求两类学生均可选择附带导入HTML,并指定版本0.7.8、原证书MD5、公钥及备案号。本记录不包含学生原始网页、真实反馈内容或认证资料。
## 功能
反馈问题页面选项改为“附带最近一次学校导入页面HTML”,明确支持本科/研究生课表及考试;沿用默认关闭、日志联动和个人信息提示。共享内存缓存2MiB、10分钟,成功发送后清除;退出、重新认证、新查询清除,离开导入页转去反馈保留。邮件仍附TPSchedule-school-page.html,不占媒体名额,失败重试保持原编号与内容。
研究生在自动课表、手动读取和考试读取的解析前留存已验证的学校表格片段。清洗后保留tbl/sample-table-1、节次ID、drpxq/xqbh学期选择证据;剔除登录页面、无关账户区域、密码字段、网络资源、认证参数和活动脚本。允许原导航策略中学校精确域名/默认端口的HTTP研究生框架,排除其他域名、端口与userinfo。HTML并非整页逐字备份,缺少可识别表格时不会附带登录页。
反馈服务器扩展最小HTML白名单,保持本科协议和旧客户端兼容,研究生附件仍受同一大小、活动内容和幂等校验。Dockerfile明确只读程序权限,避免上传包权限影响普通用户。部署须更新原feedback服务的feedback_service.py与Dockerfile;更新管理后台updates无需重新配置。详见server/feedback/html-diagnostic-update.md。
## 回归
新增研究生课表/考试重解析、错误字段保留、HTTP框架、认证/事件剔除、期限、会话缓存生命周期、反馈勾选提交,以及客户端真实生成的合成HTML到服务端邮件附件的契约验证。
独立审查发现原生currentUrl等待期间注销导致旧片段重填缓存的问题,自动和手动两条路径均已先复现失败,再移至await返回后校验会话/generation;手动解析返回后再次校验。回归测试覆盖该等待窗口,审查复核通过。
最终Flutter完整套件302项通过,静态分析无问题;反馈与更新服务95项测试通过。正式APK构建成功;Android16/API36模拟器覆盖安装成功,冷启动Status ok、TotalTime2530ms,进程运行且未见AndroidRuntime致命错误。检查了首页、反馈选项和“我的”底部,0.7.8及备案号显示正确。模拟器沿用软件渲染启动参数;真机默认渲染方式未改。
## 版本及署名
应用TPSchedule,包名com.tpschedule.app,版本0.7.8、构建号26。“我的”展示蜀ICP备2025169276号-5A。apksigner验证成品APK签名有效,证书MD5为78DE9119AD14727FA1C5E5E4E68145EE;从新APK提取的证书与0.7.6逐字节相同,RSA公钥十进制模数与用户提供值完全一致。公开签名报告增加模数与指数,便于核对;未改私钥。软著0.5.4冻结材料和旧版安装包保持。
未登录生产服务器、未变更生产配置、未发送实际邮件;真实学校登录和邮件接收由用户安装更新并同步部署反馈服务后复测。
+26
View File
@@ -0,0 +1,26 @@
# TPSchedule 0.7.9 发布记录
日期:2026-10-28。Android 版本 0.7.9,构建号 35,包名 com.tpschedule.app。
## 本次调整
点按 Dock 时更快抵达目标,减少到位后的摆动:位置弹簧刚度/阻尼由 350/20 调整为 620/36,玻璃放大和回弹周期由 620ms 收短为 420ms。页面淡入/平移从 330ms 收短为 240ms。点按仍有轻微回弹,连续反向点按保持位置和尺寸连续。
拖动弹簧、持续 Q 弹、方向牵拉、按住放大、图标到位动画、乳白材质、图标折射和灰蓝选中状态沿用上一版。保留 Android 平板适配及构建 33 的提醒恢复/诊断改进。vivo 几小时待机漏提醒尚未确认彻底解决,不将它写为已修复的发布承诺。
## 验证
- Flutter 全量 331 项测试通过;静态分析无问题;正式 APK 构建通过。
- 新增速度回归:点按 160ms 时滑块已接近目标;480ms 时额外光学层退出、尺寸和位置恢复。
- 新增页面回归:240ms 加一帧以内旧页退出,重新切回保留 TextField 内部编辑状态。以旧 330ms 配置复核失败,新配置通过。
- 既有快速反向点按、边缘外溢、拖动牵拉、光学像素和减少动态效果测试通过。
- 独立审查未发现需要修复的缺陷;查看实际 Flutter 渲染的点按和拖动序列。未新增真机性能结论。
- APK 证书与上一版逐字节核对,MD5 保持 78 DE 91 19 AD 14 72 7F A1 C5 E5 E4 E6 81 45 EE,公钥模数、原私钥、备案号及 18 项登记冻结材料保持。
## 发布
安装包为 TPSchedule-0.7.9.apk,源码为 TPSchedule-0.7.9-source.zip。使用同签名覆盖安装。旧 APK 与旧源码包保持原文件。
更新后台 https://app.glowtps.cn/update 填写版本 0.7.9、构建号 35,上传本次 APK 到已有文件托管处后,将真实公开 HTTPS 下载链接填入后台。无需更新服务端代码。发布说明见安装包旁的 TPSchedule-0.7.9-更新发布说明.md。
本地已准备发布产物,未访问服务器执行上传或修改线上发布记录。
+24
View File
@@ -0,0 +1,24 @@
# Focused diagnostic feedback review
Executor resolution (2026-10-15): structured static failure causes and numeric HTTP/WebView status are now recorded; graduate probe exceptions produce a type-only breadcrumb. Login diagnostic regression was observed RED→GREEN; final Flutter suite249/249 and backend54/54. Original independent report follows.
Scope: new diagnostic logging and feedback multipart changes only, compared with HEAD. Undergraduate final-review fixes and glass changes were excluded. Read-only inspection; no deployment or private page content accessed. Existing test additions inspected; no redundant test run while the implementer is running the feature suite.
## Important finding
[P2] Preserve a structured reason for terminal school-read failures.
Location: `mobile/lib/features/school/school_session.dart:272-278` (and equivalent `mobile/lib/features/school/undergraduate/session.dart:219-225`). All callers currently produce `school_read_failed` with the same `code: flow_failure`. Minimal trigger: let the graduate main-frame HTTP request fail versus let the same target time out; both logs contain the identical failure event even though the UI already distinguishes those causes. A 403/500, a main-frame WebView resource error, a rejected navigation and a parsing/read timeout cannot be separated in the feedback attachment. This materially limits the stated import-stall diagnostic purpose. Pass bounded static reason codes through `_failure`, plus numeric HTTP/WebView error codes where applicable, without recording URLs or exception messages.
Closely related coverage gap: `SchoolSession._loaded` still silently swallows the outer JavaScript/evidence exception (`school_session.dart:497-499`). If `runJavaScriptReturningResult` repeatedly throws while the term selection flow is waiting, `finding` remains false and `_scheduleProbe` can exhaust its retries without a `school_evidence`, parse-rejected, or terminal-failure breadcrumb. Record a bounded probe-failed event (exception type only) in that catch so the attached log identifies this stall path.
## Other areas checked
- Checkbox starts unchecked; logs are sent only in the opted-in problem submission.
- Live callers never pass credentials, cookie values, raw URLs/HTML, feedback text or course content. URL classification emits only a fixed page category.
- Cache of `_pendingSubmission` preserves immutable log bytes and the same submission ID across retry/status checking.
- Media validation remains independently limited to three files; backend allows one extra diagnostic part and limits it to 256 KiB.
- Normal generated entries are ASCII and limited to 500 rows with bounded event/field strings, so the exported payload stays below 256 KiB.
- Startup attaches persistence without awaiting it; logger file failures are swallowed and cannot fail app startup.
Non-blocking observation: persistence rewrites the destination directly on each accepted event, so termination during a write can leave a truncated log and lose the previous window. Atomic temporary-file replacement would improve crash resilience, but I have not treated this as a release blocker absent an explicit crash-durability guarantee.
+10
View File
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"isDemo": true,
"explicitlyEmpty": false,
"courses": [
{"id":"example-matrix","name":"矩阵论(示例)","teacher":"示例教师","room":"示例教室 A101","weekday":1,"startPeriod":1,"endPeriod":2,"weeks":[1,2,3,4,5,6,7,8]},
{"id":"example-english","name":"研究生英语(示例)","teacher":"示例教师","room":"示例教室 B202","weekday":3,"startPeriod":5,"endPeriod":6,"weeks":[1,3,5,7]},
{"id":"example-seminar","name":"学术研讨(示例)","teacher":"示例导师","room":"示例教室 C303","weekday":7,"startPeriod":9,"endPeriod":11,"weeks":[2,4,6,8]}
]
}
+48
View File
@@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "abaf9c523780a608bd46686fd5e53740a07077f8"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: android
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+21
View File
@@ -0,0 +1,21 @@
# TPSchedule Flutter工程
当前版本0.7.6+24,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
0.7.5兼容本科考试考场和座位合并为尚未发布的记录;新增可选HTML邮件附件及具体课表缺失字段诊断。HTML协议需要同步更新反馈服务器,见 `../server/feedback/html-diagnostic-update.md`。真实账号课表缺失字段根因尚待排查,见 `../docs/tpschedule-0.7.5-validation.md`。
## 本科使用
在「我的 → 选择学校」选电子科技大学(本科生),通过学校原网页登录,打开课表并选学期,然后点击「导入当前课表」。导入新学校/学期后在课表页设置学期开始日。本科课表刷新和更新学期仍需在网页操作;本科考试页可选一个学期、多选期末/期中/补考缓考并自动读取。成功选项用于后续刷新,历史考试不改变课表。
本科显示12节,末节22:00–22:45;研究生和示例为11节。离线HTML/HTM及原JSON导入保持,教师/教室完整显示,课程名称详情可展开。
## 开发与验证
工具链、测试与构建入口见根目录README及 `scripts/android-env.ps1`、`scripts/build-android.ps1`。本目录运行 `flutter test --no-pub`、`flutter analyze --no-pub`;用Node运行 `test/school/*probe_js_test.cjs` 验证网页捕获。
本科连接模块在 `lib/features/school/undergraduate/`,学校标识在 `lib/domain/school_identity.dart`。HTML解析不执行网页脚本,样例已脱敏;真实账号、验证码及在线SSO需本人在学校网页验证。
不要删除、重建或分发根目录 `.tools/android-user/debug.keystore`;0.5.4登记冻结和V1.0材料不得用当前源码重生成。
0.7.6新增启动后检查更新及“我的→检查更新”,从HTTPS版本接口读取构建号与说明,点击立即更新打开浏览器。更新后台部署说明位于 `../server/updates/README.md`;初次需手动覆盖安装这一版,后续才可接收更新提示。
+33
View File
@@ -0,0 +1,33 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+15
View File
@@ -0,0 +1,15 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
.kotlin/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+55
View File
@@ -0,0 +1,55 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.tpschedule.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.tpschedule.app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
testInstrumentationRunner = "com.tpschedule.app.SchoolDocumentRenderInstrumentation"
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
testImplementation("junit:junit:4.12")
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,102 @@
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");
JSONObject history = evaluate("history.replaceState({},'', '/eams/courseTableForStd.action?semester.id=523'); JSON.stringify({href:location.href})");
AtomicReference<String> historyUrl = new AtomicReference<>();
runOnMainSync(() -> historyUrl.set(view.getUrl()));
require("https://eams.uestc.edu.cn/eams/courseTableForStd.action?semester.id=523".equals(history.getString("href")), "live_history_url_missing");
require(URL.equals(historyUrl.get()), "native_history_characterization_changed");
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,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,62 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:label="TPSchedule"
android:networkSecurityConfig="@xml/network_security_config"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher">
<receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" android:exported="false" />
<receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,24 @@
package com.tpschedule.app;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashMap;
import java.util.function.IntPredicate;
/** Read-only counts. A surviving PendingIntent alone does not prove alarm delivery. */
public final class ReminderHandleAudit {
private ReminderHandleAudit() {}
public static Map<String, Object> inspect(List<Integer> ids, IntPredicate exists) {
LinkedHashSet<Integer> courses = new LinkedHashSet<>();
for (Integer id : ids) if (id != null && id >= 0) courses.add(id);
int present = 0;
for (int id : courses) if (exists.test(id)) present++;
Map<String, Object> counts = new LinkedHashMap<>();
counts.put("cachedCourseCount", courses.size());
counts.put("existingHandleCount", present);
counts.put("missingHandleCount", courses.size() - present);
return counts;
}
}
@@ -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;
}
}
}
@@ -0,0 +1,127 @@
package com.tpschedule.app
import android.Manifest
import android.app.Activity
import android.content.ContentUris
import android.content.ContentValues
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
/** Reads only calendar names and this app's marked events. No calendar reminders are created. */
class CalendarBridge(private val activity: Activity, messenger: BinaryMessenger) {
private val channel = MethodChannel(messenger, "hepan_everyday/calendar")
private val worker = Executors.newSingleThreadExecutor()
private var permissionResult: MethodChannel.Result? = null
private val resolver get() = activity.contentResolver
private val owner get() = activity.packageName
private val code = 18482
private val prefix = "hepan://course/"
private fun allowed() = listOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
.all { activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
init {
channel.setMethodCallHandler { call, result ->
if (call.method == "requestAccess") {
if (allowed()) result.success(true)
else if (permissionResult != null) result.error("BUSY", "日历权限请求进行中", null)
else {
permissionResult = result
activity.requestPermissions(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR), code)
}
} else if (call.method !in setOf("listCalendars", "ownedEvents", "saveEvent", "deleteEvent")) {
result.notImplemented()
} else worker.execute {
try {
check(allowed()) { "日历权限未获允许" }
val args = (call.arguments as? Map<*, *>) ?: emptyMap<Any, Any>()
val value: Any? = when(call.method) {
"listCalendars" -> calendars()
"ownedEvents" -> ownedEvents(args["calendarId"] as String)
"saveEvent" -> save(args)
"deleteEvent" -> { delete(args["id"] as String, args["key"] as String); null }
else -> null
}
activity.runOnUiThread { result.success(value) }
} catch (_: Exception) {
activity.runOnUiThread { result.error("CALENDAR", "日历操作失败,请检查访问权限及所选日历", null) }
}
}
}
}
fun onPermissionResult(requestCode: Int) {
if (requestCode == code) { permissionResult?.success(allowed()); permissionResult = null }
}
fun close() {
channel.setMethodCallHandler(null)
permissionResult?.success(false); permissionResult = null
worker.shutdown()
}
private fun calendars(): List<Map<String, String>> {
val rows = mutableListOf<Map<String, String>>()
resolver.query(Calendars.CONTENT_URI, arrayOf(Calendars._ID, Calendars.CALENDAR_DISPLAY_NAME),
"${Calendars.CALENDAR_ACCESS_LEVEL} >= ?", arrayOf(Calendars.CAL_ACCESS_CONTRIBUTOR.toString()), null)?.use {
while(it.moveToNext()) rows.add(mapOf("id" to it.getLong(0).toString(), "name" to (it.getString(1) ?: "手机日历")))
} ?: error("日历服务不可用")
return rows
}
private fun marker(key: String) = prefix + Uri.encode(key)
private fun ownedEvents(calendarId: String): List<Map<String, Any>> {
require(calendarId.toLong() > 0)
val rows = mutableListOf<Map<String, Any>>()
resolver.query(Events.CONTENT_URI, arrayOf(Events._ID, Events.CUSTOM_APP_URI),
"${Events.CALENDAR_ID}=? AND ${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.DELETED}=0",
arrayOf(calendarId, owner), null)?.use {
while(it.moveToNext()) {
val uri = it.getString(1) ?: continue
if(uri.startsWith(prefix)) rows.add(mapOf("id" to it.getLong(0).toString(), "key" to Uri.decode(uri.substring(prefix.length)),
"calendarId" to calendarId, "owned" to true))
}
} ?: error("日历服务不可用")
return rows
}
private fun ownership(id: String, key: String, calendarId: String? = null): Boolean {
require(id.toLong() > 0)
resolver.query(ContentUris.withAppendedId(Events.CONTENT_URI, id.toLong()),
arrayOf(Events.CUSTOM_APP_PACKAGE, Events.CUSTOM_APP_URI, Events.CALENDAR_ID, Events.DELETED), null,null,null)?.use {
return it.moveToFirst() && it.getString(0) == owner && it.getString(1) == marker(key) && it.getInt(3)==0 &&
(calendarId == null || it.getLong(2).toString() == calendarId)
}
return false
}
private fun save(args: Map<*, *>): String {
val calendarId = args["calendarId"] as String
check(calendars().any { it["id"] == calendarId }) { "日历不可写入" }
val key = args["key"] as String
require(key.isNotBlank() && key.length<=512)
val start = (args["startMs"] as Number).toLong()
val end = (args["endMs"] as Number).toLong()
require(end>start)
val values = ContentValues().apply {
put(Events.CALENDAR_ID, calendarId.toLong())
put(Events.TITLE, args["title"] as String)
put(Events.EVENT_LOCATION, args["room"] as String)
put(Events.DESCRIPTION, args["description"] as String)
put(Events.DTSTART, start); put(Events.DTEND, end)
put(Events.EVENT_TIMEZONE, "Asia/Shanghai")
put(Events.CUSTOM_APP_PACKAGE, owner); put(Events.CUSTOM_APP_URI, marker(key))
}
val id = args["eventId"] as? String
if (id != null && ownership(id, key, calendarId)) {
val count = resolver.update(Events.CONTENT_URI, values,
"${Events._ID}=? AND ${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.CUSTOM_APP_URI}=? AND ${Events.CALENDAR_ID}=? AND ${Events.DELETED}=0",
arrayOf(id, owner, marker(key), calendarId))
check(count==1) { "日程已被修改" }
return id
}
return ContentUris.parseId(resolver.insert(Events.CONTENT_URI, values) ?: error("日程写入失败")).toString()
}
private fun delete(id: String, key: String) {
if (!ownership(id,key)) return
resolver.delete(Events.CONTENT_URI,
"${Events._ID}=? AND ${Events.CUSTOM_APP_PACKAGE}=? AND ${Events.CUSTOM_APP_URI}=?", arrayOf(id,owner,marker(key)))
}
}
@@ -0,0 +1,48 @@
package com.tpschedule.app
import android.os.Build
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private var calendar: CalendarBridge? = null
private var reminders: ReminderSystemBridge? = null
private var schoolDocuments: SchoolDocumentBridge? = null
private var updateBrowser: UpdateBrowserBridge? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Once Flutter has drawn, remove the system preview immediately.
// Its default blue exit fade can flash over an already ready app.
splashScreen.setOnExitAnimationListener { view -> view.remove() }
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger)
reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger)
schoolDocuments?.close()
schoolDocuments = SchoolDocumentBridge(flutterEngine)
updateBrowser?.close()
updateBrowser = UpdateBrowserBridge(this, flutterEngine.dartExecutor.binaryMessenger)
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
schoolDocuments?.close()
schoolDocuments = null
updateBrowser?.close()
updateBrowser = null
super.cleanUpFlutterEngine(flutterEngine)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
calendar?.onPermissionResult(requestCode)
}
override fun onDestroy() {
calendar?.close()
reminders?.close()
schoolDocuments?.close()
updateBrowser?.close()
super.onDestroy()
}
}
@@ -0,0 +1,170 @@
package com.tpschedule.app
import android.app.Activity
import android.app.ActivityManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.usage.UsageStatsManager
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver
import org.json.JSONArray
import java.time.LocalDateTime
import java.time.ZoneId
class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMessenger) {
private val channel = MethodChannel(messenger, "com.tpschedule.app/reminders")
// Captured before Dart's startup/resume reconciliation can repair the cache.
private val startupAudit = captureAlarmAudit()
private var lastProblemAudit: Map<String, Any?>? = startupAudit.takeIf {
((it["missingHandleCount"] as? Number)?.toInt() ?: 0) > 0 ||
((it["overdueCachedCount"] as? Number)?.toInt() ?: 0) > 0
}
private fun hasHandle(id: Int): Boolean = PendingIntent.getBroadcast(activity, id,
Intent(activity, ScheduledNotificationReceiver::class.java),
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE) != null
private fun captureAlarmAudit(): Map<String, Any?> {
val at = System.currentTimeMillis()
return try {
val raw = activity.getSharedPreferences("scheduled_notifications", Activity.MODE_PRIVATE)
.getString("scheduled_notifications", "[]") ?: "[]"
if (raw.length > 2 * 1024 * 1024) return mapOf(
"capturedAtEpochMillis" to at, "errorCode" to "cache_too_large")
val rows = JSONArray(raw)
val ids = mutableListOf<Int>()
val overdue = mutableSetOf<Int>()
for (i in 0 until minOf(rows.length(), 500)) {
val row = rows.optJSONObject(i) ?: continue
if (!row.optString("payload").startsWith("hepan:")) continue
val id = row.optInt("id", -1)
if (id < 0) continue
ids.add(id)
val fireAt = runCatching {
LocalDateTime.parse(row.optString("scheduledDateTime"))
.atZone(ZoneId.of(row.optString("timeZoneName")))
.toInstant().toEpochMilli()
}.getOrNull()
if (fireAt != null && fireAt < at - 120_000) overdue.add(id)
}
val counts = ReminderHandleAudit.inspect(ids) { id ->
hasHandle(id)
}
counts + mapOf("capturedAtEpochMillis" to at,
"overdueCachedCount" to overdue.size, "partial" to (rows.length() > 500))
} catch (_: Exception) {
mapOf("capturedAtEpochMillis" to at, "errorCode" to "snapshot_unavailable")
}
}
init {
channel.setMethodCallHandler { call, result ->
try {
when (call.method) {
"findMissingAlarmHandles" -> {
// Preserve overdue evidence on warm resume, before Dart removes it.
val audit = captureAlarmAudit()
val auditHasProblem = ((audit["missingHandleCount"] as? Number)?.toInt() ?: 0) > 0 ||
((audit["overdueCachedCount"] as? Number)?.toInt() ?: 0) > 0
if (auditHasProblem) lastProblemAudit = audit
val ids = (call.arguments as? List<*>)?.filterIsInstance<Int>()
?.filter { it >= 0 }?.distinct()?.take(400) ?: emptyList()
val missing = ids.filterNot { hasHandle(it) }
if (missing.isNotEmpty() && !auditHasProblem) lastProblemAudit = mapOf(
"capturedAtEpochMillis" to System.currentTimeMillis(),
"cachedCourseCount" to ids.size,
"existingHandleCount" to (ids.size - missing.size),
"missingHandleCount" to missing.size)
result.success(missing)
}
"getStatus" -> result.success(status())
"openNotificationSettings" -> {
openNotificationSettings()
result.success(null)
}
"openBackgroundSettings" -> {
openBackgroundSettings()
result.success(null)
}
else -> result.notImplemented()
}
} catch (_: Exception) {
result.error("reminder_system_unavailable", "系统提醒设置暂时不可用", null)
}
}
}
private fun status(): Map<String, Any?> {
val activityManager = activity.getSystemService(Activity.ACTIVITY_SERVICE) as ActivityManager
val powerManager = activity.getSystemService(Activity.POWER_SERVICE) as PowerManager
val notificationManager = activity.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
val lastExit = if (Build.VERSION.SDK_INT >= 30) runCatching {
activityManager.getHistoricalProcessExitReasons(activity.packageName, 0, 1).firstOrNull()
}.getOrNull() else null
val wasStopped = if (Build.VERSION.SDK_INT >= 35) runCatching {
activityManager.getHistoricalProcessStartReasons(1).firstOrNull()?.wasForceStopped()
}.getOrNull() else null
return mapOf(
"manufacturer" to Build.MANUFACTURER,
"model" to Build.MODEL,
"androidVersion" to Build.VERSION.RELEASE,
"sdk" to Build.VERSION.SDK_INT,
"backgroundRestricted" to if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
activityManager.isBackgroundRestricted
} else null,
"batteryOptimizationsIgnored" to powerManager.isIgnoringBatteryOptimizations(activity.packageName),
"powerSaveMode" to powerManager.isPowerSaveMode,
"interruptionFilter" to notificationManager.currentInterruptionFilter,
"deviceIdle" to powerManager.isDeviceIdleMode,
"standbyBucket" to if (Build.VERSION.SDK_INT >= 28) runCatching {
(activity.getSystemService(Activity.USAGE_STATS_SERVICE) as UsageStatsManager).appStandbyBucket
}.getOrNull() else null,
"startupAlarmAudit" to startupAudit,
"lastProblemAlarmAudit" to lastProblemAudit,
"lastExitReason" to lastExit?.reason,
"lastExitAtEpochMillis" to lastExit?.timestamp,
"wasForceStoppedBeforeStart" to wasStopped,
// Only still-visible notifications from this app; absence is unknown.
"lastVisibleCourseNotificationAt" to runCatching {
notificationManager.activeNotifications.filter { it.tag == "hepan-course" }
.maxOfOrNull { it.postTime }
}.getOrNull(),
)
}
private fun applicationDetails() = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:${activity.packageName}"),
)
private fun openNotificationSettings() {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
} else applicationDetails()
openWithFallback(intent)
}
private fun openBackgroundSettings() {
// Show settings for the user to review; never request automatic exemption.
openWithFallback(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
private fun openWithFallback(intent: Intent) {
try {
activity.startActivity(intent)
} catch (_: Exception) {
activity.startActivity(applicationDetails())
}
}
fun close() {
channel.setMethodCallHandler(null)
}
}
@@ -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,33 @@
package com.tpschedule.app
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
class UpdateBrowserBridge(private val activity: Activity, messenger: BinaryMessenger) {
private val channel = MethodChannel(messenger, "com.tpschedule.app/updates")
init {
channel.setMethodCallHandler { call, result ->
if (call.method != "openDownload") {
result.notImplemented()
} else {
val value = call.argument<String>("url")
val uri = value?.let { Uri.parse(it) }
if (value == null || value.length > 2048 || value.any { it.isWhitespace() || it.code < 32 } ||
uri?.scheme != "https" || uri.host.isNullOrBlank() || !uri.userInfo.isNullOrEmpty()) {
result.success(false)
} else {
try {
activity.startActivity(Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE))
result.success(true)
} catch (_: ActivityNotFoundException) { result.success(false) }
catch (_: SecurityException) { result.success(false) }
}
}
}
}
fun close() { channel.setMethodCallHandler(null) }
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/tpschedule_blue" />
<item android:width="188.8dp" android:height="188.8dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
</layer-list>
@@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#348F91" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#FFFFFF" android:pathData="M28,29h52v43h-52z" />
<path android:fillColor="#348F91" android:pathData="M32,41h44v27h-44z" />
<path android:strokeColor="#FFFFFF" android:strokeWidth="4" android:strokeLineCap="round" android:pathData="M40,25v12M68,25v12M36,79c12,-9 24,9 36,0" />
<path android:fillColor="#FFFFFF" android:pathData="M40,49h8v8h-8zM60,49h8v8h-8z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M7,2h2v2h6V2h2v2h2a2,2 0,0 1,2 2v14a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2 -2V6a2,2 0,0 1,2 -2h2zM5,10v10h14V10zM7,12h4v4H7z"/>
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<group android:scaleX="0.035" android:scaleY="0.035" android:translateX="18.855275" android:translateY="18.83165">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</group>
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" android:viewportWidth="2008.27" android:viewportHeight="2009.62">
<path android:pathData="M0.5,0.5 H2007.77 V2009.12 H0.5 Z" android:strokeColor="#000000" android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="324.09" android:startY="1572.05" android:endX="1871.99" android:endY="280.93">
<item android:offset="0" android:color="#3FD1FF" />
<item android:offset="0.28" android:color="#38B3FF" />
<item android:offset="1" android:color="#2A6CFF" />
</gradient>
</aapt:attr>
</path>
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" android:viewportWidth="2008.27" android:viewportHeight="2009.62">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<group android:scaleX="0.035" android:scaleY="0.035" android:translateX="18.855275" android:translateY="18.83165">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</group>
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="288dp" android:height="288dp" android:viewportWidth="288" android:viewportHeight="288">
<group android:scaleX="0.094" android:scaleY="0.094" android:translateX="49.61131" android:translateY="49.54786">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/tpschedule_blue" />
<item android:width="188.8dp" android:height="188.8dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" android:viewportWidth="2008.27" android:viewportHeight="2009.62">
<path android:pathData="M0.5,0.5 H2007.77 V2009.12 H0.5 Z" android:strokeColor="#000000" android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="324.09" android:startY="1572.05" android:endX="1871.99" android:endY="280.93">
<item android:offset="0" android:color="#3FD1FF" />
<item android:offset="0.28" android:color="#38B3FF" />
<item android:offset="1" android:color="#2A6CFF" />
</gradient>
</aapt:attr>
</path>
</vector>

Some files were not shown because too many files have changed in this diff Show More