410 lines
12 KiB
Dart
410 lines
12 KiB
Dart
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 'navigation_policy.dart';
|
||
import 'page_probe.dart';
|
||
import 'timetable_adapter.dart';
|
||
import 'exam_adapter.dart';
|
||
|
||
class UndergraduateSession extends ChangeNotifier {
|
||
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,
|
||
)) {
|
||
if (request.isMainFrame) _failure('学校跳转未完成,请重新认证。');
|
||
return NavigationDecision.prevent;
|
||
}
|
||
if (request.isMainFrame &&
|
||
UndergraduateNavigationPolicy.isLogout(uri))
|
||
invalidateAuthentication();
|
||
return NavigationDecision.navigate;
|
||
},
|
||
onPageStarted: (_) {
|
||
_document++;
|
||
_poll?.cancel();
|
||
_probeKey = null;
|
||
pendingDraft = null;
|
||
if (!requestingExams) _generation++;
|
||
if (_active) {
|
||
_loading = true;
|
||
_emit();
|
||
}
|
||
},
|
||
onPageFinished: _loaded,
|
||
onWebResourceError: (error) async {
|
||
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('学校页面加载未完成,请检查网络后重试。');
|
||
},
|
||
onHttpError: (error) async {
|
||
final g = _generation,
|
||
d = _document,
|
||
current = await web.currentUrl();
|
||
if (_valid(g) &&
|
||
d == _document &&
|
||
error.request?.uri.toString() == current)
|
||
_failure('学校请求未完成,请稍后重试。');
|
||
},
|
||
),
|
||
);
|
||
Future<void> openTimetable() async {
|
||
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 {
|
||
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 {
|
||
await web.loadRequest(uri);
|
||
} catch (_) {
|
||
if (_valid(g)) _failure('学校页面打开未完成,请重试。');
|
||
}
|
||
}
|
||
|
||
void setReadingActive(bool active) {
|
||
if (_disposed) return;
|
||
if (!active) {
|
||
_active = false;
|
||
_reset();
|
||
return;
|
||
}
|
||
_active = true;
|
||
if (hasOpened && !_complete && !_failed) {
|
||
unawaited(
|
||
web.currentUrl().then((url) {
|
||
if (url != null && _active) _loaded(url);
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
void _failure(String text) {
|
||
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 {
|
||
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;
|
||
final status = await _evidence(uri, false);
|
||
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);
|
||
_complete = true;
|
||
_cancelTimers();
|
||
message = '考试已全部读取,正在保存…';
|
||
_emit();
|
||
} else {
|
||
await _requestExam(g);
|
||
}
|
||
} catch (error) {
|
||
if (_valid(g) && d == _document) _failure('$error');
|
||
} finally {
|
||
if (_probeKey == key) _probeKey = null;
|
||
}
|
||
}
|
||
|
||
Future<void> _requestExam(int g) async {
|
||
if (!_valid(g) || query == null || _complete) return;
|
||
final type = query!.orderedTypes[_index];
|
||
_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}读取超时,请重试。');
|
||
});
|
||
_batchTimeout ??= Timer(const Duration(seconds: 120), () {
|
||
if (_valid(g)) _failure('考试读取超时,请重试。');
|
||
});
|
||
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();
|
||
_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();
|
||
}
|
||
}
|