Files
TPSchedule/mobile/lib/features/school/undergraduate/session.dart
T

530 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../data/json_schedule_import.dart';
import '../../../domain/exam_schedule.dart';
import '../../../domain/undergraduate_exam_query.dart';
import '../../../services/diagnostic_log.dart';
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<SchoolBrowserInfo>? _browserReady;
String? browserVersion;
static final loginUri = Uri.parse('https://eams.uestc.edu.cn/eams/');
static const manualMessage = '请在学校网页打开课表并选择学期,然后点击导入当前课表。';
WebViewController? _web;
WebViewController get web => _web ??= _createWeb();
bool _disposed = false,
_active = false,
_connected = false,
_loading = false,
_manualCapture = false,
_failed = false,
_complete = false;
bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false;
String message = manualMessage;
int _generation = 0, _document = 0, _index = 0;
String? _probeKey;
Uri? _expected;
UndergraduateExamQuery? query;
ImportDraft? pendingDraft;
ExamSchedule? pendingExams;
final _parts = <ExamSchedule>[];
Timer? _poll, _itemTimeout, _batchTimeout;
Future<void>? _logoutFuture;
int get pageGeneration => _generation;
bool get requestingExams => query != null;
bool get canLogout => hasOpened || logoutFailed;
bool get busy => _loading || _manualCapture || _itemTimeout != null;
String get statusLabel => logoutFailed
? '退出未完成'
: _failed
? '读取未完成'
: _connected
? '已连接'
: hasOpened
? '等待学校登录'
: '未连接';
void _emit() {
if (!_disposed) notifyListeners();
}
bool _valid(int g) => !_disposed && _active && _generation == g;
void _cancelTimers() {
_poll?.cancel();
_itemTimeout?.cancel();
_batchTimeout?.cancel();
_poll = null;
_itemTimeout = null;
_batchTimeout = null;
}
void _reset() {
_generation++;
_cancelTimers();
_parts.clear();
_index = 0;
_expected = null;
pendingDraft = null;
pendingExams = null;
_complete = false;
_failed = false;
_manualCapture = false;
_loading = false;
_probeKey = null;
}
WebViewController _createWeb() => WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) async {
final uri = Uri.tryParse(request.url);
final upgrade = uri != null && request.isMainFrame
? UndergraduateNavigationPolicy.secureIdentityRedirect(uri)
: null;
if (upgrade != null) {
try {
await web.loadRequest(upgrade);
} catch (_) {
_failure('身份认证页面暂时无法打开');
}
return NavigationDecision.prevent;
}
if (uri == null ||
!UndergraduateNavigationPolicy.canNavigate(
uri,
isMainFrame: request.isMainFrame,
)) {
DiagnosticLog.instance.resourceFailure(
'undergraduate',
request.url,
code: 'blocked_navigation',
mainFrame: request.isMainFrame,
);
if (request.isMainFrame) {
_failure('学校跳转未完成,请重新认证。', code: 'blocked_navigation');
}
return NavigationDecision.prevent;
}
if (request.isMainFrame &&
UndergraduateNavigationPolicy.isLogout(uri)) {
invalidateAuthentication();
}
return NavigationDecision.navigate;
},
onPageStarted: (url) {
DiagnosticLog.instance.navigation('undergraduate', 'started', url);
// Within a batch, type-to-type loads retain their generation. Once
// published, any new document invalidates the deferred save result.
if (pendingExams != null) {
_reset();
}
_document++;
_poll?.cancel();
_probeKey = null;
pendingDraft = null;
if (!requestingExams) _generation++;
if (_active) {
_loading = true;
_emit();
}
},
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,
current = await web.currentUrl();
if (_valid(g) && d == _document && error.url == current) {
_failure(
'学校页面加载未完成,请检查网络后重试。',
code: 'web_resource_error',
status: error.errorCode,
);
}
},
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();
if (_valid(g) &&
d == _document &&
error.request?.uri.toString() == current) {
_failure(
'学校请求未完成,请稍后重试。',
code: 'http_error',
status: error.response?.statusCode,
);
}
},
),
);
Future<void> openTimetable() async {
DiagnosticLog.instance.add(
'login_opened',
fields: {'school': 'undergraduate', 'phase': 'timetable'},
);
await _logoutFuture;
if (_disposed) return;
_reset();
query = null;
_active = true;
hasOpened = true;
awaitingAuthentication = !_connected;
message = manualMessage;
await _navigate(loginUri);
}
Future<void> openExams(UndergraduateExamQuery value) async {
DiagnosticLog.instance.add(
'login_opened',
fields: {
'school': 'undergraduate',
'phase': 'exams',
'term': value.term.id,
},
);
await _logoutFuture;
if (_disposed) return;
_reset();
query = value;
_active = true;
hasOpened = true;
awaitingAuthentication = !_connected;
message = '请在学校网页完成登录,随后自动查找考试。';
if (_connected) {
await _requestExam(_generation);
} else {
await _navigate(loginUri);
}
}
Future<void> _navigate(Uri uri) async {
final g = _generation;
_loading = true;
_emit();
try {
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('学校页面打开未完成,请重试。');
}
}
void setReadingActive(bool active) {
if (_disposed) return;
if (!active) {
_active = false;
_reset();
return;
}
_active = true;
if (hasOpened && !_loading && !_complete && !_failed) {
unawaited(
web.currentUrl().then((url) {
if (url != null && _active) _loaded(url);
}),
);
}
}
void _failure(String text, {String code = 'read_failure', int? status}) {
DiagnosticLog.instance.add(
'school_read_failed',
fields: {
'school': 'undergraduate',
'code': code,
'status': status,
'term': query?.term.id,
},
);
if (_disposed) return;
_reset();
_failed = true;
awaitingAuthentication = false;
message = '读取未完成:$text';
_emit();
}
void _waitForLogin() {
_cancelTimers();
_parts.clear();
_index = 0;
_expected = null;
_connected = false;
awaitingAuthentication = true;
message = requestingExams ? '请在学校网页完成登录和验证码,随后重新读取全部考试。' : manualMessage;
_emit();
}
void _schedulePoll(int g, int document, String url) {
_poll?.cancel();
_poll = Timer(const Duration(milliseconds: 450), () {
if (_valid(g) && _document == document && !_failed && !_complete) {
unawaited(_loaded(url));
}
});
}
Future<UndergraduatePageEvidence> _evidence(Uri uri, bool capture) async =>
UndergraduatePageEvidence.decode(
await web.runJavaScriptReturningResult(
capture ? undergraduateCaptureScript : undergraduateStatusProbeScript,
),
uri,
);
Future<void> _loaded(String url) async {
DiagnosticLog.instance.navigation('undergraduate', 'finished', url);
final g = _generation, d = _document, key = '$_generation:$_document';
if (!_valid(g) || _failed || _complete || _probeKey == key) return;
_probeKey = key;
try {
final current = await web.currentUrl();
if (!_valid(g) || d != _document || current != url) return;
final uri = Uri.tryParse(url);
if (uri == null ||
!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',
fields: {
'school': 'undergraduate',
'result': status.authenticated,
'count': status.pages.length,
},
);
if (!_valid(g) || d != _document) return;
_loading = false;
if (!status.authenticated) {
_waitForLogin();
_schedulePoll(g, d, url);
return;
}
_connected = true;
awaitingAuthentication = false;
if (!requestingExams) {
message = manualMessage;
_emit();
return;
}
if (_expected == null || uri.toString() != _expected.toString()) {
await _requestExam(g);
return;
}
final evidence = await _evidence(uri, true);
if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'exams').toList();
if (pages.isEmpty) {
_schedulePoll(g, d, url);
return;
}
if (pages.length != 1) throw const FormatException('排考结果页面不明确');
final part = UndergraduateExamAdapter.extract(
pages.single.html,
expectedTerm: query!.term,
expectedType: query!.orderedTypes[_index],
);
if (!_valid(g) || d != _document) return;
_parts.add(part);
_index++;
_itemTimeout?.cancel();
_itemTimeout = null;
if (_index == query!.orderedTypes.length) {
pendingExams = UndergraduateExamAdapter.merge(query!, _parts);
DiagnosticLog.instance.add(
'school_parsed',
fields: {
'school': 'undergraduate',
'phase': 'exams',
'count': pendingExams!.records.length,
},
);
_complete = true;
_cancelTimers();
message = '考试已全部读取,正在保存…';
_emit();
} else {
await _requestExam(g);
}
} catch (error) {
final current = await web.currentUrl();
if (_valid(g) && d == _document) {
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;
}
}
Future<void> _requestExam(int g) async {
if (!_valid(g) || query == null || _complete) return;
final type = query!.orderedTypes[_index];
DiagnosticLog.instance.add(
'school_read_requested',
fields: {
'school': 'undergraduate',
'term': query!.term.id,
'type': type.id,
},
);
_expected = query!.uriFor(type);
_loading = true;
message = '正在读取${type.label}(${_index + 1}/${query!.types.length})';
_emit();
_itemTimeout?.cancel();
_itemTimeout = Timer(const Duration(seconds: 45), () {
if (_valid(g)) _failure('${type.label}读取超时,请重试。', code: 'item_timeout');
});
_batchTimeout ??= Timer(const Duration(seconds: 120), () {
if (_valid(g)) _failure('考试读取超时,请重试。', code: 'batch_timeout');
});
await _navigate(_expected!);
}
Future<void> readCurrentTimetable() async {
if (requestingExams || _manualCapture || !_active) return;
final g = _generation, d = _document;
_manualCapture = true;
_emit();
try {
final current = Uri.parse(await web.currentUrl() ?? '');
final evidence = await _evidence(current, true);
if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'timetable').toList();
if (pages.length != 1) {
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
}
final draft = UndergraduateTimetableAdapter.extract(
pages.single.html,
pages.single.url,
);
if (!_valid(g) || d != _document) return;
pendingDraft = draft;
_connected = true;
awaitingAuthentication = false;
message = '课表已识别,正在确认导入…';
_emit();
} catch (error) {
if (_valid(g) && d == _document) {
message = '导入未完成:$error';
_emit();
}
} finally {
if (_valid(g)) {
_manualCapture = false;
_emit();
}
}
}
bool saved({required int generation}) {
if (!_valid(generation) || pendingDraft == null && pendingExams == null) {
return false;
}
pendingDraft = null;
pendingExams = null;
message = '已导入';
_emit();
return true;
}
Future<void> reauthenticate() async {
await _logoutFuture;
if (_disposed) return;
_reset();
_active = true;
_connected = false;
awaitingAuthentication = true;
hasOpened = true;
message = '请在学校网页重新登录。';
await _navigate(loginUri);
}
void invalidateAuthentication() {
_reset();
_active = false;
hasOpened = false;
_connected = false;
awaitingAuthentication = false;
message = '学校登录已退出';
_emit();
}
Future<void> logout() =>
_logoutFuture ??= _logout().whenComplete(() => _logoutFuture = null);
Future<void> _logout() async {
final opened = hasOpened;
_active = false;
invalidateAuthentication();
if (!opened && !logoutFailed) return;
try {
await WebViewCookieManager().clearCookies();
if (_web != null) {
await _web!.clearCache();
await _web!.clearLocalStorage();
await _web!.loadHtmlString('<html></html>');
}
hasOpened = false;
logoutFailed = false;
query = null;
} catch (_) {
logoutFailed = true;
message = '退出未完成,请重试。';
}
_emit();
}
@override
void dispose() {
_disposed = true;
_active = false;
_generation++;
_cancelTimers();
super.dispose();
}
}