feat: connect undergraduate manual timetable and exam batches
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user