Files
TPSchedule/mobile/lib/features/school/school_session.dart
T

708 lines
22 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/school_term.dart';
import '../../services/diagnostic_log.dart';
import 'school_navigation_policy.dart';
import 'school_page_adapter.dart';
import 'school_exam_adapter.dart';
import 'school_login_flow.dart';
import 'school_page_probe.dart';
import 'school_browser.dart';
import 'school_browser_diagnostics.dart';
enum SchoolConnectionState {
signedOut,
authenticating,
connected,
expired,
unknown,
}
class SchoolSession extends ChangeNotifier {
SchoolSession({this.configureBrowser = configureSchoolBrowser});
final SchoolBrowserConfigurator configureBrowser;
Future<SchoolBrowserInfo>? _browserReady;
String? browserVersion;
Future<void> _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();
WebViewController _createWeb() => WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) async {
final uri = Uri.tryParse(request.url);
final upgrade = uri == null || !request.isMainFrame
? null
: SchoolNavigationPolicy.secureIdentityRedirect(uri);
if (upgrade != null) {
final generation = _pageGeneration;
try {
await _loadRequest(upgrade);
} catch (_) {
if (generation == _pageGeneration) {
_failure(
'学校身份认证暂时无法打开,请重试。',
code: 'authentication_load_failed',
);
}
}
return NavigationDecision.prevent;
}
if (uri == null ||
!SchoolNavigationPolicy.canNavigate(
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}',
);
}
if (request.isMainFrame) {
_failure('学校跳转到了其他地址,请返回学校认证入口重试。', code: 'blocked_navigation');
}
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onPageStarted: (url) {
DiagnosticLog.instance.navigation('graduate', 'started', url);
_poll?.cancel();
_manualRead = false;
_pageGeneration++;
_reader.invalidate();
pendingDraft = null;
pendingExams = null;
if (!hasOpened || _routeClosed) return;
_loading = true;
notifyListeners();
},
onPageFinished: (url) {
final generation = _pageGeneration;
unawaited(
recordSchoolPageDiagnostics(
web,
'graduate',
url,
isCurrent: () =>
hasOpened && !_routeClosed && generation == _pageGeneration,
),
);
unawaited(_loaded(url));
},
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();
if (hasOpened &&
generation == _pageGeneration &&
error.url == current) {
_failure(
'学校页面暂时未能加载,请检查网络后重试。',
code: 'web_resource_error',
status: error.errorCode,
);
}
},
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 &&
generation == _pageGeneration &&
error.request != null &&
error.request!.uri == current) {
_failure(
'学校请求未完成,请稍后重试。',
code: 'http_error',
status: error.response?.statusCode,
);
}
},
),
);
final _reader = SchoolReader(SchoolPageAdapter());
final _flow = SchoolLoginFlow();
Timer? _timeout;
Timer? _poll;
bool _readingActive = false, _manualRead = false;
bool _routeClosed = false;
int _pollsLeft = 0;
int? _probeGeneration;
SchoolConnectionState connectionState = SchoolConnectionState.signedOut;
bool _loading = false,
hasOpened = false,
finding = false,
requestingExams = false;
bool logoutFailed = false;
bool get canLogout => hasOpened || logoutFailed;
bool get busy => _loading || _reader.busy;
int _pageGeneration = 0;
int get pageGeneration => _pageGeneration;
Future<void>? _logoutFuture;
int _openSerial = 0;
ImportDraft? pendingDraft;
ExamSchedule? pendingExams;
SchoolTerm? selectedTerm;
bool _termSelectionRequested = true;
bool _termSelectionReady = false;
List<SchoolTerm> _liveTerms = const [];
bool get needsTermSelection =>
hasOpened &&
connectionState == SchoolConnectionState.connected &&
_termSelectionRequested &&
_termSelectionReady;
List<SchoolTerm> get termChoices {
final terms = [...(_liveTerms.isEmpty ? SchoolTerm.choices : _liveTerms)];
if (selectedTerm != null && !terms.contains(selectedTerm)) {
terms.add(selectedTerm!);
}
return terms..sort((a, b) => b.id.compareTo(a.id));
}
String message = '请在学校网页中完成登录与验证码。';
String get statusLabel => logoutFailed
? '学校退出未完成,请重试'
: switch (connectionState) {
SchoolConnectionState.signedOut => '未登录',
SchoolConnectionState.authenticating => '请在学校页面完成认证',
SchoolConnectionState.connected => '研究生系统已连接',
SchoolConnectionState.expired => '学校登录已过期',
SchoolConnectionState.unknown => '学校连接未确认',
};
// HTTPS entry produces the same official HTTP service callback. Do not drop
// the graduate-service query by opening a generic identity login page.
static final authUrl = Uri.parse(
'https://yjsjy.uestc.edu.cn/pyxx/jzsso/login',
);
static final timetableUrl = Uri.parse(
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73',
);
static final examUrl = Uri.parse(
'https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index?xq=73',
);
Uri get _target => requestingExams
? (selectedTerm?.examUri ?? examUrl)
: (selectedTerm?.timetableUri ?? timetableUrl);
Future<void> open() => _open(exams: false);
Future<void> openTimetable({SchoolTerm? term, bool chooseTerm = false}) =>
_open(exams: false, term: term, chooseTerm: chooseTerm);
Future<void> openExams({SchoolTerm? term, bool chooseTerm = false}) =>
_open(exams: true, term: term, chooseTerm: chooseTerm);
Future<void> openAuthentication() => _open(
exams: requestingExams,
term: selectedTerm,
chooseTerm: _termSelectionRequested,
forceAuth: true,
);
Future<void> _open({
required bool exams,
SchoolTerm? term,
bool chooseTerm = false,
bool forceAuth = false,
}) async {
final serial = ++_openSerial;
DiagnosticLog.instance.add(
'login_opened',
fields: {
'school': 'graduate',
'phase': exams ? 'exams' : 'timetable',
'term': term?.id,
},
);
final closing = _logoutFuture;
if (closing != null) await closing;
if (serial != _openSerial) return;
_timeout?.cancel();
_poll?.cancel();
_manualRead = false;
_pageGeneration++;
_reader.invalidate();
final generation = _pageGeneration;
pendingDraft = null;
pendingExams = null;
selectedTerm = term;
_termSelectionRequested = chooseTerm || term == null;
_termSelectionReady = false;
requestingExams = exams;
logoutFailed = false;
hasOpened = true;
_loading = true;
_flow.begin(_target);
final connected =
connectionState == SchoolConnectionState.connected && !forceAuth;
_termSelectionReady = connected;
finding = connected && !needsTermSelection;
_loading = !needsTermSelection;
message = needsTermSelection
? '请选择要导入的学校学期。'
: connected
? _findingMessage
: '请在学校网页中完成登录与验证码。';
if (finding) _armTimeout();
notifyListeners();
if (needsTermSelection) return;
try {
await _loadRequest(connected ? _target : authUrl);
} catch (_) {
if (serial == _openSerial && generation == _pageGeneration) {
_failure('学校入口无法打开,请检查校园网络后重试。', code: 'entry_load_failed');
}
}
}
Future<void> selectTerm(SchoolTerm term) async {
if (!hasOpened ||
!needsTermSelection ||
connectionState != SchoolConnectionState.connected ||
!termChoices.contains(term)) {
return;
}
final serial = ++_openSerial;
_poll?.cancel();
_timeout?.cancel();
_pageGeneration++;
_reader.invalidate();
final generation = _pageGeneration;
pendingDraft = null;
pendingExams = null;
selectedTerm = term;
_termSelectionRequested = false;
_manualRead = false;
_flow.begin(_target);
finding = true;
_loading = true;
message = _findingMessage;
_armTimeout();
notifyListeners();
try {
await _loadRequest(_target);
} catch (_) {
if (serial == _openSerial && generation == _pageGeneration) {
_failure('学校学期查询页面暂时无法打开,请重试。', code: 'query_load_failed');
}
}
}
String get _findingMessage => requestingExams ? '正在查找考试安排,请稍候' : '正在查找课表,请稍候';
void _armTimeout() {
_timeout?.cancel();
_timeout = Timer(
const Duration(seconds: 45),
() => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout'),
);
}
void _failure(String text, {String code = 'flow_failure', int? status}) {
DiagnosticLog.instance.add(
'school_read_failed',
fields: {
'school': 'graduate',
'code': code,
'status': status,
'term': selectedTerm?.id,
},
);
if (!hasOpened || _routeClosed) return;
_pageGeneration++;
_reader.invalidate();
_timeout?.cancel();
_poll?.cancel();
pendingDraft = null;
pendingExams = null;
finding = false;
_loading = false;
message = text;
notifyListeners();
}
/// Poll only while the authentication screen is open. Navigation callbacks
/// still read immediately; polling handles scripts and frames ready later.
void setReadingActive(bool active) {
if (_readingActive == active && _routeClosed == !active) return;
_readingActive = active;
_routeClosed = !active;
_poll?.cancel();
if (active && hasOpened) {
web.currentUrl().then((url) {
if (_readingActive && url != null) _loaded(url);
});
} else if (!active) {
_pageGeneration++;
_reader.invalidate();
_timeout?.cancel();
pendingDraft = null;
pendingExams = null;
finding = false;
_loading = false;
}
}
void _scheduleProbe(String url, int generation) {
if (!_readingActive ||
!hasOpened ||
generation != _pageGeneration ||
needsTermSelection ||
pendingDraft != null ||
pendingExams != null) {
return;
}
_poll?.cancel();
if (_pollsLeft-- <= 0) {
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout');
return;
}
_poll = Timer(const Duration(milliseconds: 750), () {
_loaded(url, resetPolling: false);
});
}
Future<SchoolPageEvidence> _evidence(Uri uri) async =>
SchoolPageEvidence.decode(
await web.runJavaScriptReturningResult(schoolPageProbeScript),
uri,
);
Future<void> _loaded(String url, {bool resetPolling = true}) async {
if (resetPolling) {
DiagnosticLog.instance.navigation('graduate', 'finished', url);
}
if (!hasOpened || _routeClosed) return;
final generation = _pageGeneration;
final uri = Uri.tryParse(url);
final current = await web.currentUrl();
if (!hasOpened ||
generation != _pageGeneration ||
current != url ||
uri == null ||
!SchoolNavigationPolicy.isSchoolPage(uri)) {
return;
}
_loading = false;
if (pendingDraft != null || pendingExams != null) return;
if (uri.host == 'idas.uestc.edu.cn') {
_termSelectionReady = false;
_timeout?.cancel();
finding = false;
connectionState = SchoolConnectionState.authenticating;
_flow.onLoaded(uri, authenticated: false);
message = '请在学校网页中完成登录与验证码。';
notifyListeners();
return;
}
if (!SchoolNavigationPolicy.isGraduatePage(uri) ||
_probeGeneration == generation) {
return;
}
if (resetPolling) _pollsLeft = 60;
_probeGeneration = generation;
final targetPage = requestingExams
? SchoolNavigationPolicy.isExam(uri)
: SchoolNavigationPolicy.isTimetable(uri);
if (!_termSelectionRequested && (targetPage || _manualRead)) {
finding = true;
message = _findingMessage;
notifyListeners();
if (_timeout?.isActive != true) _armTimeout();
}
try {
final evidence = await _evidence(uri);
DiagnosticLog.instance.add(
'school_evidence',
fields: {
'school': 'graduate',
'result': evidence.authenticated,
'count': evidence.pages.length,
'term': selectedTerm?.id,
},
);
if (!hasOpened ||
generation != _pageGeneration ||
await web.currentUrl() != url) {
return;
}
if (evidence.terms.isNotEmpty) _liveTerms = evidence.terms;
if (_termSelectionRequested) {
if (evidence.authenticated || evidence.pages.isNotEmpty) {
connectionState = SchoolConnectionState.connected;
_termSelectionReady = true;
_timeout?.cancel();
_poll?.cancel();
finding = false;
message = '请选择要导入的学校学期。';
notifyListeners();
}
return;
}
for (final page in evidence.pages) {
if (page.kind != (requestingExams ? 'exams' : 'timetable')) continue;
try {
if (requestingExams) {
final draft = SchoolExamAdapter.extract(page.html);
if (generation != _pageGeneration) return;
if (draft.termId != selectedTerm?.id.toString()) {
DiagnosticLog.instance.add(
'term_mismatch',
fields: {'school': 'graduate'},
);
continue;
}
pendingExams = draft;
} else {
// The probe validates the live document's origin. The strict parser
// gets only its recognized fragment with the canonical table path.
final draft = await _reader.read(_target, () async => page.html);
if (!hasOpened || generation != _pageGeneration) return;
if (draft.schoolTerm != selectedTerm) {
DiagnosticLog.instance.add(
'term_mismatch',
fields: {'school': 'graduate'},
);
continue;
}
pendingDraft = draft;
}
_timeout?.cancel();
_poll?.cancel();
connectionState = SchoolConnectionState.connected;
finding = true;
message = '读取成功,正在保存…';
DiagnosticLog.instance.add(
'school_parsed',
fields: {
'school': 'graduate',
'phase': requestingExams ? 'exams' : 'timetable',
'count':
pendingDraft?.courses.length ??
pendingExams?.records.length ??
0,
},
);
notifyListeners();
return;
} catch (error) {
DiagnosticLog.instance.add(
'school_parse_rejected',
fields: {
'school': 'graduate',
'code': error.runtimeType.toString(),
},
);
if (generation != _pageGeneration) return;
// A recognized table can still be incomplete during asynchronous load.
}
}
if (!hasOpened || generation != _pageGeneration) return;
final next = _manualRead
? null
: _flow.onLoaded(uri, authenticated: evidence.authenticated);
if (next != null) {
DiagnosticLog.instance.add(
'school_redirect_requested',
fields: {
'school': 'graduate',
'phase': requestingExams ? 'exams' : 'timetable',
'term': selectedTerm?.id,
},
);
finding = true;
_loading = true;
message = _findingMessage;
_armTimeout();
notifyListeners();
try {
await _loadRequest(next);
} catch (_) {
if (generation == _pageGeneration) {
_failure('课表查询页面暂时无法打开,请重试。', code: 'query_load_failed');
}
}
return;
}
if (!finding) message = '请在学校网页完成认证;进入研究生系统后会自动读取。';
} catch (error) {
DiagnosticLog.instance.add(
'school_probe_failed',
fields: {'school': 'graduate', 'code': error.runtimeType.toString()},
);
} finally {
if (_probeGeneration == generation) _probeGeneration = null;
_scheduleProbe(url, generation);
}
if (hasOpened && generation == _pageGeneration) notifyListeners();
}
Future<ImportDraft> readTimetable() async {
if (_termSelectionRequested || selectedTerm == null) {
throw UnsupportedSchoolLayout();
}
final generation = _pageGeneration;
final uri = Uri.tryParse(await web.currentUrl() ?? '');
if (uri == null || !SchoolNavigationPolicy.isGraduatePage(uri)) {
throw UnsupportedSchoolLayout();
}
final evidence = await _evidence(uri);
if (generation != _pageGeneration ||
await web.currentUrl() != uri.toString()) {
throw UnsupportedSchoolLayout();
}
for (final page in evidence.pages) {
if (page.kind != 'timetable') continue;
try {
final draft = await _reader.read(
selectedTerm!.timetableUri,
() async => page.html,
);
if (draft.schoolTerm != selectedTerm) continue;
return draft;
} on UnsupportedSchoolLayout {
if (generation != _pageGeneration) rethrow;
}
}
throw UnsupportedSchoolLayout();
}
Future<void> readCurrentPage() async {
if (needsTermSelection) return;
_poll?.cancel();
_timeout?.cancel();
_pageGeneration++;
_reader.invalidate();
pendingDraft = null;
pendingExams = null;
_manualRead = true;
final url = await web.currentUrl();
if (url != null) await _loaded(url);
}
bool saved({
required bool success,
required int generation,
ImportDraft? draft,
ExamSchedule? exams,
}) {
if (generation != _pageGeneration ||
draft != pendingDraft ||
exams != pendingExams) {
return false;
}
pendingDraft = null;
pendingExams = null;
finding = false;
message = success
? '${requestingExams ? '考试安排' : '课表'}已导入并在本机保存。'
: '保存未完成,请重试。';
notifyListeners();
return true;
}
Future<void> logout() {
if (_logoutFuture != null) return _logoutFuture!;
final future = _performLogout();
_logoutFuture = future;
return future.whenComplete(() => _logoutFuture = null);
}
void invalidateAuthentication() {
_openSerial++;
_pageGeneration++;
_reader.invalidate();
_flow.reset();
_poll?.cancel();
_timeout?.cancel();
_readingActive = false;
_routeClosed = true;
hasOpened = false;
pendingDraft = null;
pendingExams = null;
finding = false;
_loading = false;
connectionState = SchoolConnectionState.signedOut;
message = '学校登录已退出';
notifyListeners();
}
Future<void> _performLogout() async {
_openSerial++;
_pageGeneration++;
_reader.invalidate();
_flow.reset();
_timeout?.cancel();
_poll?.cancel();
hasOpened = false;
pendingDraft = null;
pendingExams = null;
finding = false;
var failed = false;
try {
await WebViewCookieManager().clearCookies();
await web.clearCache();
await web.clearLocalStorage();
await web.loadHtmlString('<html></html>');
} catch (_) {
failed = true;
}
_loading = false;
logoutFailed = failed;
connectionState = failed
? SchoolConnectionState.unknown
: SchoolConnectionState.signedOut;
message = failed ? '学校退出未完成,请重试;重新连接会再次经过学校认证。' : '已退出学校登录。';
notifyListeners();
}
@override
void dispose() {
hasOpened = false;
_pageGeneration++;
_reader.invalidate();
_timeout?.cancel();
_poll?.cancel();
super.dispose();
}
}