268 lines
9.6 KiB
Dart
268 lines
9.6 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:webview_flutter/webview_flutter.dart';
|
|
|
|
import '../../data/json_schedule_import.dart';
|
|
import 'school_navigation_policy.dart';
|
|
import 'school_page_adapter.dart';
|
|
|
|
enum SchoolConnectionState {
|
|
signedOut,
|
|
authenticating,
|
|
connected,
|
|
expired,
|
|
unknown,
|
|
}
|
|
|
|
class SchoolSession extends ChangeNotifier {
|
|
SchoolSession() {
|
|
web = WebViewController()
|
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
..setNavigationDelegate(
|
|
NavigationDelegate(
|
|
onNavigationRequest: (request) {
|
|
final uri = Uri.tryParse(request.url);
|
|
if (uri == null ||
|
|
!SchoolNavigationPolicy.canNavigate(
|
|
uri,
|
|
isMainFrame: request.isMainFrame,
|
|
)) {
|
|
message = '学校跳转到了其他地址,已停止自动交接登录信息。';
|
|
notifyListeners();
|
|
return NavigationDecision.prevent;
|
|
}
|
|
return NavigationDecision.navigate;
|
|
},
|
|
onPageStarted: (_) {
|
|
_pageGeneration++;
|
|
_reader.invalidate();
|
|
pendingDraft = null;
|
|
if (!hasOpened) {
|
|
return;
|
|
}
|
|
_loading = true;
|
|
formReady = false;
|
|
notifyListeners();
|
|
},
|
|
onPageFinished: _loaded,
|
|
onWebResourceError: (error) {
|
|
if (error.isForMainFrame == true) {
|
|
_loading = false;
|
|
message = '学校页面暂时未能加载,请检查网络或使用统一认证入口。';
|
|
notifyListeners();
|
|
}
|
|
},
|
|
onHttpError: (error) async {
|
|
final current = Uri.tryParse(await web.currentUrl() ?? '');
|
|
if (!hasOpened ||
|
|
error.request == null ||
|
|
error.request!.uri != current) {
|
|
return;
|
|
}
|
|
_loading = false;
|
|
message = '学校页面请求未完成,可稍后重试。';
|
|
notifyListeners();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
late final WebViewController web;
|
|
final _reader = SchoolReader(SchoolPageAdapter());
|
|
SchoolConnectionState connectionState = SchoolConnectionState.signedOut;
|
|
bool _loading = false,
|
|
_submitting = false,
|
|
hasOpened = false,
|
|
formReady = false;
|
|
bool get busy => _loading || _submitting || _reader.busy;
|
|
int _pageGeneration = 0;
|
|
ImportDraft? pendingDraft;
|
|
String message = '请通过学校统一身份认证登录。';
|
|
String get statusLabel => switch (connectionState) {
|
|
SchoolConnectionState.signedOut => '未登录',
|
|
SchoolConnectionState.authenticating => '请在学校页面完成认证',
|
|
SchoolConnectionState.connected => '学校认证已确认',
|
|
SchoolConnectionState.expired => '学校登录已过期',
|
|
SchoolConnectionState.unknown => '学校页面已打开 · 等待完整课表',
|
|
};
|
|
static final authUrl = Uri.parse(
|
|
'https://idas.uestc.edu.cn/authserver/login',
|
|
);
|
|
static final timetableUrl = Uri.parse(
|
|
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1',
|
|
);
|
|
Future<void> open() => openTimetable();
|
|
Future<void> openTimetable() async {
|
|
hasOpened = true;
|
|
_loading = true;
|
|
pendingDraft = null;
|
|
message = '正在打开研究生课表页面…';
|
|
notifyListeners();
|
|
try {
|
|
await web.loadRequest(timetableUrl);
|
|
} catch (_) {
|
|
_loading = false;
|
|
message = '学校课表页面无法打开,请检查校园网络。';
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> openAuthentication() async {
|
|
hasOpened = true;
|
|
_loading = true;
|
|
message = '正在准备学校认证…';
|
|
notifyListeners();
|
|
try {
|
|
await web.loadRequest(authUrl);
|
|
} catch (_) {
|
|
_loading = false;
|
|
message = '学校认证页面暂时不可用。';
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
static const _readyScript = """(() => {
|
|
if (location.origin !== 'https://idas.uestc.edu.cn' || location.pathname !== '/authserver/login') return false;
|
|
const f = document.querySelector('.login-main #pwdFromId');
|
|
if (!f || f.method.toLowerCase() !== 'post') return false;
|
|
const action = new URL(f.action, location.href);
|
|
return action.origin === location.origin && action.pathname === '/authserver/login'
|
|
&& !!f.querySelector('#username') && !!f.querySelector('#password')
|
|
&& !!f.querySelector('#saltPassword') && !!f.querySelector('#pwdEncryptSalt')
|
|
&& !!f.querySelector('#login_submit') && typeof window.startLogin === 'function'
|
|
&& typeof window.encryptPassword === 'function' && typeof window.checkUserCaptcha === 'function';
|
|
})()""";
|
|
bool _truth(Object value) => value == true || value.toString() == 'true';
|
|
Future<void> _loaded(String url) async {
|
|
if (!hasOpened) {
|
|
return;
|
|
}
|
|
_loading = false;
|
|
final generation = _pageGeneration;
|
|
final uri = Uri.tryParse(url);
|
|
if (uri != null && SchoolNavigationPolicy.canInjectCredentials(uri)) {
|
|
connectionState = connectionState == SchoolConnectionState.connected
|
|
? SchoolConnectionState.expired
|
|
: SchoolConnectionState.authenticating;
|
|
try {
|
|
final ready = _truth(
|
|
await web.runJavaScriptReturningResult(_readyScript),
|
|
);
|
|
if (!hasOpened || generation != _pageGeneration) return;
|
|
formReady = ready;
|
|
} catch (_) {
|
|
if (!hasOpened || generation != _pageGeneration) return;
|
|
formReady = false;
|
|
}
|
|
message = formReady ? '可以使用应用界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。';
|
|
} else if (uri != null && SchoolNavigationPolicy.isTimetable(uri)) {
|
|
connectionState = SchoolConnectionState.unknown;
|
|
message = '已打开课表页面,正在核对课程结构。';
|
|
try {
|
|
final draft = await readTimetable();
|
|
if (!hasOpened || generation != _pageGeneration) return;
|
|
pendingDraft = draft;
|
|
connectionState = SchoolConnectionState.connected;
|
|
} catch (error) {
|
|
if (!hasOpened || generation != _pageGeneration) return;
|
|
message = error is UnsupportedSchoolLayout
|
|
? error.toString()
|
|
: '课表读取未完成,已有课表保持不变。';
|
|
}
|
|
} else {
|
|
connectionState = SchoolConnectionState.unknown;
|
|
message = '可点击右上角日历打开学校课表。';
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> submitCredentials(String username, String password) async {
|
|
if (busy) return;
|
|
_submitting = true;
|
|
message = '正在交接学校认证…';
|
|
notifyListeners();
|
|
try {
|
|
final url = Uri.tryParse(await web.currentUrl() ?? '');
|
|
if (url == null ||
|
|
!SchoolNavigationPolicy.canInjectCredentials(url) ||
|
|
!_truth(await web.runJavaScriptReturningResult(_readyScript))) {
|
|
throw StateError('学校表单尚未准备好,请使用学校页面登录');
|
|
}
|
|
final result = await web.runJavaScriptReturningResult('''(() => {
|
|
if (!($_readyScript)) return false;
|
|
const f = document.querySelector('.login-main #pwdFromId');
|
|
const u = f.querySelector('#username'), p = f.querySelector('#password');
|
|
u.value = ${jsonEncode(username)}; p.value = ${jsonEncode(password)};
|
|
p.removeAttribute('readonly');
|
|
u.dispatchEvent(new Event('input', {bubbles:true}));
|
|
p.dispatchEvent(new Event('input', {bubbles:true}));
|
|
window.checkUserCaptcha(); return true;
|
|
})()''');
|
|
if (!_truth(result)) throw StateError('学校表单已变化,请使用学校页面登录');
|
|
var settled = false;
|
|
for (var i = 0; i < 20; i++) {
|
|
if (_truth(
|
|
await web.runJavaScriptReturningResult(
|
|
'!!window.jQuery && window.jQuery.active === 0',
|
|
),
|
|
)) {
|
|
settled = true;
|
|
break;
|
|
}
|
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
|
}
|
|
final current = Uri.tryParse(await web.currentUrl() ?? '');
|
|
if (!settled ||
|
|
current == null ||
|
|
!SchoolNavigationPolicy.canInjectCredentials(current)) {
|
|
message = '请在学校页面继续完成验证。';
|
|
return;
|
|
}
|
|
await web.runJavaScript('''(() => {
|
|
if (!($_readyScript)) return;
|
|
window.startLogin(document.querySelector('.login-main #pwdFromId #login_submit'));
|
|
})()''');
|
|
message = '已交给学校处理;如有验证码,请在学校页面完成。';
|
|
} catch (_) {
|
|
message = '登录交接未完成,请使用学校页面继续。';
|
|
} finally {
|
|
_submitting = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<ImportDraft> readTimetable() async {
|
|
final uri = Uri.tryParse(await web.currentUrl() ?? '');
|
|
if (uri == null) throw UnsupportedSchoolLayout();
|
|
return _reader.read(uri, () async {
|
|
final raw = await web.runJavaScriptReturningResult(
|
|
'JSON.stringify(document.documentElement.outerHTML)',
|
|
);
|
|
final value = raw is String ? raw : raw.toString();
|
|
try {
|
|
final decoded = jsonDecode(value);
|
|
return decoded is String ? decoded : value;
|
|
} catch (_) {
|
|
return value;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
_pageGeneration++;
|
|
_reader.invalidate();
|
|
hasOpened = false;
|
|
await WebViewCookieManager().clearCookies();
|
|
await web.clearCache();
|
|
await web.clearLocalStorage();
|
|
await web.loadHtmlString('<html></html>');
|
|
_loading = false;
|
|
hasOpened = false;
|
|
pendingDraft = null;
|
|
formReady = false;
|
|
connectionState = SchoolConnectionState.signedOut;
|
|
message = '已退出学校登录。';
|
|
notifyListeners();
|
|
}
|
|
}
|