import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import '../../services/diagnostic_log.dart'; import 'school_browser_diagnostics.dart'; class SchoolPageCandidate { const SchoolPageCandidate(this.token, this.html); final String token, html; } abstract interface class SchoolFullPageGateway { bool supports(WebViewController web); Future fetch( WebViewController web, Uri url, String documentToken, ); Future render(String token); Future discard(String token); Future invalidate(WebViewController web); } enum FullPageRecoveryResult { unchanged, restored, failed, cancelled } class AndroidSchoolFullPageGateway implements SchoolFullPageGateway { const AndroidSchoolFullPageGateway(); static const _channel = MethodChannel('com.tpschedule.app/school_document'); @override bool supports(WebViewController web) => web.platform is AndroidWebViewController; @override Future fetch( WebViewController web, Uri url, String documentToken, ) async { final response = await _channel.invokeMapMethod('fetch', { 'viewId': (web.platform as AndroidWebViewController).webViewIdentifier, 'url': url.toString(), 'documentToken': documentToken, }); final token = response?['token'], page = response?['html']; if (token is! String || !RegExp(r'^[A-Za-z0-9-]{1,128}$').hasMatch(token) || page is! String) { throw const FormatException('Invalid page response'); } return SchoolPageCandidate(token, page); } @override Future render(String token) async => await _channel.invokeMethod('render', {'token': token}) == true; @override Future discard(String token) => _channel.invokeMethod('discard', {'token': token}); @override Future invalidate(WebViewController web) => _channel.invokeMethod( 'invalidate', {'viewId': (web.platform as AndroidWebViewController).webViewIdentifier}, ); } class SchoolFullPageRecovery { SchoolFullPageRecovery({SchoolFullPageGateway? gateway}) : gateway = gateway ?? const AndroidSchoolFullPageGateway(); final SchoolFullPageGateway gateway; final _attempted = {}; int _epoch = 0; WebViewController? _lastWeb; void reset() { invalidateDocument(); _attempted.clear(); } void invalidateDocument() { _epoch++; final web = _lastWeb; if (web != null && gateway.supports(web)) { unawaited(gateway.invalidate(web).catchError((Object _) {})); } } static bool canRestore(Uri uri) { if (uri.scheme != 'https' || uri.host != 'eams.uestc.edu.cn' || uri.port != 443 || uri.userInfo.isNotEmpty) { return false; } if (uri.queryParametersAll.keys.any( (key) => const { 'x-requested-with', 'ticket', 'password', 'username', 'j_username', 'j_password', 'optype', 'operation', 'action', 'method', }.contains(key.toLowerCase()), )) { return false; } return uri.path == '/eams' || uri.path == '/eams/' || RegExp( r'^/eams/(home(?:Ext)?(?:!(?:main|index|welcome))?|index|courseTableForStd(?:!courseTable)?|stdExamTable!examTable)\.action$', ).hasMatch(uri.path); } Map? _pageState(Object raw) { dynamic value = raw; for (var i = 0; i < 2 && value is String; i++) { value = jsonDecode(value); } return value is Map && value['scripts'] is int && value['scripts'] >= 0 && value['scripts'] <= 4 && value['styles'] == 0 && value['links'] == 0 && value['jquery'] == false && value['documentToken'] is String && RegExp(r'^[A-Za-z0-9-]{1,128}$').hasMatch(value['documentToken']) ? value : null; } bool _completePage(String source, Uri uri) { if (source.length > 2 * 1024 * 1024 || utf8.encode(source).length > 2 * 1024 * 1024 || !RegExp(r')', caseSensitive: false).hasMatch(source)) { return false; } final document = html.parse(source); final head = document.head; if (document.querySelector('input[type="password"]') != null || head == null || !head .querySelectorAll('script[src]') .any((e) => e.attributes['src']!.trim().isNotEmpty) || !head .querySelectorAll('link[rel~="stylesheet"][href]') .any((e) => e.attributes['href']!.trim().isNotEmpty)) { return false; } if (head .querySelectorAll('meta[http-equiv]') .any((e) => e.attributes['http-equiv']!.toLowerCase() == 'refresh')) { return false; } for (final base in head.querySelectorAll('base[href]')) { if (uri.resolve(base.attributes['href']!).origin != uri.origin) { return false; } } return document.querySelector( '#manualArrangeCourseTable, #stdExamTableForm', ) != null || document.querySelectorAll('a[href]').any((a) { final target = uri.resolve(a.attributes['href']!); return target.scheme == 'https' && target.host == uri.host && target.port == 443 && target.userInfo.isEmpty && target.path == '/eams/logout.action'; }); } void _record(String phase, [String? code]) => DiagnosticLog.instance.add( 'school_full_page_restore', fields: {'school': 'undergraduate', 'phase': phase, 'code': code}, ); Future tryRestore( WebViewController web, Uri url, { required bool authenticated, required String documentKey, required bool Function() isCurrent, void Function()? onStarted, }) async { if (!authenticated || !canRestore(url) || !gateway.supports(web) || _attempted.contains(documentKey)) { return FullPageRecoveryResult.unchanged; } final epoch = _epoch; _lastWeb = web; bool valid() => isCurrent() && epoch == _epoch; SchoolPageCandidate? candidate; try { if (!valid() || await web.currentUrl() != url.toString() || !valid()) { return FullPageRecoveryResult.cancelled; } final nonce = List.generate( 4, (_) => Random.secure() .nextInt(0x100000000) .toRadixString(16) .padLeft(8, '0'), ).join(); final diagnostic = schoolPageDiagnosticsScript.replaceAll( '__EXPECTED_URL__', jsonEncode(url.toString()), ); final raw = await web.runJavaScriptReturningResult('''(() => { const state=JSON.parse($diagnostic); if(!state)return 'null'; if(typeof window.__tpscheduleDocumentToken!=='string')Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'$nonce',configurable:true}); state.documentToken=window.__tpscheduleDocumentToken; return JSON.stringify(state); })()'''); if (!valid() || await web.currentUrl() != url.toString() || !valid()) { return FullPageRecoveryResult.cancelled; } final state = _pageState(raw); if (state == null) return FullPageRecoveryResult.unchanged; if (_attempted.length >= 4) return FullPageRecoveryResult.failed; _attempted.add(documentKey); onStarted?.call(); if (!valid()) return FullPageRecoveryResult.cancelled; _record('requested', 'missing_head'); candidate = await gateway .fetch(web, url, state['documentToken'] as String) .timeout(const Duration(seconds: 35)); if (!valid() || await web.currentUrl() != url.toString() || !valid()) { _record('cancelled', 'document_changed'); return FullPageRecoveryResult.cancelled; } if (!_completePage(candidate.html, url)) { _record('rejected', 'incomplete_head'); return FullPageRecoveryResult.failed; } if (!valid()) return FullPageRecoveryResult.cancelled; final rendered = await gateway .render(candidate.token) .timeout(const Duration(seconds: 10)); _record( rendered ? 'applied' : 'cancelled', rendered ? null : 'document_changed', ); return rendered ? FullPageRecoveryResult.restored : FullPageRecoveryResult.cancelled; } catch (error) { if (!valid()) return FullPageRecoveryResult.cancelled; const codes = { 'invalid_url', 'no_session', 'document_changed', 'unexpected_status', 'redirect_rejected', 'invalid_content_type', 'too_large', 'network_failed', 'tls_failed', 'invalid_cookie', 'cookie_scope', 'unsupported_encoding', 'unsafe_url', 'stale_document', 'invalid_response', 'unavailable', }; final code = error is PlatformException && codes.contains(error.code) ? error.code : error is TimeoutException ? 'timeout' : 'capture_failed'; _record('failed', code); return FullPageRecoveryResult.failed; } finally { if (candidate != null) { try { await gateway.discard(candidate.token); } catch (_) { /* No pending private page is persisted. */ } } } } }