fix: recover authenticated undergraduate full pages with session guards
This commit is contained in:
+2
-2
@@ -1,8 +1,8 @@
|
||||
# TPSchedule Flutter工程
|
||||
|
||||
当前版本0.7.2+20,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
|
||||
当前版本0.7.3+21,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
|
||||
|
||||
学校浏览器沿用宽视口及第三方Cookie配置、Chromium版本显示和跳转重试;0.7.2细化脚本错误和页面依赖状态,258项单元测试通过。实际教务页面组件缺失仍未确认根因,见 `../docs/tpschedule-0.7.2-validation.md`。
|
||||
学校浏览器沿用宽视口及第三方Cookie配置。0.7.3新增本科已登录缺依赖页面的同会话完整页恢复,保留原学校地址与来源;失败或切页不覆盖旧页面和课表,真实账号仍需复测。见 `../docs/tpschedule-0.7.3-validation.md`。
|
||||
|
||||
## 本科使用
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ android {
|
||||
// flag during build.
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
testInstrumentationRunner = "com.tpschedule.app.SchoolDocumentRenderInstrumentation"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -40,6 +41,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
testImplementation("junit:junit:4.12")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Instrumentation;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.*;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import org.json.*;
|
||||
|
||||
/** Device-only check using public, local fixtures; no school requests or credentials. */
|
||||
public final class SchoolDocumentRenderInstrumentation extends Instrumentation {
|
||||
private static final String URL = "https://eams.uestc.edu.cn/eams/home!main.action";
|
||||
private WebView view;
|
||||
private volatile CountDownLatch loaded;
|
||||
private volatile String failureCode = "initial_fixture";
|
||||
private final AtomicInteger fixtures = new AtomicInteger();
|
||||
@Override public void onCreate(Bundle arguments) { super.onCreate(arguments); start(); }
|
||||
private void require(boolean value, String code) { if (!value) { failureCode = code; throw new AssertionError(code); } }
|
||||
private void waitFor(CountDownLatch latch) throws Exception { require(latch.await(12, TimeUnit.SECONDS), "fixture_timeout"); }
|
||||
private JSONObject evaluate(String expression) throws Exception {
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicReference<String> result = new AtomicReference<>();
|
||||
runOnMainSync(() -> view.evaluateJavascript(expression, value -> { result.set(value); done.countDown(); }));
|
||||
waitFor(done);
|
||||
return new JSONObject(new JSONArray("[" + result.get() + "]").getString(0));
|
||||
}
|
||||
private boolean checkDocument(String nonce) throws Exception {
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicBoolean result = new AtomicBoolean();
|
||||
runOnMainSync(() -> SchoolDocumentRenderer.checkDocument(view, URL, nonce, valid -> { result.set(valid); done.countDown(); }));
|
||||
waitFor(done);
|
||||
return result.get();
|
||||
}
|
||||
@Override public void onStart() {
|
||||
Bundle result = new Bundle();
|
||||
int finishCode = Activity.RESULT_CANCELED;
|
||||
try {
|
||||
loaded = new CountDownLatch(1);
|
||||
runOnMainSync(() -> {
|
||||
view = new WebView(getTargetContext());
|
||||
view.getSettings().setJavaScriptEnabled(true);
|
||||
view.setWebViewClient(new WebViewClient() {
|
||||
@Override public void onPageFinished(WebView webView, String url) { loaded.countDown(); }
|
||||
@Override public WebResourceResponse shouldInterceptRequest(WebView webView, WebResourceRequest request) {
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null) path = "";
|
||||
String body = "";
|
||||
String mime = "text/plain";
|
||||
if (path.endsWith("fixture.js")) { body = "window.fixtureReady='external-script';"; mime = "application/javascript"; fixtures.incrementAndGet(); }
|
||||
if (path.endsWith("fixture.css")) { body = "#probe {color:rgb(12, 34, 56)}"; mime = "text/css"; fixtures.incrementAndGet(); }
|
||||
return new WebResourceResponse(mime,"UTF-8",new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
});
|
||||
CookieManager.getInstance().setCookie(URL, "SchoolDocumentFixture=public; Path=/eams; Secure");
|
||||
view.loadDataWithBaseURL(URL,"<html><script>Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'fixture-old',enumerable:false})</script><body>old-public-fixture</body></html>","text/html","UTF-8",URL);
|
||||
});
|
||||
waitFor(loaded);
|
||||
require(checkDocument("fixture-old"), "current_document_rejected");
|
||||
loaded = new CountDownLatch(1);
|
||||
runOnMainSync(() -> view.loadDataWithBaseURL(URL,"<html><script>Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'fixture-new',enumerable:false})</script><body>same-url-new-document</body></html>","text/html","UTF-8",URL));
|
||||
waitFor(loaded);
|
||||
require(!checkDocument("fixture-old"), "same_url_new_document_accepted");
|
||||
require(checkDocument("fixture-new"), "new_document_rejected");
|
||||
String html = "<!doctype html><html><head><meta charset='UTF-8'><link rel='stylesheet' href='fixture.css'><script src='fixture.js'></script></head><body><p id='probe'>电子科技大学 • 课表 ✓</p></body></html>";
|
||||
loaded = new CountDownLatch(1);
|
||||
AtomicBoolean applied = new AtomicBoolean();
|
||||
runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, URL, CookieManager.getInstance().getCookie(URL), new SchoolDocumentReader.Document(html, java.util.Collections.emptyList()))));
|
||||
require(applied.get(), "render_not_applied"); waitFor(loaded);
|
||||
JSONObject actual = evaluate("JSON.stringify({href:location.href,origin:location.origin,text:document.getElementById('probe').textContent,script:window.fixtureReady,color:getComputedStyle(document.getElementById('probe')).color})");
|
||||
require(URL.equals(actual.getString("href")), "visible_url_changed");
|
||||
require("https://eams.uestc.edu.cn".equals(actual.getString("origin")), "origin_changed");
|
||||
require("电子科技大学 • 课表 ✓".equals(actual.getString("text")), "unicode_changed");
|
||||
require("external-script".equals(actual.getString("script")), "external_script_missing");
|
||||
require("rgb(12, 34, 56)".equals(actual.getString("color")), "external_css_missing");
|
||||
require(fixtures.get() == 2, "relative_asset_resolution_failed");
|
||||
runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, "https://eams.uestc.edu.cn/eams/index.action", CookieManager.getInstance().getCookie(URL), new SchoolDocumentReader.Document("bad",java.util.Collections.emptyList()))));
|
||||
require(!applied.get(), "stale_url_accepted");
|
||||
runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, URL, "SchoolDocumentFixture=wrong", new SchoolDocumentReader.Document("bad",java.util.Collections.emptyList()))));
|
||||
require(!applied.get(), "changed_session_accepted");
|
||||
JSONObject retained = evaluate("JSON.stringify({text:document.getElementById('probe').textContent})");
|
||||
require("电子科技大学 • 课表 ✓".equals(retained.getString("text")), "failed_render_changed_dom");
|
||||
result.putString("stream", "SchoolDocument render: URL/origin/Unicode/scripts/CSS/session/document guards passed\n");
|
||||
sendStatus(0,result);
|
||||
finishCode = Activity.RESULT_OK;
|
||||
} catch (Throwable ignored) {
|
||||
// Fixed output only: never print URI, cookie, page source or exception messages.
|
||||
result.putString("stream", "SchoolDocument render fixture failed: " + failureCode + "\n");
|
||||
sendStatus(-2,result);
|
||||
} finally {
|
||||
runOnMainSync(() -> { if (view != null) view.destroy(); });
|
||||
}
|
||||
finish(finishCode,result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/** UI-thread confined registry. Contents never cross the Flutter channel. */
|
||||
public final class SchoolDocumentPending<T> {
|
||||
private static final Semaphore SLOTS = new Semaphore(4);
|
||||
private static final long TTL = 20000;
|
||||
private static final class Entry<T> {
|
||||
final long viewId, created;
|
||||
final T value;
|
||||
Entry(long viewId, long created, T value) { this.viewId = viewId; this.created = created; this.value = value; }
|
||||
}
|
||||
private final LongSupplier clock;
|
||||
private final Map<String,Entry<T>> entries = new HashMap<>();
|
||||
private boolean closed;
|
||||
public SchoolDocumentPending(LongSupplier clock) { this.clock = clock; }
|
||||
public String put(long viewId, T value) {
|
||||
expire();
|
||||
if (closed) return null;
|
||||
Iterator<Entry<T>> old = entries.values().iterator();
|
||||
while (old.hasNext()) if (old.next().viewId == viewId) { old.remove(); SLOTS.release(); }
|
||||
if (!SLOTS.tryAcquire()) return null;
|
||||
String token = UUID.randomUUID().toString();
|
||||
entries.put(token, new Entry<>(viewId, clock.getAsLong(), value));
|
||||
return token;
|
||||
}
|
||||
public T get(String token) {
|
||||
expire();
|
||||
Entry<T> entry = entries.get(token);
|
||||
return entry == null ? null : entry.value;
|
||||
}
|
||||
public T remove(String token) {
|
||||
Entry<T> removed = entries.remove(token);
|
||||
if (removed == null) return null;
|
||||
SLOTS.release();
|
||||
return removed.value;
|
||||
}
|
||||
public void removeView(long viewId) {
|
||||
Iterator<Entry<T>> current = entries.values().iterator();
|
||||
while (current.hasNext()) if (current.next().viewId == viewId) { current.remove(); SLOTS.release(); }
|
||||
}
|
||||
private void expire() {
|
||||
long now = clock.getAsLong();
|
||||
Iterator<Entry<T>> current = entries.values().iterator();
|
||||
while (current.hasNext()) if (now - current.next().created >= TTL) { current.remove(); SLOTS.release(); }
|
||||
}
|
||||
public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
SLOTS.release(entries.size());
|
||||
entries.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/** Bounded transport for an already visible read-only school document. */
|
||||
public final class SchoolDocumentReader {
|
||||
public interface ConnectionFactory { HttpURLConnection open(URL url) throws IOException; }
|
||||
public static final class Failure extends Exception {
|
||||
public final String code;
|
||||
public Failure(String code) { super(code); this.code = code; }
|
||||
}
|
||||
public static final class Document {
|
||||
public final String html;
|
||||
public final List<String> setCookies;
|
||||
public Document(String html, List<String> setCookies) { this.html = html; this.setCookies = setCookies; }
|
||||
}
|
||||
private final ConnectionFactory connections;
|
||||
public SchoolDocumentReader(ConnectionFactory connections) { this.connections = connections; }
|
||||
public static URI validateUrl(String value) throws Failure {
|
||||
try {
|
||||
if (value == null || value.length() > 8192) throw new Failure("unsafe_url");
|
||||
URI uri = new URI(value);
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null ||
|
||||
(uri.getPort() != -1 && uri.getPort() != 443)) throw new Failure("unsafe_url");
|
||||
String path = uri.getRawPath();
|
||||
boolean undergraduate = "eams.uestc.edu.cn".equalsIgnoreCase(uri.getHost()) && path != null &&
|
||||
path.matches("/eams/?|/eams/(home(?:Ext)?(?:!(?:main|index|welcome))?|index|courseTableForStd(?:!courseTable)?|stdExamTable!examTable)\\.action");
|
||||
boolean graduate = "yjsjy.uestc.edu.cn".equalsIgnoreCase(uri.getHost()) && path != null &&
|
||||
path.matches("/pyxx/pygl/(xskbcx/index/[1-9][0-9]*|kskcxx/index)");
|
||||
if (!undergraduate && !graduate) throw new Failure("unsafe_url");
|
||||
if (uri.getRawQuery() != null) {
|
||||
for (String parameter : uri.getRawQuery().split("[&;]")) {
|
||||
String key = URLDecoder.decode(parameter.split("=", 2)[0], "UTF-8").toLowerCase(Locale.ROOT);
|
||||
if (key.matches("x-requested-with|ticket|password|username|j_username|j_password|action|optype|operation|method|command|redirect|redirect_uri|service"))
|
||||
throw new Failure("unsafe_url");
|
||||
}
|
||||
}
|
||||
return uri;
|
||||
} catch (Failure failure) { throw failure; }
|
||||
catch (Exception ignored) { throw new Failure("unsafe_url"); }
|
||||
}
|
||||
|
||||
public Document fetch(String url, String cookie, String userAgent) throws Failure {
|
||||
URI uri = validateUrl(url);
|
||||
if (cookie == null || cookie.trim().isEmpty() || cookie.length() > 16384 || hasControls(cookie)) throw new Failure("no_session");
|
||||
HttpURLConnection connection = null;
|
||||
long deadline = System.nanoTime() + 15_000_000_000L;
|
||||
try {
|
||||
connection = connections.open(uri.toURL());
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(8000);
|
||||
connection.setReadTimeout(8000);
|
||||
connection.setUseCaches(false);
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
if (userAgent != null && userAgent.length() <= 4096 && !hasControls(userAgent)) connection.setRequestProperty("User-Agent", userAgent);
|
||||
connection.setRequestProperty("Accept", "text/html,application/xhtml+xml");
|
||||
connection.setRequestProperty("Accept-Encoding", "gzip");
|
||||
if (connection.getResponseCode() != 200) throw new Failure("invalid_response");
|
||||
String contentType = connection.getContentType();
|
||||
if (contentType == null) throw new Failure("invalid_response");
|
||||
String mime = contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
|
||||
if (!mime.equals("text/html") && !mime.equals("application/xhtml+xml")) throw new Failure("invalid_response");
|
||||
Charset charset = StandardCharsets.UTF_8;
|
||||
Matcher declared = Pattern.compile("(?:^|;)\\s*charset\\s*=\\s*[\"']?([^;\\s\"']+)", Pattern.CASE_INSENSITIVE).matcher(contentType);
|
||||
if (declared.find()) {
|
||||
try { charset = Charset.forName(declared.group(1)); }
|
||||
catch (Exception ignored) { throw new Failure("invalid_response"); }
|
||||
}
|
||||
String encoding = connection.getContentEncoding();
|
||||
if (encoding != null && !encoding.equalsIgnoreCase("gzip") && !encoding.equalsIgnoreCase("identity")) throw new Failure("invalid_response");
|
||||
byte[] bytes;
|
||||
try (InputStream raw = new BoundedInput(connection.getInputStream(), deadline);
|
||||
InputStream decoded = "gzip".equalsIgnoreCase(encoding) ? new GZIPInputStream(raw) : raw;
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = decoded.read(buffer)) != -1) {
|
||||
if (System.nanoTime() > deadline) throw new Failure("network_failed");
|
||||
if (body.size() + count > 2 * 1024 * 1024) throw new Failure("too_large");
|
||||
body.write(buffer, 0, count);
|
||||
}
|
||||
bytes = body.toByteArray();
|
||||
}
|
||||
return new Document(new String(bytes, charset), safeCookies(uri, connection.getHeaderFields()));
|
||||
} catch (Failure failure) { throw failure; }
|
||||
catch (BoundFailure failure) { throw new Failure(failure.code); }
|
||||
catch (Exception ignored) { throw new Failure("network_failed"); }
|
||||
finally { if (connection != null) connection.disconnect(); }
|
||||
}
|
||||
|
||||
private static final class BoundFailure extends IOException {
|
||||
final String code;
|
||||
BoundFailure(String code) { super(code); this.code = code; }
|
||||
}
|
||||
/** Also bounds gzip metadata, which the decoder may read before emitting bytes. */
|
||||
private static final class BoundedInput extends FilterInputStream {
|
||||
final long deadline;
|
||||
int total;
|
||||
BoundedInput(InputStream source, long deadline) { super(source); this.deadline = deadline; }
|
||||
private void check(int count) throws BoundFailure {
|
||||
if (System.nanoTime() > deadline) throw new BoundFailure("network_failed");
|
||||
if (count > 0) total += count;
|
||||
if (total > 2 * 1024 * 1024) throw new BoundFailure("too_large");
|
||||
}
|
||||
@Override public int read() throws IOException {
|
||||
check(0); int result = in.read(); check(result < 0 ? 0 : 1); return result;
|
||||
}
|
||||
@Override public int read(byte[] bytes, int offset, int length) throws IOException {
|
||||
check(0); int count = in.read(bytes, offset, length); check(count); return count;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasControls(String value) {
|
||||
for (int i = 0; i < value.length(); i++) if (value.charAt(i) < 32 || value.charAt(i) == 127) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<String> safeCookies(URI uri, Map<String,List<String>> headers) {
|
||||
List<String> accepted = new ArrayList<>();
|
||||
int total = 0;
|
||||
if (headers == null) return accepted;
|
||||
String root = uri.getHost().equalsIgnoreCase("eams.uestc.edu.cn") ? "/eams" : "/pyxx";
|
||||
for (Map.Entry<String,List<String>> entry : headers.entrySet()) {
|
||||
if (entry.getKey() == null || !entry.getKey().equalsIgnoreCase("Set-Cookie") || entry.getValue() == null) continue;
|
||||
for (String cookie : entry.getValue()) {
|
||||
if (cookie == null || cookie.length() > 4096 || hasControls(cookie)) continue;
|
||||
String[] parts = cookie.split(";", -1);
|
||||
if (!parts[0].trim().matches("[!#$%&'*+.^_`|~0-9A-Za-z-]+=[^;]*")) continue;
|
||||
boolean safe = true, domainSeen = false, pathSeen = false;
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
String[] attr = parts[i].trim().split("=", 2);
|
||||
if (attr[0].equalsIgnoreCase("Domain")) {
|
||||
if (domainSeen || attr.length != 2 || !attr[1].equalsIgnoreCase(uri.getHost())) safe = false;
|
||||
domainSeen = true;
|
||||
}
|
||||
if (attr[0].equalsIgnoreCase("Path")) {
|
||||
if (pathSeen || attr.length != 2 || !(attr[1].equals(root) || attr[1].startsWith(root+"/")) || attr[1].contains("%") || attr[1].contains("\\") || attr[1].contains("..")) safe = false;
|
||||
pathSeen = true;
|
||||
}
|
||||
}
|
||||
if (safe && accepted.size() < 16 && total + cookie.length() <= 16384) {
|
||||
accepted.add(cookie); total += cookie.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableList(accepted);
|
||||
}
|
||||
|
||||
static List<String> cookiesForRender(String url, List<String> cookies) throws Failure {
|
||||
return safeCookies(validateUrl(url), Collections.singletonMap("Set-Cookie", cookies));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.ValueCallback;
|
||||
import android.os.Looper;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** Same-turn final guards and WebView load, shared with device instrumentation. */
|
||||
public final class SchoolDocumentRenderer {
|
||||
private SchoolDocumentRenderer() { }
|
||||
/** Checks the live document; callers must recheck native state in this callback. */
|
||||
public static void checkDocument(WebView view, String url, String documentToken, ValueCallback<Boolean> callback) {
|
||||
if (Looper.myLooper() != Looper.getMainLooper() || view == null || documentToken == null ||
|
||||
!documentToken.matches("[A-Za-z0-9-]{1,128}")) { callback.onReceiveValue(false); return; }
|
||||
try {
|
||||
SchoolDocumentReader.validateUrl(url);
|
||||
view.evaluateJavascript("location.href === " + JSONObject.quote(url) +
|
||||
" && window.__tpscheduleDocumentToken === " + JSONObject.quote(documentToken),
|
||||
value -> callback.onReceiveValue("true".equals(value)));
|
||||
} catch (Exception ignored) { callback.onReceiveValue(false); }
|
||||
}
|
||||
public static boolean render(WebView view, String url, String originalCookie, SchoolDocumentReader.Document document) {
|
||||
if (Looper.myLooper() != Looper.getMainLooper()) return false;
|
||||
try {
|
||||
SchoolDocumentReader.validateUrl(url);
|
||||
if (view == null || document == null || document.html == null || document.html.length() > 2 * 1024 * 1024 ||
|
||||
originalCookie == null || originalCookie.trim().isEmpty() || !url.equals(view.getUrl())) return false;
|
||||
CookieManager cookies = CookieManager.getInstance();
|
||||
if (!originalCookie.equals(cookies.getCookie(url))) return false;
|
||||
// The guards, scoped cookie application and load occur in one UI turn.
|
||||
for (String cookie : SchoolDocumentReader.cookiesForRender(url, document.setCookies)) cookies.setCookie(url, cookie);
|
||||
view.loadDataWithBaseURL(url, document.html, "text/html", "UTF-8", url);
|
||||
return true;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var calendar: CalendarBridge? = null
|
||||
private var reminders: ReminderSystemBridge? = null
|
||||
private var schoolDocuments: SchoolDocumentBridge? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -20,6 +21,13 @@ class MainActivity : FlutterActivity() {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
schoolDocuments?.close()
|
||||
schoolDocuments = SchoolDocumentBridge(flutterEngine)
|
||||
}
|
||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
schoolDocuments?.close()
|
||||
schoolDocuments = null
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
@@ -28,6 +36,7 @@ class MainActivity : FlutterActivity() {
|
||||
override fun onDestroy() {
|
||||
calendar?.close()
|
||||
reminders?.close()
|
||||
schoolDocuments?.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.tpschedule.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.ArrayBlockingQueue
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
/** Fetches a bounded full document with the visible WebView's existing session. */
|
||||
class SchoolDocumentBridge(private val engine: FlutterEngine) {
|
||||
private class Candidate(
|
||||
val viewId: Long,
|
||||
val url: String,
|
||||
val cookie: String,
|
||||
val documentToken: String,
|
||||
val view: WeakReference<WebView>,
|
||||
var document: SchoolDocumentReader.Document? = null,
|
||||
)
|
||||
|
||||
private val ui = Handler(Looper.getMainLooper())
|
||||
private val worker = ThreadPoolExecutor(
|
||||
2, 2, 0L, TimeUnit.MILLISECONDS, ArrayBlockingQueue<Runnable>(2),
|
||||
)
|
||||
private val pending = SchoolDocumentPending<Candidate> { SystemClock.elapsedRealtime() }
|
||||
private val reader = SchoolDocumentReader { url -> url.openConnection() as HttpsURLConnection }
|
||||
private val channel = MethodChannel(engine.dartExecutor.binaryMessenger, "com.tpschedule.app/school_document")
|
||||
private var closed = false
|
||||
|
||||
init {
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
try {
|
||||
if (closed || Looper.myLooper() != Looper.getMainLooper()) {
|
||||
fail(result, "unavailable")
|
||||
} else {
|
||||
when (call.method) {
|
||||
"fetch" -> fetch(call, result)
|
||||
"render" -> render(call, result)
|
||||
"invalidate" -> {
|
||||
val viewId = call.argument<Number>("viewId")?.toLong()
|
||||
if (viewId != null) pending.removeView(viewId)
|
||||
result.success(null)
|
||||
}
|
||||
"discard" -> {
|
||||
call.argument<String>("token")?.let { pending.remove(it) }
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
} catch (failure: SchoolDocumentReader.Failure) {
|
||||
fail(result, failure.code)
|
||||
} catch (_: Exception) {
|
||||
fail(result, "unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun webView(viewId: Long): WebView? =
|
||||
WebViewFlutterAndroidExternalApi.getWebView(engine, viewId)
|
||||
|
||||
private fun fetch(call: MethodCall, result: MethodChannel.Result) {
|
||||
val viewId = call.argument<Number>("viewId")?.toLong()
|
||||
?: return fail(result, "unavailable")
|
||||
val url = call.argument<String>("url") ?: return fail(result, "unsafe_url")
|
||||
val documentToken = call.argument<String>("documentToken")
|
||||
?.takeIf { it.matches(Regex("[A-Za-z0-9-]{1,128}")) }
|
||||
?: return fail(result, "stale_document")
|
||||
SchoolDocumentReader.validateUrl(url)
|
||||
val view = webView(viewId) ?: return fail(result, "unavailable")
|
||||
if (view.url != url) return fail(result, "stale_document")
|
||||
val cookie = CookieManager.getInstance().getCookie(url)
|
||||
if (cookie.isNullOrBlank()) return fail(result, "no_session")
|
||||
val userAgent = view.settings.userAgentString
|
||||
val candidate = Candidate(viewId, url, cookie, documentToken, WeakReference(view))
|
||||
val token = pending.put(viewId, candidate) ?: return fail(result, "unavailable")
|
||||
// Expiration clears native bodies/cookies even when Dart never calls discard.
|
||||
ui.postDelayed({ pending.remove(token) }, 20_000)
|
||||
try {
|
||||
worker.execute {
|
||||
var document: SchoolDocumentReader.Document? = null
|
||||
var error: String? = null
|
||||
try {
|
||||
document = reader.fetch(url, cookie, userAgent)
|
||||
} catch (failure: SchoolDocumentReader.Failure) {
|
||||
error = failure.code
|
||||
} catch (_: Exception) {
|
||||
error = "network_failed"
|
||||
}
|
||||
ui.post {
|
||||
try {
|
||||
if (closed) {
|
||||
pending.remove(token)
|
||||
fail(result, "unavailable")
|
||||
} else if (pending.get(token) !== candidate || !matches(candidate)) {
|
||||
pending.remove(token)
|
||||
fail(result, "stale_document")
|
||||
} else if (error != null || document == null) {
|
||||
pending.remove(token)
|
||||
fail(result, error ?: "invalid_response")
|
||||
} else {
|
||||
candidate.document = document
|
||||
result.success(mapOf("token" to token, "html" to document!!.html))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pending.remove(token)
|
||||
fail(result, "unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: RejectedExecutionException) {
|
||||
pending.remove(token)
|
||||
fail(result, "unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
private fun matches(candidate: Candidate): Boolean {
|
||||
val actual = webView(candidate.viewId) ?: return false
|
||||
return actual === candidate.view.get() && actual.url == candidate.url &&
|
||||
CookieManager.getInstance().getCookie(candidate.url) == candidate.cookie
|
||||
}
|
||||
|
||||
private fun render(call: MethodCall, result: MethodChannel.Result) {
|
||||
val token = call.argument<String>("token") ?: return result.success(false)
|
||||
val candidate = pending.get(token) ?: return result.success(false)
|
||||
val document = candidate.document ?: return result.success(false)
|
||||
if (!matches(candidate)) return result.success(false)
|
||||
val view = candidate.view.get() ?: return result.success(false)
|
||||
SchoolDocumentRenderer.checkDocument(view, candidate.url, candidate.documentToken) { sameDocument ->
|
||||
try {
|
||||
// Invalidation, replacement or expiration during JS verification cancels this render.
|
||||
if (closed || !sameDocument || pending.get(token) !== candidate || !matches(candidate)) {
|
||||
pending.remove(token)
|
||||
result.success(false)
|
||||
} else {
|
||||
pending.remove(token)
|
||||
result.success(SchoolDocumentRenderer.render(view, candidate.url, candidate.cookie, document))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pending.remove(token)
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(result: MethodChannel.Result, code: String) = result.error(code, code, null)
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
channel.setMethodCallHandler(null)
|
||||
pending.close()
|
||||
worker.shutdownNow()
|
||||
ui.removeCallbacksAndMessages(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class SchoolDocumentPendingTest {
|
||||
@Test public void invalidateViewRemovesCandidateAwaitingDocumentVerification() {
|
||||
SchoolDocumentPending<String> store = new SchoolDocumentPending<>(() -> 0);
|
||||
try {
|
||||
String waiting = store.put(42, "awaiting-verification"), other = store.put(43, "other-view");
|
||||
store.removeView(42);
|
||||
assertNull(store.get(waiting));
|
||||
assertEquals("other-view", store.get(other));
|
||||
assertNotNull(store.put(42, "new-document"));
|
||||
assertNull(store.get(waiting));
|
||||
} finally { store.close(); }
|
||||
}
|
||||
@Test public void capsPendingAcrossViewsAndReleasesOnDiscard() {
|
||||
long[] now = {0};
|
||||
SchoolDocumentPending<String> store = new SchoolDocumentPending<>(() -> now[0]);
|
||||
for (int i=0;i<4;i++) assertNotNull(store.put(i, "doc"));
|
||||
assertNull(store.put(5, "over-cap"));
|
||||
String removed = store.put(0, "replacement");
|
||||
assertNotNull(removed);
|
||||
store.remove(removed);
|
||||
assertNotNull(store.put(5, "after-discard"));
|
||||
store.close();
|
||||
}
|
||||
@Test public void expiresAtTwentySecondsAndDoesNotReviveAfterClose() {
|
||||
long[] now = {100};
|
||||
SchoolDocumentPending<String> store = new SchoolDocumentPending<>(() -> now[0]);
|
||||
String token = store.put(42, "private");
|
||||
now[0] = 20099; assertEquals("private", store.get(token));
|
||||
now[0] = 20100; assertNull(store.get(token));
|
||||
String next = store.put(43, "private"); store.close();
|
||||
assertNull(store.get(next)); assertNull(store.put(44, "late"));
|
||||
}
|
||||
@Test public void newFetchReplacesOnlyTheSameViewAndInvalidatesOldToken() {
|
||||
SchoolDocumentPending<String> store = new SchoolDocumentPending<>(() -> 0);
|
||||
String first = store.put(1,"old"), other = store.put(2,"other"), latest = store.put(1,"latest");
|
||||
assertNull(store.get(first)); assertEquals("other",store.get(other)); assertEquals("latest",store.get(latest));
|
||||
assertEquals("latest",store.remove(latest)); assertNull(store.remove(latest));
|
||||
store.close();
|
||||
}
|
||||
@Test public void fourSlotLimitIsSharedAcrossRegistries() {
|
||||
SchoolDocumentPending<String> a = new SchoolDocumentPending<>(() -> 0), b = new SchoolDocumentPending<>(() -> 0);
|
||||
try {
|
||||
for(int i=0;i<4;i++) assertNotNull(a.put(i,"private"));
|
||||
assertNull(b.put(7,"over-cap"));
|
||||
a.close(); assertNotNull(b.put(7,"released"));
|
||||
} finally { a.close(); b.close(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
public class SchoolDocumentReaderTest {
|
||||
private static final String URL = "https://eams.uestc.edu.cn/eams/home!main.action";
|
||||
// Mimics beangle3 head.ftl: presence of XRW suppresses the complete head.
|
||||
static class Backend extends HttpURLConnection {
|
||||
int status = 200;
|
||||
String mime = "text/html; charset=UTF-8";
|
||||
byte[] body;
|
||||
String encoding;
|
||||
List<String> cookies = new ArrayList<>();
|
||||
boolean disconnected;
|
||||
Backend() throws Exception { super(new URL(URL)); }
|
||||
public void connect() { }
|
||||
public boolean usingProxy() { return false; }
|
||||
public void disconnect() { disconnected = true; }
|
||||
public int getResponseCode() { return status; }
|
||||
public String getContentType() { return mime; }
|
||||
public String getContentEncoding() { return encoding; }
|
||||
public Map<String,List<String>> getHeaderFields() { return Collections.singletonMap("Set-Cookie", cookies); }
|
||||
public InputStream getInputStream() {
|
||||
if (body != null) return new ByteArrayInputStream(body);
|
||||
boolean fragment = getRequestProperty("X-Requested-With") != null;
|
||||
return new ByteArrayInputStream((fragment ? "<div>课表</div>" : "<html><head><script src='jquery.js'></script><link rel='stylesheet' href='site.css'></head><body>课表</body></html>").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
private SchoolDocumentReader reader(Backend backend) { return new SchoolDocumentReader(url -> backend); }
|
||||
private void reject(String code, Throwing action) throws Exception {
|
||||
try { action.run(); fail("expected " + code); }
|
||||
catch (SchoolDocumentReader.Failure failure) { assertEquals(code, failure.code); assertEquals(code, failure.getMessage()); }
|
||||
}
|
||||
interface Throwing { void run() throws Exception; }
|
||||
|
||||
@Test public void missingXrwRestoresFullHeadWithSameSessionGet() throws Exception {
|
||||
Backend backend = new Backend();
|
||||
SchoolDocumentReader.Document document = reader(backend).fetch(URL, "SESSION=private", "fixture-agent");
|
||||
assertTrue(document.html.contains("jquery.js"));
|
||||
assertTrue(document.html.contains("site.css"));
|
||||
assertTrue(document.html.contains("课表"));
|
||||
assertEquals("GET", backend.getRequestMethod());
|
||||
assertEquals("SESSION=private", backend.getRequestProperty("Cookie"));
|
||||
assertEquals("fixture-agent", backend.getRequestProperty("User-Agent"));
|
||||
assertNull(backend.getRequestProperty("X-Requested-With"));
|
||||
assertFalse(backend.getInstanceFollowRedirects());
|
||||
assertTrue(backend.disconnected);
|
||||
}
|
||||
@Test public void missingSessionMakesNoRequest() throws Exception {
|
||||
reject("no_session", () -> new SchoolDocumentReader(url -> { fail("request before session validation"); return null; }).fetch(URL, "", "ua"));
|
||||
}
|
||||
@Test public void rejectsUnsafeTargetsAndDecodedCredentialQuery() throws Exception {
|
||||
String[] urls = {"http://eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn:444/eams/", "https://eams.uestc.edu.cn.evil/eams/", "https://user@eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn/eams/logout.action", "https://eams.uestc.edu.cn/eams/home!save.action", URL+"?%74icket=secret", URL+"?X-Requested-With=a", URL+"?action=delete", URL+"?opType=save", "https://eams.uestc.edu.cn/eams/%2e%2e/eams/"};
|
||||
for (String url : urls) reject("unsafe_url", () -> reader(new Backend()).fetch(url, "S=x", "ua"));
|
||||
}
|
||||
@Test public void allowsOnlySettledReadonlyRoutes() throws Exception {
|
||||
String[] urls = {"https://eams.uestc.edu.cn/eams", "https://eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn/eams/homeExt!index.action", "https://eams.uestc.edu.cn/eams/courseTableForStd!courseTable.action?semester.id=42", "https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action", "https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/42", "https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index"};
|
||||
for (String url : urls) assertNotNull(reader(new Backend()).fetch(url, "S=x", "ua"));
|
||||
}
|
||||
@Test public void neverFollowsRedirectsIncludingSameHost() throws Exception {
|
||||
Backend backend = new Backend(); backend.status = 302;
|
||||
reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua"));
|
||||
assertFalse(backend.getInstanceFollowRedirects()); assertTrue(backend.disconnected);
|
||||
}
|
||||
@Test public void rejectsNonHtmlOrNonSuccess() throws Exception {
|
||||
for (String mime : new String[]{"application/json", "text/plain", null}) {
|
||||
Backend backend = new Backend(); backend.mime = mime;
|
||||
reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua"));
|
||||
}
|
||||
for (int status : new int[]{201,204,401,403,500}) {
|
||||
Backend backend = new Backend(); backend.status = status;
|
||||
reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua"));
|
||||
}
|
||||
}
|
||||
@Test public void enforcesDecodedLimitEvenForGzip() throws Exception {
|
||||
byte[] oversized = new byte[2*1024*1024+1]; Arrays.fill(oversized, (byte)'a');
|
||||
Backend plain = new Backend(); plain.body = oversized;
|
||||
reject("too_large", () -> reader(plain).fetch(URL, "S=x", "ua"));
|
||||
Backend zipped = new Backend(); zipped.encoding = "gzip";
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try (GZIPOutputStream gzip = new GZIPOutputStream(buffer)) { gzip.write(oversized); }
|
||||
zipped.body = buffer.toByteArray();
|
||||
reject("too_large", () -> reader(zipped).fetch(URL, "S=x", "ua"));
|
||||
}
|
||||
@Test public void acceptsHtmlCharsetAndGzip() throws Exception {
|
||||
Backend backend = new Backend(); backend.mime = "application/xhtml+xml; charset=GB18030";
|
||||
backend.body = "<html>中文</html>".getBytes("GB18030");
|
||||
assertEquals("<html>中文</html>", reader(backend).fetch(URL,"S=x","ua").html);
|
||||
}
|
||||
@Test public void boundsCompressedHeadersBeforeDecoderProducesOutput() throws Exception {
|
||||
Backend backend = new Backend(); backend.encoding = "gzip";
|
||||
backend.body = new byte[2*1024*1024+1]; Arrays.fill(backend.body,(byte)'a');
|
||||
byte[] gzipHeader = {(byte)0x1f,(byte)0x8b,8,8,0,0,0,0,0,3};
|
||||
System.arraycopy(gzipHeader,0,backend.body,0,gzipHeader.length);
|
||||
reject("too_large", () -> reader(backend).fetch(URL,"S=x","ua"));
|
||||
}
|
||||
@Test public void onlyKeepsBoundedSameSchoolCookieScope() throws Exception {
|
||||
Backend backend = new Backend();
|
||||
backend.cookies = Arrays.asList("SID=next; Path=/eams; Secure; HttpOnly", "A=b; Domain=eams.uestc.edu.cn; Path=/eams/", "BAD=x; Domain=.uestc.edu.cn; Path=/eams", "BAD=x; Domain=eams.uestc.edu.cn.evil; Path=/eams", "BAD=x; Path=/", "BAD=x; Path=/eamsOther", "BAD=x\r\nInjected: yes", "BAD=x; Domain=eams.uestc.edu.cn; Domain=evil", "z="+String.join("", Collections.nCopies(4097,"a")));
|
||||
assertEquals(Arrays.asList("SID=next; Path=/eams; Secure; HttpOnly", "A=b; Domain=eams.uestc.edu.cn; Path=/eams/"),reader(backend).fetch(URL,"S=x","ua").setCookies);
|
||||
}
|
||||
@Test public void sanitizesTransportFailure() throws Exception {
|
||||
reject("network_failed", () -> new SchoolDocumentReader(url -> { throw new IOException("secret-cookie-and-url"); }).fetch(URL,"S=x","ua"));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
const appVersion = '0.7.2';
|
||||
const appBuildNumber = 20;
|
||||
const appVersion = '0.7.3';
|
||||
const appBuildNumber = 21;
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
import '../../services/diagnostic_log.dart';
|
||||
import 'school_browser_diagnostics.dart';
|
||||
|
||||
class SchoolPageCandidate {
|
||||
const SchoolPageCandidate(this.token, this.html);
|
||||
final String token, html;
|
||||
}
|
||||
|
||||
abstract interface class SchoolFullPageGateway {
|
||||
bool supports(WebViewController web);
|
||||
Future<SchoolPageCandidate> fetch(
|
||||
WebViewController web,
|
||||
Uri url,
|
||||
String documentToken,
|
||||
);
|
||||
Future<bool> render(String token);
|
||||
Future<void> discard(String token);
|
||||
Future<void> invalidate(WebViewController web);
|
||||
}
|
||||
|
||||
enum FullPageRecoveryResult { unchanged, restored, failed, cancelled }
|
||||
|
||||
class AndroidSchoolFullPageGateway implements SchoolFullPageGateway {
|
||||
const AndroidSchoolFullPageGateway();
|
||||
static const _channel = MethodChannel('com.tpschedule.app/school_document');
|
||||
@override
|
||||
bool supports(WebViewController web) =>
|
||||
web.platform is AndroidWebViewController;
|
||||
@override
|
||||
Future<SchoolPageCandidate> fetch(
|
||||
WebViewController web,
|
||||
Uri url,
|
||||
String documentToken,
|
||||
) async {
|
||||
final response = await _channel.invokeMapMethod<String, dynamic>('fetch', {
|
||||
'viewId': (web.platform as AndroidWebViewController).webViewIdentifier,
|
||||
'url': url.toString(),
|
||||
'documentToken': documentToken,
|
||||
});
|
||||
final token = response?['token'], page = response?['html'];
|
||||
if (token is! String ||
|
||||
!RegExp(r'^[A-Za-z0-9-]{1,128}$').hasMatch(token) ||
|
||||
page is! String) {
|
||||
throw const FormatException('Invalid page response');
|
||||
}
|
||||
return SchoolPageCandidate(token, page);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> render(String token) async =>
|
||||
await _channel.invokeMethod<bool>('render', {'token': token}) == true;
|
||||
@override
|
||||
Future<void> discard(String token) =>
|
||||
_channel.invokeMethod<void>('discard', {'token': token});
|
||||
@override
|
||||
Future<void> invalidate(WebViewController web) => _channel.invokeMethod<void>(
|
||||
'invalidate',
|
||||
{'viewId': (web.platform as AndroidWebViewController).webViewIdentifier},
|
||||
);
|
||||
}
|
||||
|
||||
class SchoolFullPageRecovery {
|
||||
SchoolFullPageRecovery({SchoolFullPageGateway? gateway})
|
||||
: gateway = gateway ?? const AndroidSchoolFullPageGateway();
|
||||
final SchoolFullPageGateway gateway;
|
||||
final _attempted = <String>{};
|
||||
int _epoch = 0;
|
||||
WebViewController? _lastWeb;
|
||||
void reset() {
|
||||
invalidateDocument();
|
||||
_attempted.clear();
|
||||
}
|
||||
|
||||
void invalidateDocument() {
|
||||
_epoch++;
|
||||
final web = _lastWeb;
|
||||
if (web != null && gateway.supports(web)) {
|
||||
unawaited(gateway.invalidate(web).catchError((Object _) {}));
|
||||
}
|
||||
}
|
||||
|
||||
static bool canRestore(Uri uri) {
|
||||
if (uri.scheme != 'https' ||
|
||||
uri.host != 'eams.uestc.edu.cn' ||
|
||||
uri.port != 443 ||
|
||||
uri.userInfo.isNotEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (uri.queryParametersAll.keys.any(
|
||||
(key) => const {
|
||||
'x-requested-with',
|
||||
'ticket',
|
||||
'password',
|
||||
'username',
|
||||
'j_username',
|
||||
'j_password',
|
||||
'optype',
|
||||
'operation',
|
||||
'action',
|
||||
'method',
|
||||
}.contains(key.toLowerCase()),
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return uri.path == '/eams' ||
|
||||
uri.path == '/eams/' ||
|
||||
RegExp(
|
||||
r'^/eams/(home(?:Ext)?(?:!(?:main|index|welcome))?|index|courseTableForStd(?:!courseTable)?|stdExamTable!examTable)\.action$',
|
||||
).hasMatch(uri.path);
|
||||
}
|
||||
|
||||
Map? _pageState(Object raw) {
|
||||
dynamic value = raw;
|
||||
for (var i = 0; i < 2 && value is String; i++) {
|
||||
value = jsonDecode(value);
|
||||
}
|
||||
return value is Map &&
|
||||
value['scripts'] is int &&
|
||||
value['scripts'] >= 0 &&
|
||||
value['scripts'] <= 4 &&
|
||||
value['styles'] == 0 &&
|
||||
value['links'] == 0 &&
|
||||
value['jquery'] == false &&
|
||||
value['documentToken'] is String &&
|
||||
RegExp(r'^[A-Za-z0-9-]{1,128}$').hasMatch(value['documentToken'])
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
bool _completePage(String source, Uri uri) {
|
||||
if (source.length > 2 * 1024 * 1024 ||
|
||||
utf8.encode(source).length > 2 * 1024 * 1024 ||
|
||||
!RegExp(r'<head(?:\s|>)', caseSensitive: false).hasMatch(source)) {
|
||||
return false;
|
||||
}
|
||||
final document = html.parse(source);
|
||||
final head = document.head;
|
||||
if (document.querySelector('input[type="password"]') != null ||
|
||||
head == null ||
|
||||
!head
|
||||
.querySelectorAll('script[src]')
|
||||
.any((e) => e.attributes['src']!.trim().isNotEmpty) ||
|
||||
!head
|
||||
.querySelectorAll('link[rel~="stylesheet"][href]')
|
||||
.any((e) => e.attributes['href']!.trim().isNotEmpty)) {
|
||||
return false;
|
||||
}
|
||||
if (head
|
||||
.querySelectorAll('meta[http-equiv]')
|
||||
.any((e) => e.attributes['http-equiv']!.toLowerCase() == 'refresh')) {
|
||||
return false;
|
||||
}
|
||||
for (final base in head.querySelectorAll('base[href]')) {
|
||||
if (uri.resolve(base.attributes['href']!).origin != uri.origin) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return document.querySelector(
|
||||
'#manualArrangeCourseTable, #stdExamTableForm',
|
||||
) !=
|
||||
null ||
|
||||
document.querySelectorAll('a[href]').any((a) {
|
||||
final target = uri.resolve(a.attributes['href']!);
|
||||
return target.scheme == 'https' &&
|
||||
target.host == uri.host &&
|
||||
target.port == 443 &&
|
||||
target.userInfo.isEmpty &&
|
||||
target.path == '/eams/logout.action';
|
||||
});
|
||||
}
|
||||
|
||||
void _record(String phase, [String? code]) => DiagnosticLog.instance.add(
|
||||
'school_full_page_restore',
|
||||
fields: {'school': 'undergraduate', 'phase': phase, 'code': code},
|
||||
);
|
||||
|
||||
Future<FullPageRecoveryResult> tryRestore(
|
||||
WebViewController web,
|
||||
Uri url, {
|
||||
required bool authenticated,
|
||||
required String documentKey,
|
||||
required bool Function() isCurrent,
|
||||
void Function()? onStarted,
|
||||
}) async {
|
||||
if (!authenticated ||
|
||||
!canRestore(url) ||
|
||||
!gateway.supports(web) ||
|
||||
_attempted.contains(documentKey)) {
|
||||
return FullPageRecoveryResult.unchanged;
|
||||
}
|
||||
final epoch = _epoch;
|
||||
_lastWeb = web;
|
||||
bool valid() => isCurrent() && epoch == _epoch;
|
||||
SchoolPageCandidate? candidate;
|
||||
try {
|
||||
if (!valid() || await web.currentUrl() != url.toString() || !valid()) {
|
||||
return FullPageRecoveryResult.cancelled;
|
||||
}
|
||||
final nonce = List.generate(
|
||||
4,
|
||||
(_) => Random.secure()
|
||||
.nextInt(0x100000000)
|
||||
.toRadixString(16)
|
||||
.padLeft(8, '0'),
|
||||
).join();
|
||||
final diagnostic = schoolPageDiagnosticsScript.replaceAll(
|
||||
'__EXPECTED_URL__',
|
||||
jsonEncode(url.toString()),
|
||||
);
|
||||
final raw = await web.runJavaScriptReturningResult('''(() => {
|
||||
const state=JSON.parse($diagnostic);
|
||||
if(!state)return 'null';
|
||||
if(typeof window.__tpscheduleDocumentToken!=='string')Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'$nonce',configurable:true});
|
||||
state.documentToken=window.__tpscheduleDocumentToken;
|
||||
return JSON.stringify(state);
|
||||
})()''');
|
||||
if (!valid() || await web.currentUrl() != url.toString() || !valid()) {
|
||||
return FullPageRecoveryResult.cancelled;
|
||||
}
|
||||
final state = _pageState(raw);
|
||||
if (state == null) return FullPageRecoveryResult.unchanged;
|
||||
if (_attempted.length >= 4) return FullPageRecoveryResult.failed;
|
||||
_attempted.add(documentKey);
|
||||
onStarted?.call();
|
||||
if (!valid()) return FullPageRecoveryResult.cancelled;
|
||||
_record('requested', 'missing_head');
|
||||
candidate = await gateway
|
||||
.fetch(web, url, state['documentToken'] as String)
|
||||
.timeout(const Duration(seconds: 35));
|
||||
if (!valid() || await web.currentUrl() != url.toString() || !valid()) {
|
||||
_record('cancelled', 'document_changed');
|
||||
return FullPageRecoveryResult.cancelled;
|
||||
}
|
||||
if (!_completePage(candidate.html, url)) {
|
||||
_record('rejected', 'incomplete_head');
|
||||
return FullPageRecoveryResult.failed;
|
||||
}
|
||||
if (!valid()) return FullPageRecoveryResult.cancelled;
|
||||
final rendered = await gateway
|
||||
.render(candidate.token)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
_record(
|
||||
rendered ? 'applied' : 'cancelled',
|
||||
rendered ? null : 'document_changed',
|
||||
);
|
||||
return rendered
|
||||
? FullPageRecoveryResult.restored
|
||||
: FullPageRecoveryResult.cancelled;
|
||||
} catch (error) {
|
||||
if (!valid()) return FullPageRecoveryResult.cancelled;
|
||||
const codes = {
|
||||
'invalid_url',
|
||||
'no_session',
|
||||
'document_changed',
|
||||
'unexpected_status',
|
||||
'redirect_rejected',
|
||||
'invalid_content_type',
|
||||
'too_large',
|
||||
'network_failed',
|
||||
'tls_failed',
|
||||
'invalid_cookie',
|
||||
'cookie_scope',
|
||||
'unsupported_encoding',
|
||||
'unsafe_url',
|
||||
'stale_document',
|
||||
'invalid_response',
|
||||
'unavailable',
|
||||
};
|
||||
final code = error is PlatformException && codes.contains(error.code)
|
||||
? error.code
|
||||
: error is TimeoutException
|
||||
? 'timeout'
|
||||
: 'capture_failed';
|
||||
_record('failed', code);
|
||||
return FullPageRecoveryResult.failed;
|
||||
} finally {
|
||||
if (candidate != null) {
|
||||
try {
|
||||
await gateway.discard(candidate.token);
|
||||
} catch (_) {
|
||||
/* No pending private page is persisted. */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,14 @@ import 'timetable_adapter.dart';
|
||||
import 'exam_adapter.dart';
|
||||
import '../school_browser.dart';
|
||||
import '../school_browser_diagnostics.dart';
|
||||
import '../school_full_page_recovery.dart';
|
||||
|
||||
class UndergraduateSession extends ChangeNotifier {
|
||||
UndergraduateSession({this.configureBrowser = configureSchoolBrowser});
|
||||
UndergraduateSession({
|
||||
this.configureBrowser = configureSchoolBrowser,
|
||||
SchoolFullPageRecovery? pageRecovery,
|
||||
}) : _pageRecovery = pageRecovery ?? SchoolFullPageRecovery();
|
||||
final SchoolFullPageRecovery _pageRecovery;
|
||||
final SchoolBrowserConfigurator configureBrowser;
|
||||
Future<SchoolBrowserInfo>? _browserReady;
|
||||
String? browserVersion;
|
||||
@@ -28,6 +33,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_connected = false,
|
||||
_loading = false,
|
||||
_manualCapture = false,
|
||||
_recoveringPage = false,
|
||||
_failed = false,
|
||||
_complete = false;
|
||||
bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false;
|
||||
@@ -44,7 +50,8 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
int get pageGeneration => _generation;
|
||||
bool get requestingExams => query != null;
|
||||
bool get canLogout => hasOpened || logoutFailed;
|
||||
bool get busy => _loading || _manualCapture || _itemTimeout != null;
|
||||
bool get busy =>
|
||||
_loading || _manualCapture || _recoveringPage || _itemTimeout != null;
|
||||
String get statusLabel => logoutFailed
|
||||
? '退出未完成'
|
||||
: _failed
|
||||
@@ -70,6 +77,8 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
|
||||
void _reset() {
|
||||
_generation++;
|
||||
_pageRecovery.reset();
|
||||
_recoveringPage = false;
|
||||
_cancelTimers();
|
||||
_parts.clear();
|
||||
_index = 0;
|
||||
@@ -123,6 +132,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onPageStarted: (url) {
|
||||
_pageRecovery.invalidateDocument();
|
||||
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.
|
||||
@@ -130,6 +140,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_reset();
|
||||
}
|
||||
_document++;
|
||||
_recoveringPage = false;
|
||||
_poll?.cancel();
|
||||
_probeKey = null;
|
||||
pendingDraft = null;
|
||||
@@ -350,6 +361,37 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
_connected = true;
|
||||
awaitingAuthentication = false;
|
||||
final recovery = await _pageRecovery.tryRestore(
|
||||
web,
|
||||
uri,
|
||||
authenticated: true,
|
||||
documentKey: '$g:$d',
|
||||
isCurrent: () => _valid(g) && d == _document,
|
||||
onStarted: () {
|
||||
_recoveringPage = true;
|
||||
message = '已登录,正在恢复完整教务页面…';
|
||||
_emit();
|
||||
},
|
||||
);
|
||||
if (!_valid(g) || d != _document) return;
|
||||
_recoveringPage = false;
|
||||
if (recovery == FullPageRecoveryResult.restored) {
|
||||
_loading = true;
|
||||
message = '完整页面已恢复,正在检查…';
|
||||
_schedulePoll(g, d, url);
|
||||
_emit();
|
||||
return;
|
||||
}
|
||||
if (recovery == FullPageRecoveryResult.cancelled) {
|
||||
message = '页面已变化,请等待加载完成或重新认证。';
|
||||
_emit();
|
||||
return;
|
||||
}
|
||||
if (recovery == FullPageRecoveryResult.failed) {
|
||||
message = '已登录,但完整教务页面暂未恢复,请重新认证后重试。';
|
||||
_emit();
|
||||
return;
|
||||
}
|
||||
if (!requestingExams) {
|
||||
message = manualMessage;
|
||||
_emit();
|
||||
@@ -438,7 +480,9 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> readCurrentTimetable() async {
|
||||
if (requestingExams || _manualCapture || !_active) return;
|
||||
if (requestingExams || _manualCapture || _recoveringPage || !_active) {
|
||||
return;
|
||||
}
|
||||
final g = _generation, d = _document;
|
||||
_manualCapture = true;
|
||||
_emit();
|
||||
@@ -535,6 +579,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_disposed = true;
|
||||
_active = false;
|
||||
_generation++;
|
||||
_pageRecovery.reset();
|
||||
_cancelTimers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: hepan_everyday
|
||||
description: TPSchedule 学生课表
|
||||
publish_to: none
|
||||
version: 0.7.2+20
|
||||
version: 0.7.3+21
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
dependencies:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const root = path.join(__dirname, '../../lib/features/school');
|
||||
const diagnostic = fs.readFileSync(path.join(root, 'school_browser_diagnostics.dart'), 'utf8').match(/schoolPageDiagnosticsScript = r'''([\s\S]*?)''';/)[1];
|
||||
const wrapper = fs.readFileSync(path.join(root, 'school_full_page_recovery.dart'), 'utf8').match(/'''(\(\(\) => \{[\s\S]*?)'''/)[1];
|
||||
const url = 'https://eams.uestc.edu.cn/eams/home.action';
|
||||
function context(password = false) {
|
||||
const document = {scripts: Array(2), styleSheets: [], querySelector: () => password ? {} : null, querySelectorAll: () => []};
|
||||
for (const key of ['cookie', 'body', 'forms']) Object.defineProperty(document, key, {get() {throw Error('private_data_read');}});
|
||||
return vm.createContext({document, location: {href: url}, window: {}, performance: {getEntriesByType: () => []}});
|
||||
}
|
||||
function run(ctx, nonce) {
|
||||
return JSON.parse(vm.runInContext(wrapper.replace('$diagnostic', diagnostic.replace('__EXPECTED_URL__', JSON.stringify(url))).replace('$nonce', nonce), ctx));
|
||||
}
|
||||
const ctx = context();
|
||||
assert.equal(run(ctx, 'first-document').documentToken, 'first-document');
|
||||
assert.equal(run(ctx, 'unused-token').documentToken, 'first-document');
|
||||
assert.equal(Object.keys(ctx.window).includes('__tpscheduleDocumentToken'), false);
|
||||
assert.equal(run(context(), 'new-document').documentToken, 'new-document');
|
||||
assert.equal(run(context(true), 'ignored'), null);
|
||||
const moved = context(); moved.location.href = 'https://idas.uestc.edu.cn/authserver/login';
|
||||
assert.equal(run(moved, 'ignored'), null);
|
||||
console.log('Recovery snapshot: actual wrapper, stable document nonce, new-document change, password/redirect guards and no private reads passed.');
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:hepan_everyday/features/school/school_full_page_recovery.dart';
|
||||
import 'package:hepan_everyday/features/school/undergraduate/session.dart';
|
||||
import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart';
|
||||
|
||||
import '../support/fake_school_web.dart';
|
||||
|
||||
const _complete = '''<!doctype html><html><head>
|
||||
<script src="/eams/static/jquery.js"></script>
|
||||
<link rel="stylesheet" href="/eams/static/site.css"></head>
|
||||
<body><a href="/eams/logout.action">退出</a>PRIVATE_PAGE_CONTENT</body></html>''';
|
||||
const _fragment =
|
||||
'<a href="/eams/logout.action">退出</a><script>jQuery(document);</script>';
|
||||
|
||||
class Gateway implements SchoolFullPageGateway {
|
||||
int requests = 0, renders = 0, discards = 0;
|
||||
String html = _complete;
|
||||
Completer<SchoolPageCandidate>? pending;
|
||||
Completer<bool>? pendingRender;
|
||||
bool live = false;
|
||||
void Function()? onRender;
|
||||
@override
|
||||
bool supports(WebViewController web) => true;
|
||||
@override
|
||||
Future<SchoolPageCandidate> fetch(
|
||||
WebViewController web,
|
||||
Uri uri,
|
||||
String documentToken,
|
||||
) async {
|
||||
requests++;
|
||||
live = true;
|
||||
return pending == null
|
||||
? SchoolPageCandidate('opaque-token', html)
|
||||
: pending!.future;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> render(String token) async {
|
||||
renders++;
|
||||
if (pendingRender != null) {
|
||||
return await pendingRender!.future && live;
|
||||
}
|
||||
final accepted = live;
|
||||
onRender?.call();
|
||||
return accepted;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> discard(String token) async {
|
||||
discards++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> invalidate(WebViewController web) async {
|
||||
live = false;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late FakeSchoolPlatform platform;
|
||||
late WebViewController web;
|
||||
late Gateway gateway;
|
||||
late SchoolFullPageRecovery recovery;
|
||||
final url = Uri.parse('https://eams.uestc.edu.cn/eams/home.action');
|
||||
setUp(() {
|
||||
platform = FakeSchoolPlatform();
|
||||
WebViewPlatform.instance = platform;
|
||||
web = WebViewController();
|
||||
platform.web.url = url.toString();
|
||||
platform.web.javascript = (_) async => jsonEncode({
|
||||
'scripts': 2,
|
||||
'styles': 0,
|
||||
'links': 0,
|
||||
'loaded': 0,
|
||||
'jquery': false,
|
||||
'documentToken': 'fixture-document-token',
|
||||
'beangle': false,
|
||||
'require': false,
|
||||
'script202': 0,
|
||||
'style202': 0,
|
||||
});
|
||||
gateway = Gateway();
|
||||
recovery = SchoolFullPageRecovery(gateway: gateway);
|
||||
});
|
||||
Future<FullPageRecoveryResult> restore({
|
||||
bool authenticated = true,
|
||||
bool Function()? current,
|
||||
}) => recovery.tryRestore(
|
||||
web,
|
||||
url,
|
||||
authenticated: authenticated,
|
||||
documentKey: 'visit:document',
|
||||
isCurrent: current ?? () => true,
|
||||
);
|
||||
|
||||
test(
|
||||
'authenticated fragment is replaced only after a complete page is fetched',
|
||||
() async {
|
||||
expect(await restore(), FullPageRecoveryResult.restored);
|
||||
expect(gateway.requests, 1);
|
||||
expect(gateway.renders, 1);
|
||||
expect(gateway.discards, 1);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'incomplete head or password form preserves current page and does not loop',
|
||||
() async {
|
||||
for (final html in [
|
||||
_fragment,
|
||||
_complete.replaceFirst(
|
||||
'PRIVATE_PAGE_CONTENT',
|
||||
'<input type="password">',
|
||||
),
|
||||
_complete.replaceFirst(
|
||||
'</head>',
|
||||
'<base href="https://other.test/"></head>',
|
||||
),
|
||||
_complete.replaceFirst(
|
||||
'</head>',
|
||||
'<meta http-equiv="refresh" content="0;url=/login"></head>',
|
||||
),
|
||||
]) {
|
||||
gateway.html = html;
|
||||
recovery.reset();
|
||||
expect(await restore(), FullPageRecoveryResult.failed);
|
||||
expect(await restore(), FullPageRecoveryResult.unchanged);
|
||||
}
|
||||
expect(gateway.renders, 0);
|
||||
expect(gateway.requests, 4);
|
||||
expect(gateway.discards, 4);
|
||||
},
|
||||
);
|
||||
for (final started in [false, true]) {
|
||||
test(
|
||||
'session recovers once and completes after render, started=$started',
|
||||
() async {
|
||||
final session = UndergraduateSession(pageRecovery: recovery);
|
||||
addTearDown(session.dispose);
|
||||
await session.openTimetable();
|
||||
var rendered = false;
|
||||
platform.web.javascript = (script) async {
|
||||
if (script == undergraduateStatusProbeScript) {
|
||||
return jsonEncode({
|
||||
'topUrl': platform.web.url!,
|
||||
'authenticated': true,
|
||||
'pages': [],
|
||||
});
|
||||
}
|
||||
return jsonEncode({
|
||||
'scripts': rendered ? 20 : 2,
|
||||
'styles': 0,
|
||||
'links': rendered ? 3 : 0,
|
||||
'jquery': rendered,
|
||||
'documentToken': 'fixture-document-token',
|
||||
});
|
||||
};
|
||||
gateway.onRender = () {
|
||||
rendered = true;
|
||||
if (started) platform.delegate.started(platform.web.url!);
|
||||
platform.delegate.finished(platform.web.url!);
|
||||
};
|
||||
platform.web.url = url.toString();
|
||||
platform.delegate.started(url.toString());
|
||||
platform.delegate.finished(url.toString());
|
||||
await flushSchoolEvents();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 550));
|
||||
await flushSchoolEvents();
|
||||
expect(session.statusLabel, '已连接');
|
||||
expect(session.busy, false);
|
||||
expect(session.message, UndergraduateSession.manualMessage);
|
||||
expect(gateway.requests, 1);
|
||||
expect(gateway.renders, 1);
|
||||
expect(session.pendingDraft, isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
test(
|
||||
'same URL document replacement or closing during fetch cancels rendering',
|
||||
() async {
|
||||
gateway.pending = Completer<SchoolPageCandidate>();
|
||||
var current = true;
|
||||
final task = restore(current: () => current);
|
||||
await flushSchoolEvents();
|
||||
expect(gateway.requests, 1);
|
||||
current = false;
|
||||
gateway.pending!.complete(
|
||||
const SchoolPageCandidate('opaque-token', _complete),
|
||||
);
|
||||
expect(await task, FullPageRecoveryResult.cancelled);
|
||||
expect(gateway.renders, 0);
|
||||
expect(gateway.discards, 1);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'reset invalidates a native render awaiting document verification',
|
||||
() async {
|
||||
gateway.pendingRender = Completer<bool>();
|
||||
final task = restore();
|
||||
await flushSchoolEvents();
|
||||
expect(gateway.renders, 1);
|
||||
recovery.reset();
|
||||
gateway.pendingRender!.complete(true);
|
||||
expect(await task, FullPageRecoveryResult.cancelled);
|
||||
expect(gateway.live, false);
|
||||
expect(gateway.discards, 1);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'authentication pages and complete documents are not refetched',
|
||||
() async {
|
||||
expect(
|
||||
await restore(authenticated: false),
|
||||
FullPageRecoveryResult.unchanged,
|
||||
);
|
||||
platform.web.javascript = (_) async => jsonEncode({
|
||||
'scripts': 20,
|
||||
'styles': 4,
|
||||
'links': 4,
|
||||
'loaded': 4,
|
||||
'jquery': true,
|
||||
'beangle': true,
|
||||
'require': true,
|
||||
'script202': 0,
|
||||
'style202': 0,
|
||||
});
|
||||
expect(await restore(), FullPageRecoveryResult.unchanged);
|
||||
expect(gateway.requests, 0);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'logout and external or credential-bearing addresses never use the gateway',
|
||||
() async {
|
||||
for (final address in [
|
||||
'https://eams.uestc.edu.cn/eams/logout.action',
|
||||
'https://other.test/eams/home.action',
|
||||
'https://eams.uestc.edu.cn/eams/home.action?ticket=PRIVATE_TICKET',
|
||||
'http://eams.uestc.edu.cn/eams/home.action',
|
||||
]) {
|
||||
platform.web.url = address;
|
||||
expect(
|
||||
await recovery.tryRestore(
|
||||
web,
|
||||
Uri.parse(address),
|
||||
authenticated: true,
|
||||
documentKey: address,
|
||||
isCurrent: () => true,
|
||||
),
|
||||
FullPageRecoveryResult.unchanged,
|
||||
);
|
||||
}
|
||||
expect(gateway.requests, 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user