diff --git a/README.md b/README.md index 95b4e8e..298b8b1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # TPSchedule +当前Android版本0.7.1+19:学校浏览器启用宽视口和认证Cookie配置,修复本科认证跳转期间停止读取的问题,补齐脚本/CSS/嵌入页面失败诊断和Chromium版本显示。详见[0.7.1验证记录](docs/tpschedule-0.7.1-validation.md)。真实学校页面仍需本人手机复测。 + 0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。 -学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.0+18,Android 7.0 及以上。 +学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.1+19,Android 7.0 及以上。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 diff --git a/docs/android-mvp-validation.md b/docs/android-mvp-validation.md index f4325fc..3806def 100644 --- a/docs/android-mvp-validation.md +++ b/docs/android-mvp-validation.md @@ -1,6 +1,6 @@ # Android 首版验证记录 -当前本科接入版为0.7.0+18,验证见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 +当前浏览器兼容性修复版为0.7.1+19,验证见 [0.7.1记录](tpschedule-0.7.1-validation.md)。本科接入记录见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。 diff --git a/docs/school-integration-status.md b/docs/school-integration-status.md index 968edeb..d20b18a 100644 --- a/docs/school-integration-status.md +++ b/docs/school-integration-status.md @@ -1,4 +1,6 @@ -# 学校接入状态 · TPSchedule 0.7.0 +# 学校接入状态 · TPSchedule 0.7.1 + +0.7.1补充学校浏览器宽视口、第三方Cookie和资源错误诊断,修复本科认证跳转导致读取永久中断的竞态。255项单元测试及Android16原生浏览器测试通过。用户截图中的样式与组件缺失尚需真机复测,不能仅凭测试认定已解决,详见 [0.7.1验证记录](tpschedule-0.7.1-validation.md)。 ## 本科接入 diff --git a/docs/tpschedule-0.7.1-review.md b/docs/tpschedule-0.7.1-review.md new file mode 100644 index 0000000..c138887 --- /dev/null +++ b/docs/tpschedule-0.7.1-review.md @@ -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. diff --git a/docs/tpschedule-0.7.1-validation.md b/docs/tpschedule-0.7.1-validation.md new file mode 100644 index 0000000..ee4207a --- /dev/null +++ b/docs/tpschedule-0.7.1-validation.md @@ -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。 diff --git a/mobile/README.md b/mobile/README.md index 3041900..1ecfaca 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,6 +1,8 @@ # TPSchedule Flutter工程 -当前版本0.7.0+18,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。 +当前版本0.7.1+19,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。 + +学校浏览器在首次导航前配置宽视口及第三方Cookie,显示系统Chromium版本,记录脱敏资源错误;本科认证跳转期间过期的探测会重试。255项单元测试、Android16原生浏览器测试通过,实际学校账号在线加载仍需手机验证,见 `../docs/tpschedule-0.7.1-validation.md`。 ## 本科使用 diff --git a/mobile/integration_test/school_browser_test.dart b/mobile/integration_test/school_browser_test.dart new file mode 100644 index 0000000..90c6082 --- /dev/null +++ b/mobile/integration_test/school_browser_test.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:hepan_everyday/features/school/school_browser.dart'; +import 'package:hepan_everyday/services/diagnostic_log.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets( + 'Android school browser honors desktop viewport and keeps console details private', + (tester) async { + final web = WebViewController(); + await web.setJavaScriptMode(JavaScriptMode.unrestricted); + var loaded = Completer(); + await web.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) { + if (!loaded.isCompleted) loaded.complete(); + }, + ), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: WebViewWidget(controller: web)), + ), + ); + await tester.pumpAndSettle(); + const html = + '' + 'Legacy teaching portal fixture'; + await web.loadHtmlString(html); + await loaded.future.timeout(const Duration(seconds: 20)); + final before = num.parse( + (await web.runJavaScriptReturningResult( + 'document.documentElement.clientWidth', + )).toString(), + ); + final info = await configureSchoolBrowser(web, 'undergraduate'); + loaded = Completer(); + await web.loadHtmlString(html); + await loaded.future.timeout(const Duration(seconds: 20)); + final after = num.parse( + (await web.runJavaScriptReturningResult( + 'document.documentElement.clientWidth', + )).toString(), + ); + expect(before, lessThan(1000)); + expect(after, greaterThanOrEqualTo(1100)); + expect(info.chromiumVersion, matches(r'^\d+(\.\d+){1,3}$')); + await web.runJavaScript('console.error("PRIVATE_NATIVE_CONSOLE_DETAIL")'); + await Future.delayed(const Duration(milliseconds: 200)); + final log = utf8.decode(await DiagnosticLog.instance.export()); + expect(log, contains('school_console_error')); + expect(log, isNot(contains('PRIVATE_NATIVE_CONSOLE_DETAIL'))); + }, + ); +} diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart index c20019c..70cbc45 100644 --- a/mobile/lib/app/app_version.dart +++ b/mobile/lib/app/app_version.dart @@ -1,2 +1,2 @@ -const appVersion = '0.7.0'; -const appBuildNumber = 18; +const appVersion = '0.7.1'; +const appBuildNumber = 19; diff --git a/mobile/lib/features/feedback/feedback_http_sender.dart b/mobile/lib/features/feedback/feedback_http_sender.dart index 17658d5..e33df06 100644 --- a/mobile/lib/features/feedback/feedback_http_sender.dart +++ b/mobile/lib/features/feedback/feedback_http_sender.dart @@ -120,6 +120,12 @@ class HttpFeedbackSender implements FeedbackSender { throw const FeedbackException('服务器不支持此附件的格式或内容,请移除附件,或换用正常的图片、视频后提交'); } if (response.statusCode == 422 && errorCode == 'validation_error') { + if (submission.diagnosticLog != null && + result?['detail'] == 'Unexpected file field.') { + throw const FeedbackException( + '反馈服务器尚未更新日志附件协议,请先更新服务器,或取消附带日志后提交。填写内容已保留。', + ); + } throw const FeedbackException('填写内容未通过服务器校验,请检查必填内容、长度及附件后重新提交'); } if (response.statusCode == 409 && matchesSubmission) { diff --git a/mobile/lib/features/school/school_auth_page.dart b/mobile/lib/features/school/school_auth_page.dart index 7283841..6d225ab 100644 --- a/mobile/lib/features/school/school_auth_page.dart +++ b/mobile/lib/features/school/school_auth_page.dart @@ -121,7 +121,10 @@ class _SchoolAuthPageState extends State { padding: const EdgeInsets.all(12), color: const Color(0xFFEFF6FF), child: Text( - s.message, + [ + s.message, + if (s.browserVersion != null) 'Chromium ${s.browserVersion}', + ].join('\n'), style: const TextStyle(fontSize: 12, height: 1.5), ), ), diff --git a/mobile/lib/features/school/school_browser.dart b/mobile/lib/features/school/school_browser.dart new file mode 100644 index 0000000..d8647f7 --- /dev/null +++ b/mobile/lib/features/school/school_browser.dart @@ -0,0 +1,75 @@ +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; + +import '../../services/diagnostic_log.dart'; + +class SchoolBrowserInfo { + const SchoolBrowserInfo({this.chromiumVersion}); + final String? chromiumVersion; +} + +typedef SchoolBrowserConfigurator = Future Function( + WebViewController controller, + String school, +); + +/// Configure only the dedicated school browser before its first request. +Future configureSchoolBrowser( + WebViewController controller, + String school, +) async { + final android = controller.platform; + if (android is! AndroidWebViewController) return const SchoolBrowserInfo(); + try { + await android.setUseWideViewPort(true); + final cookies = WebViewCookieManager().platform; + if (cookies is AndroidWebViewCookieManager) { + await cookies.setAcceptThirdPartyCookies(android, true); + } + await controller.setOnConsoleMessage((message) { + final text = message.message.toLowerCase(); + final code = text.contains('mixed content') + ? 'mixed_content' + : text.contains('content security policy') + ? 'content_security_policy' + : text.contains('cors') + ? 'cross_origin' + : text.contains('refused to') + ? 'resource_refused' + : message.level == JavaScriptLogLevel.error + ? 'script_error' + : null; + if (code != null) { + DiagnosticLog.instance.add( + 'school_console_error', + fields: {'school': school, 'code': code}, + ); + } + }); + String? version; + try { + version = RegExp(r'\bChrome/(\d+(?:\.\d+){0,3})\b') + .firstMatch(await android.getUserAgent() ?? '') + ?.group(1); + } catch (_) { + /* Version inspection must not block authentication. */ + } + DiagnosticLog.instance.add( + 'browser_configured', + fields: { + 'school': school, + 'phase': 'wide_viewport', + 'result': true, + 'code': 'third_party_cookies', + 'version': version, + }, + ); + return SchoolBrowserInfo(chromiumVersion: version); + } catch (error) { + DiagnosticLog.instance.add( + 'browser_setup_failed', + fields: {'school': school, 'code': error.runtimeType.toString()}, + ); + rethrow; + } +} diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart index f74c1a4..fd3c7c3 100644 --- a/mobile/lib/features/school/school_session.dart +++ b/mobile/lib/features/school/school_session.dart @@ -12,6 +12,7 @@ import 'school_page_adapter.dart'; import 'school_exam_adapter.dart'; import 'school_login_flow.dart'; import 'school_page_probe.dart'; +import 'school_browser.dart'; enum SchoolConnectionState { signedOut, @@ -22,7 +23,28 @@ enum SchoolConnectionState { } class SchoolSession extends ChangeNotifier { - SchoolSession(); + SchoolSession({this.configureBrowser = configureSchoolBrowser}); + final SchoolBrowserConfigurator configureBrowser; + Future? _browserReady; + String? browserVersion; + Future _loadRequest(Uri uri) async { + final controller = web, generation = _pageGeneration, serial = _openSerial; + try { + final info = await (_browserReady ??= configureBrowser( + controller, + 'graduate', + )); + if (!hasOpened || generation != _pageGeneration || serial != _openSerial) { + return; + } + browserVersion = info.chromiumVersion; + await controller.loadRequest(uri); + } catch (_) { + _browserReady = null; + rethrow; + } + } + // Creating Android WebView starts its browser engine. Do it on school access, // not while the student is opening an offline timetable. late final WebViewController web = _createWeb(); @@ -38,7 +60,7 @@ class SchoolSession extends ChangeNotifier { if (upgrade != null) { final generation = _pageGeneration; try { - await web.loadRequest(upgrade); + await _loadRequest(upgrade); } catch (_) { if (generation == _pageGeneration) { _failure( @@ -54,6 +76,12 @@ class SchoolSession extends ChangeNotifier { uri, isMainFrame: request.isMainFrame, )) { + DiagnosticLog.instance.resourceFailure( + 'graduate', + request.url, + code: 'blocked_navigation', + mainFrame: request.isMainFrame, + ); if (kDebugMode && request.isMainFrame) { debugPrint( 'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}', @@ -80,6 +108,13 @@ class SchoolSession extends ChangeNotifier { }, onPageFinished: _loaded, onWebResourceError: (error) async { + DiagnosticLog.instance.resourceFailure( + 'graduate', + error.url, + code: 'web_resource_error', + status: error.errorCode, + mainFrame: error.isForMainFrame, + ); if (error.isForMainFrame != true || error.url == null) return; final generation = _pageGeneration; final current = await web.currentUrl(); @@ -94,6 +129,12 @@ class SchoolSession extends ChangeNotifier { } }, onHttpError: (error) async { + DiagnosticLog.instance.resourceFailure( + 'graduate', + error.request?.uri.toString(), + code: 'http_error', + status: error.response?.statusCode, + ); final generation = _pageGeneration; final current = Uri.tryParse(await web.currentUrl() ?? ''); if (hasOpened && @@ -231,7 +272,7 @@ class SchoolSession extends ChangeNotifier { notifyListeners(); if (needsTermSelection) return; try { - await web.loadRequest(connected ? _target : authUrl); + await _loadRequest(connected ? _target : authUrl); } catch (_) { if (serial == _openSerial && generation == _pageGeneration) { _failure('学校入口无法打开,请检查校园网络后重试。', code: 'entry_load_failed'); @@ -264,7 +305,7 @@ class SchoolSession extends ChangeNotifier { _armTimeout(); notifyListeners(); try { - await web.loadRequest(_target); + await _loadRequest(_target); } catch (_) { if (serial == _openSerial && generation == _pageGeneration) { _failure('学校学期查询页面暂时无法打开,请重试。', code: 'query_load_failed'); @@ -499,7 +540,7 @@ class SchoolSession extends ChangeNotifier { _armTimeout(); notifyListeners(); try { - await web.loadRequest(next); + await _loadRequest(next); } catch (_) { if (generation == _pageGeneration) { _failure('课表查询页面暂时无法打开,请重试。', code: 'query_load_failed'); diff --git a/mobile/lib/features/school/undergraduate/auth_page.dart b/mobile/lib/features/school/undergraduate/auth_page.dart index 2aeaad2..5a20d87 100644 --- a/mobile/lib/features/school/undergraduate/auth_page.dart +++ b/mobile/lib/features/school/undergraduate/auth_page.dart @@ -168,7 +168,10 @@ class _UndergraduateAuthPageState extends State { padding: const EdgeInsets.all(12), color: const Color(0xFFEFF6FF), child: Text( - s.message, + [ + s.message, + if (s.browserVersion != null) 'Chromium ${s.browserVersion}', + ].join('\n'), style: const TextStyle(fontSize: 12, height: 1.5), ), ), diff --git a/mobile/lib/features/school/undergraduate/session.dart b/mobile/lib/features/school/undergraduate/session.dart index 0d2873f..af22fc8 100644 --- a/mobile/lib/features/school/undergraduate/session.dart +++ b/mobile/lib/features/school/undergraduate/session.dart @@ -11,8 +11,13 @@ import 'navigation_policy.dart'; import 'page_probe.dart'; import 'timetable_adapter.dart'; import 'exam_adapter.dart'; +import '../school_browser.dart'; class UndergraduateSession extends ChangeNotifier { + UndergraduateSession({this.configureBrowser = configureSchoolBrowser}); + final SchoolBrowserConfigurator configureBrowser; + Future? _browserReady; + String? browserVersion; static final loginUri = Uri.parse('https://eams.uestc.edu.cn/eams/'); static const manualMessage = '请在学校网页打开课表并选择学期,然后点击导入当前课表。'; WebViewController? _web; @@ -99,6 +104,12 @@ class UndergraduateSession extends ChangeNotifier { uri, isMainFrame: request.isMainFrame, )) { + DiagnosticLog.instance.resourceFailure( + 'undergraduate', + request.url, + code: 'blocked_navigation', + mainFrame: request.isMainFrame, + ); if (request.isMainFrame) { _failure('学校跳转未完成,请重新认证。', code: 'blocked_navigation'); } @@ -129,6 +140,13 @@ class UndergraduateSession extends ChangeNotifier { }, onPageFinished: _loaded, onWebResourceError: (error) async { + DiagnosticLog.instance.resourceFailure( + 'undergraduate', + error.url, + code: 'web_resource_error', + status: error.errorCode, + mainFrame: error.isForMainFrame, + ); if (error.isForMainFrame != true || error.url == null) return; final g = _generation, d = _document, @@ -142,6 +160,12 @@ class UndergraduateSession extends ChangeNotifier { } }, onHttpError: (error) async { + DiagnosticLog.instance.resourceFailure( + 'undergraduate', + error.request?.uri.toString(), + code: 'http_error', + status: error.response?.statusCode, + ); final g = _generation, d = _document, current = await web.currentUrl(); @@ -202,8 +226,16 @@ class UndergraduateSession extends ChangeNotifier { _loading = true; _emit(); try { - await web.loadRequest(uri); + final controller = web; + final info = await (_browserReady ??= configureBrowser( + controller, + 'undergraduate', + )); + if (!_valid(g)) return; + browserVersion = info.chromiumVersion; + await controller.loadRequest(uri); } catch (_) { + _browserReady = null; if (_valid(g)) _failure('学校页面打开未完成,请重试。'); } } @@ -283,6 +315,11 @@ class UndergraduateSession extends ChangeNotifier { !UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true)) { return; } + if (!UndergraduateNavigationPolicy.isDataPage(uri)) { + _loading = false; + _waitForLogin(); + return; + } final status = await _evidence(uri, false); DiagnosticLog.instance.add( 'school_evidence', @@ -346,8 +383,17 @@ class UndergraduateSession extends ChangeNotifier { await _requestExam(g); } } catch (error) { + final current = await web.currentUrl(); if (_valid(g) && d == _document) { - _failure('$error', code: error.runtimeType.toString()); + if (current != url) { + DiagnosticLog.instance.add( + 'school_probe_stale', + fields: {'school': 'undergraduate', 'code': 'redirect_in_progress'}, + ); + if (current != null) _schedulePoll(g, d, current); + } else { + _failure('$error', code: error.runtimeType.toString()); + } } } finally { if (_probeKey == key) _probeKey = null; diff --git a/mobile/lib/services/diagnostic_log.dart b/mobile/lib/services/diagnostic_log.dart index c27b013..6c0a116 100644 --- a/mobile/lib/services/diagnostic_log.dart +++ b/mobile/lib/services/diagnostic_log.dart @@ -94,7 +94,45 @@ class DiagnosticLog { add('navigation_$phase', fields: {'school': school, 'page': page}); } + void resourceFailure( + String school, + String? url, { + required String code, + int? status, + bool? mainFrame, + }) { + final uri = Uri.tryParse(url ?? ''); + final path = uri?.path.toLowerCase() ?? ''; + final kind = path.endsWith('.js') + ? 'script' + : path.endsWith('.css') + ? 'stylesheet' + : RegExp(r'\.(png|jpg|jpeg|gif|webp|svg|ico)$').hasMatch(path) + ? 'image' + : 'document_or_request'; + add( + 'school_resource_failed', + fields: { + 'school': school, + 'phase': mainFrame == null + ? 'unknown' + : mainFrame + ? 'main' + : 'subresource', + 'page': kind, + 'code': code, + 'status': status, + 'type': uri?.scheme == 'http' + ? 'http' + : uri?.scheme == 'https' + ? 'https' + : 'other', + }, + ); + } + Future attach(Directory directory) async { + // Retention and export use the same bounded, sanitized entries. final file = File('${directory.path}/diagnostics-recent.json'); try { if (await file.exists() && await file.length() <= maxBytes) { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d032183..50ab4de 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -618,7 +618,7 @@ packages: source: hosted version: "4.14.1" webview_flutter_android: - dependency: transitive + dependency: "direct main" description: name: webview_flutter_android sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2bfa186..42a0e31 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: TPSchedule 学生课表 publish_to: none -version: 0.7.0+18 +version: 0.7.1+19 environment: sdk: ^3.13.5 dependencies: @@ -10,6 +10,7 @@ dependencies: flutter_localizations: sdk: flutter webview_flutter: 4.14.1 + webview_flutter_android: 4.14.1 flutter_local_notifications: 22.3.2 timezone: 0.11.1 html: 0.15.7 diff --git a/mobile/test/features/feedback_http_sender_test.dart b/mobile/test/features/feedback_http_sender_test.dart index 411540b..3ead59a 100644 --- a/mobile/test/features/feedback_http_sender_test.dart +++ b/mobile/test/features/feedback_http_sender_test.dart @@ -33,6 +33,39 @@ class FakeFeedbackHttpTransport implements FeedbackHttpTransport { } void main() { + test( + 'old feedback service gets a specific diagnostic-upgrade hint', + () async { + final transport = FakeFeedbackHttpTransport() + ..response = FeedbackHttpResponse( + 422, + utf8.encode( + '{"error":"validation_error","detail":"Unexpected file field."}', + ), + ); + await expectLater( + HttpFeedbackSender( + endpoint: 'https://example.com/v1/feedback', + transport: transport, + ).send( + FeedbackSubmission.problem( + id: 'same-id', + message: '问题', + diagnosticLog: Uint8List.fromList( + utf8.encode('TPSchedule diagnostic log\n'), + ), + ), + ), + throwsA( + isA().having( + (e) => e.message, + 'upgrade hint', + allOf(contains('日志'), contains('更新')), + ), + ), + ); + }, + ); test('diagnostics use a separate UTF8 attachment field', () async { final transport = FakeFeedbackHttpTransport(); await HttpFeedbackSender( diff --git a/mobile/test/school/browser_compatibility_test.dart b/mobile/test/school/browser_compatibility_test.dart new file mode 100644 index 0000000..fc6d7c6 --- /dev/null +++ b/mobile/test/school/browser_compatibility_test.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:hepan_everyday/domain/school_term.dart'; +import 'package:hepan_everyday/features/school/school_browser.dart'; +import 'package:hepan_everyday/features/school/school_session.dart'; +import 'package:hepan_everyday/features/school/undergraduate/session.dart'; +import 'package:hepan_everyday/services/diagnostic_log.dart'; + +import '../support/fake_school_web.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('both school entries wait for browser compatibility settings before loading', () async { + for (final undergraduate in [false, true]) { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final ready = Completer(); + final graduate = SchoolSession(configureBrowser: (_, _) => ready.future); + final under = UndergraduateSession( + configureBrowser: (_, _) => ready.future, + ); + addTearDown(graduate.dispose); + addTearDown(under.dispose); + final opening = undergraduate + ? under.openTimetable() + : graduate.openTimetable(term: SchoolTerm(73)); + await flushSchoolEvents(); + expect(platform.web.loads, isEmpty); + ready.complete(const SchoolBrowserInfo(chromiumVersion: '155.0.1.2')); + await opening; + expect(platform.web.loads, hasLength(1)); + expect( + undergraduate ? under.browserVersion : graduate.browserVersion, + '155.0.1.2', + ); + } + }); + test( + 'cancel while browser settings initialize never starts a late navigation', + () async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final ready = Completer(); + final session = UndergraduateSession( + configureBrowser: (_, _) => ready.future, + ); + addTearDown(session.dispose); + final opening = session.openTimetable(); + await flushSchoolEvents(); + session.setReadingActive(false); + ready.complete(const SchoolBrowserInfo()); + await opening; + expect(platform.web.loads, isEmpty); + }, + ); + test('subresource failures remain diagnostic without aborting the school session', () async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = UndergraduateSession(); + addTearDown(session.dispose); + await session.openTimetable(); + platform.delegate.resourceError( + WebResourceError( + errorCode: -105, + description: 'PRIVATE_COOKIE', + isForMainFrame: false, + url: 'http://eams.uestc.edu.cn/eams/scripts/component.js?ticket=PRIVATE_TICKET', + ), + ); + await flushSchoolEvents(); + final log = utf8.decode(await DiagnosticLog.instance.export()); + expect(log, contains('school_resource_failed')); + expect(log, contains('"page":"script"')); + expect(log, contains('"status":-105')); + expect(log, isNot(contains('PRIVATE_COOKIE'))); + expect(log, isNot(contains('PRIVATE_TICKET'))); + expect(session.message, isNot(contains('未完成'))); + }); +} diff --git a/mobile/test/school/undergraduate_session_test.dart b/mobile/test/school/undergraduate_session_test.dart index 8c53d0b..90eaaee 100644 --- a/mobile/test/school/undergraduate_session_test.dart +++ b/mobile/test/school/undergraduate_session_test.dart @@ -31,6 +31,48 @@ void main() { session = UndergraduateSession(); }); tearDown(() => session.dispose()); + test( + 'page-finished during an old probe is retried on the redirected portal', + () async { + await session.openTimetable(); + var first = true; + platform.web.javascript = (_) async { + if (first) { + first = false; + platform.web.url = 'https://eams.uestc.edu.cn/eams/home.action'; + platform.delegate.finished(platform.web.url!); + } + return underEvidence(platform.web.url!); + }; + platform.delegate.finished(platform.web.url!); + await flushSchoolEvents(); + await Future.delayed(const Duration(milliseconds: 600)); + await flushSchoolEvents(); + expect(session.statusLabel, '已连接'); + expect(session.busy, isFalse); + }, + ); + test('a redirect between page callback and probe does not permanently stop login detection', () async { + await session.openTimetable(); + var redirect = true; + platform.web.javascript = (_) async { + if (redirect) { + redirect = false; + platform.web.url = 'https://idas.uestc.edu.cn/authserver/login'; + return underEvidence(platform.web.url!, authenticated: false); + } + return underEvidence(platform.web.url!); + }; + platform.delegate.finished(platform.web.url!); + await flushSchoolEvents(); + expect(session.message, isNot(contains('未完成'))); + platform.delegate.finished(platform.web.url!); + await flushSchoolEvents(); + platform.web.url = 'https://eams.uestc.edu.cn/eams/'; + platform.delegate.finished(platform.web.url!); + await flushSchoolEvents(); + expect(session.statusLabel, '已连接'); + }); test( 'activating a loading browser never probes the previous blank document', () async {