From 624e252a643958f1ad3b1a22fc91d2def5b4ef77 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 03:31:53 +0800 Subject: [PATCH 1/9] refactor: unify session state into SessionNode tree with storm-safe renew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce lib/services/session/ with a unified SessionNode tree: - CpdailySessionNode (egate) as top-level, IdsSessionNode as its child (renews via parent cpdaily session), LeafSessionNode for token-only providers (gradescope/hydro). - CookieProvider: unified read-only cookie view for downstream apps. - SessionTree.withCookie: 401-renew-retry helper with single-flight + stale-epoch skipping so concurrent callers never storm /auth/renew. ThirdPartyAuthService becomes a facade over the tree, preserving its full public API (egateCookies/egateBinding/renewEgateBinding/etc.) so storage format, cloud-sync serialization, and backend routes are unchanged. Migrate schedule/assignment/oa_gym/home_page callers onto withCookie; drop hand-rolled 401-retry in each service. Add test/session_storm_test.dart covering single-flight, stale-epoch skip, concurrent withCookie renew sharing, and ids→parent delegation. LoggingHttpClient gains an optional inner client for test injection. flutter analyze: clean. flutter test: 25/25 pass. --- lib/pages/home_page.dart | 24 +- lib/services/assignment_service.dart | 72 +++--- lib/services/http_client.dart | 3 +- lib/services/oa_gym_service.dart | 63 +++-- lib/services/schedule_service.dart | 68 ++--- lib/services/session/cookie_provider.dart | 26 ++ lib/services/session/session_node.dart | 109 ++++++++ lib/services/session/session_nodes.dart | 274 +++++++++++++++++++++ lib/services/session/session_tree.dart | 134 ++++++++++ lib/services/third_party_auth_service.dart | 193 ++++++++------- test/session_storm_test.dart | 210 ++++++++++++++++ 11 files changed, 970 insertions(+), 206 deletions(-) create mode 100644 lib/services/session/cookie_provider.dart create mode 100644 lib/services/session/session_node.dart create mode 100644 lib/services/session/session_nodes.dart create mode 100644 lib/services/session/session_tree.dart create mode 100644 test/session_storm_test.dart diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 9c4ba41..d5eef05 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -394,17 +394,19 @@ class _HomePageState extends State with TickerProviderStateMixin { if (cookieType == null) return cookies; final sp = ServiceProvider.of(context); - // ecourse and student-leave both authenticate against the eGate/IDS - // SSO, so they share the eGate binding's CpDaily cookies (which always - // include CASTGC via [ThirdPartyAuthService.egateCookies]). Without an - // eGate binding there is nothing to inject and the webview opens - // unauthenticated — the UI should steer the user to bind eGate first. - final String rawCookies = sp.thirdPartyAuthService.egateCookies(); - if (rawCookies.isEmpty) return cookies; - - final domain = 'ids.shanghaitech.edu.cn'; - - for (final part in rawCookies.split(';')) { + // ecourse / student-leave / eams webviews all authenticate against the + // IDS SSO, whose cookies are exposed by the IDS session node (a child of + // the CpDaily node — it falls back to the parent CpDaily CASTGC-bearing + // cookie set when no dedicated IDS cookie set has been minted). Read it + // through the unified [CookieProvider] view so the source is abstracted. + final cp = sp.thirdPartyAuthService.idsNode.cookieProvider; + if (cp == null || cp.isEmpty) return cookies; + + final domain = cp.domain.isNotEmpty + ? cp.domain + : 'ids.shanghaitech.edu.cn'; + + for (final part in cp.cookies.split(';')) { final idx = part.indexOf('='); if (idx > 0) { final key = part.substring(0, idx).trim(); diff --git a/lib/services/assignment_service.dart b/lib/services/assignment_service.dart index 7b0c896..a007b12 100644 --- a/lib/services/assignment_service.dart +++ b/lib/services/assignment_service.dart @@ -12,6 +12,7 @@ import 'api_base_url.dart'; import 'auth_service.dart'; import 'http_client.dart'; import 'schedule_service.dart'; +import 'session/session_tree.dart'; import 'storage_service.dart'; import 'third_party_auth_service.dart'; @@ -302,28 +303,25 @@ class AssignmentService extends ChangeNotifier { notifyListeners(); } - // -- Per-platform fetchers -- - Future?> _fetchBlackboard() async { - final egate = _tpAuth.egateBinding; - if (egate == null) return null; - final tgc = (egate.raw['tgc'] as String?) ?? ''; - if (tgc.isEmpty) return null; - - Future doFetch(String token) => _http.post( - Uri.parse('$_baseUrl/deadlines/blackboard'), - headers: _jsonHeaders(), - body: jsonEncode({'token': token}), - tag: 'deadlines:blackboard', - ); + final node = _tpAuth.egateNode; + if (!node.isAvailable) return null; try { - var resp = await doFetch(tgc); - if (resp.statusCode == 401 && await _tpAuth.renewEgateBinding()) { - final newTgc = - (_tpAuth.egateBinding?.raw['tgc'] as String?) ?? tgc; - resp = await doFetch(newTgc); - } + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + final tgc = (node.rawFields['tgc'] as String?) ?? ''; + final r = await _http.post( + Uri.parse('$_baseUrl/deadlines/blackboard'), + headers: _jsonHeaders(), + body: jsonEncode({'token': tgc}), + tag: 'deadlines:blackboard', + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + if (resp == null) return null; return _parseDeadlinesResponse(resp, 'blackboard'); } catch (e) { _platformErrors['blackboard'] = '同步失败,请检查网络或稍后重试'; @@ -333,32 +331,30 @@ class AssignmentService extends ChangeNotifier { Future?> _fetchExamTable() async { final semesterId = _selectedSemesterId(); - if (!_tpAuth.hasEgateBinding || + final node = _tpAuth.egateNode; + if (!node.isAvailable || semesterId == null || semesterId.isEmpty) { return null; } - Map buildBody() => { - 'semester_id': semesterId, - 'cookies': _tpAuth.egateCookies(), - }; - try { - var resp = await _http.post( - Uri.parse('$_baseUrl/schedule/exam_table'), - headers: _jsonHeaders(), - body: jsonEncode(buildBody()), - tag: 'schedule:exam_table', + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + final r = await _http.post( + Uri.parse('$_baseUrl/schedule/exam_table'), + headers: _jsonHeaders(), + body: jsonEncode({ + 'semester_id': semesterId, + 'cookies': cp.cookies, + }), + tag: 'schedule:exam_table', + ); + return CookieAction(r, expired: r.statusCode == 401); + }, ); - if (resp.statusCode == 401 && await _tpAuth.renewEgateBinding()) { - resp = await _http.post( - Uri.parse('$_baseUrl/schedule/exam_table'), - headers: _jsonHeaders(), - body: jsonEncode(buildBody()), - tag: 'schedule:exam_table:retry', - ); - } + if (resp == null) return null; return _parseExamTableResponse(resp); } catch (e) { _platformErrors['exam'] = '同步失败,请检查网络或稍后重试'; diff --git a/lib/services/http_client.dart b/lib/services/http_client.dart index 23f59b4..236ffb1 100644 --- a/lib/services/http_client.dart +++ b/lib/services/http_client.dart @@ -8,7 +8,8 @@ class LoggingHttpClient { final http.Client _inner; final DebugLogger _logger; - LoggingHttpClient(this._logger) : _inner = http.Client(); + LoggingHttpClient(this._logger, {http.Client? inner}) + : _inner = inner ?? http.Client(); Future get( Uri url, { diff --git a/lib/services/oa_gym_service.dart b/lib/services/oa_gym_service.dart index de6e8b6..2a322ce 100644 --- a/lib/services/oa_gym_service.dart +++ b/lib/services/oa_gym_service.dart @@ -6,6 +6,8 @@ import 'package:http/http.dart' as http; import '../models/oa_gym.dart'; import 'api_base_url.dart'; import 'auth_service.dart'; +import 'session/cookie_provider.dart'; +import 'session/session_tree.dart'; import 'storage_service.dart'; import 'third_party_auth_service.dart'; @@ -90,7 +92,6 @@ class OaGymService extends ChangeNotifier { () => _postJson( 'oa/gym/availability', { - 'auth': _authPayload(), 'sports': sports.map((sport) => sport.id).toList(), 'date': date, 'startSlot': startSlot, @@ -142,7 +143,6 @@ class OaGymService extends ChangeNotifier { () => _postJson( 'oa/gym/book', { - 'auth': _authPayload(), 'booking': { 'sport': sport.id, 'date': date, @@ -178,7 +178,6 @@ class OaGymService extends ChangeNotifier { () => _postJson( 'oa/gym/search', { - 'auth': _authPayload(), 'startDate': startDate, 'endDate': endDate, 'venueNames': venueNames.toList(), @@ -206,7 +205,7 @@ class OaGymService extends ChangeNotifier { Future _ensureMetadata() async { if (_metadataReady) return; - final data = await _postJson('oa/gym/metadata', {'auth': _authPayload()}); + final data = await _postJson('oa/gym/metadata', const {}); final payload = (data['data'] as Map?)?.cast() ?? data; _venues = _stringMap(payload['venues']); _allVenues = _stringMap(payload['allVenues']); @@ -254,54 +253,52 @@ class OaGymService extends ChangeNotifier { return _auth; } - /// CpDaily auth payload built entirely from the eGate binding — the same - /// source Schedule/Assignment use. No CASTGC ever leaves the primary - /// SSO session (which has none). - Map _authPayload() { - _requireAuth(); - final cookies = _tpAuth.egateCookies(); - if (cookies.isEmpty) { - throw OaGymException('当前 eGate 登录态已失效,请重新绑定 eGate'); - } - final egate = _tpAuth.egateBinding!; - final raw = egate.raw; + /// CpDaily auth payload built from a [CookieProvider] snapshot plus the + /// egate node's raw session fields (tgc/sessionToken/userId/tenantId). The + /// cookie + epoch captured at request time drive the storm-safe renew-retry + /// in [_postJson]. + Map _authPayload(CookieProvider cp) { + final raw = _tpAuth.egateNode.rawFields; return { 'tgc': (raw['tgc'] as String?) ?? '', - 'cookies': cookies, + 'cookies': cp.cookies, 'sessionToken': (raw['sessionToken'] as String?) ?? '', 'userId': (raw['userId'] as String?) ?? '', 'tenantId': (raw['tenantId'] as String?) ?? '', }; } + /// POST `$_baseUrl/[path]` with CpDaily auth + [extra] body fields. On 401 + /// the egate node is renewed exactly once (single-flighted across all + /// concurrent callers) and the request retried with the fresh cookie. Future> _postJson( String path, - Map body, + Map extra, ) async { - var response = await _client - .post( - Uri.parse('$_baseUrl/$path'), - headers: const {'Content-Type': 'application/json; charset=UTF-8'}, - body: jsonEncode(body), - ) - .timeout(const Duration(seconds: 30)); - - // 401 → CpDaily session expired: renew the eGate binding once, then retry. - if (response.statusCode == 401) { - _sessionReady = false; - if (await _tpAuth.renewEgateBinding()) { - response = await _client + _requireAuth(); + final node = _tpAuth.egateNode; + final response = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + final body = {...extra, 'auth': _authPayload(cp)}; + final r = await _client .post( Uri.parse('$_baseUrl/$path'), headers: const { 'Content-Type': 'application/json; charset=UTF-8', }, - body: jsonEncode(body..['auth'] = _authPayload()), + body: jsonEncode(body), ) .timeout(const Duration(seconds: 30)); - } + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + if (response == null) { + throw OaGymException('当前 eGate 登录态已失效,请重新绑定 eGate'); + } + if (response.statusCode == 401) { + _sessionReady = false; } - final decoded = response.body.isEmpty ? {} : (jsonDecode(response.body) as Map).cast(); diff --git a/lib/services/schedule_service.dart b/lib/services/schedule_service.dart index a9534d1..bc126b8 100644 --- a/lib/services/schedule_service.dart +++ b/lib/services/schedule_service.dart @@ -7,6 +7,8 @@ import '../models/course_table.dart'; import 'api_base_url.dart'; import 'auth_service.dart'; import 'http_client.dart'; +import 'session/cookie_provider.dart'; +import 'session/session_tree.dart'; import 'storage_service.dart'; import 'third_party_auth_service.dart'; @@ -44,15 +46,15 @@ class ScheduleService extends ChangeNotifier { 'Content-Type': 'application/json; charset=UTF-8', }; - Map _authBody() { - final cookies = _tpAuth.egateCookies(); - return { - 'studentId': _tpAuth.egateStudentId, - 'cookies': cookies, - }; - } + /// Auth payload built from a [CookieProvider] snapshot captured at request + /// time. The epoch captured alongside is what makes the renew-retry + /// storm-safe (see [_postWithRetry]). + Map _authBody(CookieProvider cp) => { + 'studentId': cp.studentId, + 'cookies': cp.cookies, + }; - bool get _hasEgateBinding => _tpAuth.hasEgateBinding; + bool get _hasEgateBinding => _tpAuth.egateNode.isAvailable; Future loadCachedData() async { _semesterInfo = _storage.loadSemesters(); @@ -92,7 +94,7 @@ class ScheduleService extends ChangeNotifier { Future fetchSemesters() async { final resp = await _postWithRetry( '$_baseUrl/schedule/semesters', - _authBody(), + const {}, 'fetchSemesters', ); final data = jsonDecode(resp.body) as Map; @@ -106,8 +108,7 @@ class ScheduleService extends ChangeNotifier { } Future fetchCourseTable(String semesterId) async { - final body = { - ..._authBody(), + final extra = { 'semester_id': semesterId, if (_semesterInfo?.tableId.isNotEmpty == true) 'table_id': _semesterInfo!.tableId, @@ -115,7 +116,7 @@ class ScheduleService extends ChangeNotifier { final resp = await _postWithRetry( '$_baseUrl/schedule/course_table', - body, + extra, 'fetchCourseTable', ); final data = jsonDecode(resp.body) as Map; @@ -160,11 +161,11 @@ class ScheduleService extends ChangeNotifier { String semester, String cacheKey, ) async { - final body = {..._authBody(), 'year': year, 'semester': semester}; + final extra = {'year': year, 'semester': semester}; final resp = await _postWithRetry( '$_baseUrl/schedule/term_begin', - body, + extra, 'fetchTermBegin', ); final data = jsonDecode(resp.body) as Map; @@ -209,35 +210,38 @@ class ScheduleService extends ChangeNotifier { } } + /// POST [url] with CpDaily auth + [extra] body fields. On 401 the egate + /// node is renewed exactly once (single-flighted across all concurrent + /// callers) and the request retried with the fresh cookie. Throws on any + /// non-200 after the retry budget is exhausted. Future _postWithRetry( String url, - Map body, + Map extra, String tag, ) async { - var resp = await _http.post( - Uri.parse(url), - headers: _jsonHeaders(), - body: jsonEncode(body), - tag: tag, - ); - - if (resp.statusCode == 401) { - // CpDaily session expired — renew the eGate binding once and retry. - if (await _tpAuth.renewEgateBinding()) { - final newBody = {...body, ..._authBody()}; - resp = await _http.post( + final node = _tpAuth.egateNode; + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + final body = {..._authBody(cp), ...extra}; + final r = await _http.post( Uri.parse(url), headers: _jsonHeaders(), - body: jsonEncode(newBody), - tag: '$tag-retry', + body: jsonEncode(body), + tag: tag, ); - } + return CookieAction( + r, + expired: r.statusCode == 401, + ); + }, + ); + if (resp == null) { + throw Exception('eGate session unavailable'); } - if (resp.statusCode != 200) { throw Exception('Request failed with status ${resp.statusCode}'); } - return resp; } } diff --git a/lib/services/session/cookie_provider.dart b/lib/services/session/cookie_provider.dart new file mode 100644 index 0000000..80372bc --- /dev/null +++ b/lib/services/session/cookie_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +/// A read-only snapshot of cookies + identity a downstream consumer (campus +/// service, webview feature) injects into its requests. +/// +/// Implementations: [CpdailyCookieProvider] (CASTGC-bearing CpDaily session), +/// [IdsCookieProvider] (IDS SSO cookies minted from the CpDaily session), and +/// a no-op empty view for leaf nodes that expose no cookies (Gradescope/Hydro +/// work via bearer tokens server-side, not browser cookies). +@immutable +class CookieProvider { + final String cookies; + final String studentId; + final String domain; + + const CookieProvider({ + required this.cookies, + this.studentId = '', + this.domain = '', + }); + + /// Empty provider — callers treat [isEmpty] as "session unavailable". + static const CookieProvider empty = CookieProvider(cookies: ''); + + bool get isEmpty => cookies.isEmpty; +} diff --git a/lib/services/session/session_node.dart b/lib/services/session/session_node.dart new file mode 100644 index 0000000..98cfb88 --- /dev/null +++ b/lib/services/session/session_node.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'cookie_provider.dart'; + +/// One node in the unified session tree. +/// +/// Topology (master branch): +/// ``` +/// SessionTree +/// ├── gradescope : LeafSessionNode (token-only, no cookies) +/// ├── hydro : LeafSessionNode (token-only, no cookies) +/// └── egate : CpdailySessionNode (CASTGC + CpDaily session) +/// └── ids : IdsSessionNode (IDS cookies refreshed via parent) +/// ``` +/// +/// Every node exposes: +/// - [cookieProvider] — the [CookieProvider] view downstream apps read. Null +/// for leaf nodes that authenticate via bearer tokens server-side. +/// - [renew()] — refresh this node's credentials. Single-flighted: concurrent +/// callers share ONE in-flight renew and observe the same result. +/// - [epoch] — monotonically increasing, bumped on every successful renew. +/// Callers capture the epoch when they read cookies, then pass it to +/// [renewIfNeeded] so a concurrent renew that already refreshed the cookie +/// is NOT re-triggered (anti-renew-storm). +/// +/// The 401-renew-retry pattern lives in [SessionTree.withCookie] so individual +/// services stop hand-rolling it. +abstract class SessionNode extends ChangeNotifier { + SessionNode({required this.id}); + + /// Stable identifier (matches storage key / platform id). + final String id; + + /// Optional parent — set when this node's session is derived from another + /// (e.g. IDS cookies are minted from the CpDaily session). Null for roots. + SessionNode? parent; + + final List _children = []; + List get children => List.unmodifiable(_children); + + void attachChild(SessionNode child) { + child.parent = this; + _children.add(child); + } + + void detachChild(SessionNode child) { + if (child.parent == this) child.parent = null; + _children.remove(child); + } + + /// The cookie view exposed to downstream consumers, or null when this node + /// authenticates via bearer tokens (Gradescope/Hydro) and has no cookies. + CookieProvider? get cookieProvider => null; + + /// True when this node has a usable session (bound + non-empty credentials). + bool get isAvailable; + + // -- Renewal: single-flight + stale-epoch skip -- + + int _epoch = 0; + Future? _renewInFlight; + + /// Current epoch. Bumped after every successful [renew]. Callers capture + /// this when reading cookies and pass it to [renewIfNeeded]. + int get epoch => _epoch; + + /// Bump epoch and notify. Called by subclasses after persisting refreshed + /// credentials into the wrapped account. + @protected + void markRenewed() { + _epoch++; + notifyListeners(); + } + + /// Subclass-specific renew. MUST persist refreshed credentials and call + /// [markRenewed] on success. Returns true on success, false on failure. + @protected + Future doRenew(); + + /// Refresh this node's credentials. Single-flighted: concurrent callers + /// share one in-flight renew and observe the same result. + Future renew() { + if (_renewInFlight != null) return _renewInFlight!; + final f = doRenew().whenComplete(() => _renewInFlight = null); + _renewInFlight = f; + return f; + } + + /// Renew only if no renew has completed since [beforeEpoch]. This is the + /// anti-storm gate: a caller that captured cookies at epoch N, hit a 401, + /// and now wants to renew will skip if another caller already renewed + /// (epoch > N) — it just re-reads the fresh cookies instead. + /// + /// Returns true if a renew ran and succeeded OR was already done by a + /// concurrent caller (epoch advanced). Returns false only when a renew + /// actually ran and failed, or the node is unavailable. + Future renewIfNeeded(int beforeEpoch) async { + if (!isAvailable) return false; + // A concurrent renew already advanced past the caller's snapshot — + // the cookies the caller will re-read are already fresh. Skip. + if (_epoch > beforeEpoch) return true; + return renew(); + } + + @override + String toString() => '$runtimeType($id)'; +} diff --git a/lib/services/session/session_nodes.dart b/lib/services/session/session_nodes.dart new file mode 100644 index 0000000..68ef1c1 --- /dev/null +++ b/lib/services/session/session_nodes.dart @@ -0,0 +1,274 @@ +import 'dart:async'; +import 'dart:convert'; + + +import '../../models/third_party_account.dart'; +import '../http_client.dart'; +import 'cookie_provider.dart'; +import 'session_node.dart'; + +/// Callback the facade ([ThirdPartyAuthService]) installs so a node can +/// persist a refreshed [ThirdPartyAccount] back into secure storage and fire +/// the external change notification (listeners + cloud-sync push hook) in one +/// place. Returns nothing; the node owns the in-memory account afterwards. +typedef PersistAccount = Future Function(ThirdPartyAccount updated); + +/// Callback to read the current API base URL (depends on storage settings). +typedef BaseUrlGetter = String Function(); + +/// The CpDaily session node — the single source of CASTGC / CpDaily cookies. +/// +/// Wraps the `egate` [ThirdPartyAccount]. Renewal POSTs to `/auth/renew` with +/// the stored session token + tgc; the response rotates the CpDaily session. +/// On success the refreshed raw is persisted via [persist] and [markRenewed] +/// bumps the epoch so concurrent 401-retry callers skip their own renew. +/// +/// This node is the **parent** of [IdsSessionNode]: the IDS SSO cookies are +/// derived from (and refreshed via) this CpDaily session. +class CpdailySessionNode extends SessionNode { + CpdailySessionNode({ + required this.persist, + required this.http, + required this.baseUrl, + }) : super(id: 'egate'); + + final PersistAccount persist; + final LoggingHttpClient http; + final BaseUrlGetter baseUrl; + ThirdPartyAccount? _account; + + /// The wrapped account. Set by the facade on bind/unbind/replaceAll. + ThirdPartyAccount? get account => _account; + + void setAccount(ThirdPartyAccount? acc) { + _account = acc; + notifyListeners(); + } + + @override + bool get isAvailable => _account != null && cookieProvider != null; + + @override + CookieProvider? get cookieProvider { + final acc = _account; + if (acc == null) return null; + final raw = acc.raw; + final baseCookies = (raw['cookies'] as String?) ?? ''; + final tgc = (raw['tgc'] as String?) ?? ''; + final cookies = tgc.isEmpty + ? baseCookies + : (baseCookies.isNotEmpty + ? '$baseCookies; CASTGC=$tgc' + : 'CASTGC=$tgc'); + if (cookies.isEmpty) return null; + return CookieProvider( + cookies: cookies, + studentId: acc.sid ?? '', + domain: 'ids.shanghaitech.edu.cn', + ); + } + + /// Raw fields downstream services (OA gym) read alongside cookies. + Map get rawFields => _account?.raw ?? const {}; + + @override + Future doRenew() async { + final acc = _account; + if (acc == null) return false; + try { + final resp = await http.post( + Uri.parse('${baseUrl()}/auth/renew'), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode({ + 'sessionToken': acc.raw['sessionToken'] ?? '', + 'tgc': acc.raw['tgc'] ?? '', + 'userId': acc.raw['userId'] ?? '', + 'tenantId': acc.raw['tenantId'] ?? '', + }), + tag: 'egateCpDailyRenew', + ); + if (resp.statusCode != 200) return false; + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) return false; + + final newRaw = { + ...acc.raw, + 'sessionToken': + data['sessionToken'] as String? ?? acc.raw['sessionToken'] ?? '', + 'tgc': data['tgc'] as String? ?? acc.raw['tgc'] ?? '', + 'userId': data['userId'] as String? ?? acc.raw['userId'] ?? '', + 'tenantId': + data['tenantId'] as String? ?? acc.raw['tenantId'] ?? '', + 'cookies': data['cookies'] as String? ?? acc.raw['cookies'] ?? '', + }; + final updated = ThirdPartyAccount( + platform: acc.platform, + account: acc.account, + sid: acc.sid, + name: acc.name, + email: acc.email, + token: acc.token, + expire: acc.expire, + raw: newRaw, + hydroOrigin: acc.hydroOrigin, + hydroDomains: acc.hydroDomains, + boundAt: acc.boundAt, + autoRenew: acc.autoRenew, + password: acc.password, + ); + _account = updated; + await persist(updated); + markRenewed(); + return true; + } catch (_) { + return false; + } + } +} + +/// The IDS SSO cookie node — a **child** of [CpdailySessionNode]. +/// +/// IDS cookies are minted from the CpDaily session (CASTGC) via an SSO bounce. +/// Renewal walks up to the parent CpDaily session, renews *it* if stale, then +/// re-derives the IDS cookies. This is the "ids 走 ids cpdaily renew" path: +/// independent renew implementation that delegates the underlying session +/// refresh to the parent. +/// +/// The IDS cookies live in the same egate [ThirdPartyAccount.raw] under +/// `idsCookies` / `idsDomain`, so no separate storage entry is needed. +class IdsSessionNode extends SessionNode { + IdsSessionNode({required CpdailySessionNode cpdaily}) + : _cpdaily = cpdaily, + super(id: 'ids') { + _cpdaily.addListener(_onParentChanged); + } + + final CpdailySessionNode _cpdaily; + + void _onParentChanged() { + // Parent renewed → our derived IDS cookies may be stale; bump epoch so a + // pending caller's renewIfNeeded re-reads fresh state. We do NOT clear + // idsCookies here (they may still be valid); renew will overwrite them. + notifyListeners(); + } + + @override + bool get isAvailable => _cpdaily.isAvailable; + + @override + CookieProvider? get cookieProvider { + final raw = _cpdaily.rawFields; + final idsCookies = (raw['idsCookies'] as String?) ?? ''; + if (idsCookies.isEmpty) { + // Fall back to the parent CpDaily cookies — IDS and CpDaily share the + // CASTGC-bearing cookie set for ecourse/egate webviews. When a dedicated + // IDS cookie set has been minted it takes precedence. + return _cpdaily.cookieProvider; + } + return CookieProvider( + cookies: idsCookies, + studentId: _cpdaily.cookieProvider?.studentId ?? '', + domain: (raw['idsDomain'] as String?) ?? 'ids.shanghaitech.edu.cn', + ); + } + + @override + Future doRenew() async { + // IDS cookies are refreshed via the parent CpDaily session. Renew the + // parent (single-flighted at its level); on success the parent's raw is + // updated and our cookieProvider re-derives from it. A dedicated IDS + // bounce endpoint can be wired here later; for now the CpDaily renew + // already rotates the CASTGC-bearing cookie set IDS depends on. + final ok = await _cpdaily.renew(); + if (ok) markRenewed(); + return ok; + } +} + +/// A leaf node for token-only third-party providers (Gradescope, Hydro). +/// +/// These authenticate via bearer tokens consumed server-side by the TechPie +/// backend — they expose NO [CookieProvider]. Renewal is password-based +/// re-authentication (only when [ThirdPartyAccount.autoRenew] is set with a +/// stored password); a 401 from the backend means the token is dead and the +/// binding should be unbound, not renewed. +class LeafSessionNode extends SessionNode { + LeafSessionNode({ + required this.platform, + required this.persist, + required this.http, + required this.baseUrl, + }) : super(id: platform.id); + + final ThirdPartyPlatform platform; + final PersistAccount persist; + final LoggingHttpClient http; + final BaseUrlGetter baseUrl; + + ThirdPartyAccount? _account; + + ThirdPartyAccount? get account => _account; + + void setAccount(ThirdPartyAccount? acc) { + _account = acc; + notifyListeners(); + } + + @override + bool get isAvailable => _account != null; + + @override + CookieProvider? get cookieProvider => null; + + @override + Future doRenew() async { + final acc = _account; + if (acc == null || !acc.autoRenew) return false; + final pw = acc.password; + if (pw == null || pw.isEmpty) return false; + try { + final body = { + 'account': acc.account, + 'password': pw, + }; + if (platform == ThirdPartyPlatform.hydro && + acc.hydroOrigin != null && + acc.hydroOrigin!.isNotEmpty) { + body['args'] = {'url': acc.hydroOrigin}; + } + final resp = await http.post( + Uri.parse('${baseUrl()}/auth/third-party/${platform.id}'), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode(body), + tag: 'thirdPartyRenew:${platform.id}', + ); + if (resp.statusCode != 200) return false; + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) return false; + final d = (data['data'] as Map?)?.cast() ?? const {}; + final token = d['token'] as String?; + if (token == null || token.isEmpty) return false; + final renewed = ThirdPartyAccount( + platform: acc.platform, + account: acc.account, + sid: d['sid'] as String? ?? acc.sid, + name: d['name'] as String? ?? acc.name, + email: d['email'] as String? ?? acc.email, + token: token, + expire: (d['expire'] as num?)?.toInt() ?? acc.expire, + raw: (d['raw'] as Map?)?.cast() ?? acc.raw, + hydroOrigin: acc.hydroOrigin, + hydroDomains: acc.hydroDomains, + boundAt: acc.boundAt, + autoRenew: true, + password: pw, + ); + _account = renewed; + await persist(renewed); + markRenewed(); + return true; + } catch (_) { + return false; + } + } +} diff --git a/lib/services/session/session_tree.dart b/lib/services/session/session_tree.dart new file mode 100644 index 0000000..4bdaf26 --- /dev/null +++ b/lib/services/session/session_tree.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../models/third_party_account.dart'; +import '../http_client.dart'; +import 'cookie_provider.dart'; +import 'session_node.dart'; +import 'session_nodes.dart'; +/// +/// Topology is built once at construction; the facade +/// ([ThirdPartyAuthService]) feeds account mutations in via [setAccount]. +class SessionTree extends ChangeNotifier { + SessionTree({ + required this.persist, + required this.http, + required this.baseUrl, + }) { + egate = CpdailySessionNode( + persist: persist, + http: http, + baseUrl: baseUrl, + ); + ids = IdsSessionNode(cpdaily: egate); + egate.attachChild(ids); + gradescope = LeafSessionNode( + platform: ThirdPartyPlatform.gradescope, + persist: persist, + http: http, + baseUrl: baseUrl, + ); + hydro = LeafSessionNode( + platform: ThirdPartyPlatform.hydro, + persist: persist, + http: http, + baseUrl: baseUrl, + ); + + // Propagate child notifications up so listeners on the tree (facade → + // UI / sync) fire on any node change. + for (final n in [egate, ids, gradescope, hydro]) { + n.addListener(notifyListeners); + } + } + + final PersistAccount persist; + final LoggingHttpClient http; + final BaseUrlGetter baseUrl; + + + late final CpdailySessionNode egate; + late final IdsSessionNode ids; + late final LeafSessionNode gradescope; + late final LeafSessionNode hydro; + + /// All top-level nodes in a stable order. + List get roots => [egate, gradescope, hydro]; + + /// Lookup a leaf/ root node by [ThirdPartyPlatform]. + SessionNode nodeFor(ThirdPartyPlatform p) => switch (p) { + ThirdPartyPlatform.egate => egate, + ThirdPartyPlatform.gradescope => gradescope, + ThirdPartyPlatform.hydro => hydro, + }; + + /// Feed an account mutation from the facade into the matching node. Used by + /// bind/unbind/replaceAll/updateRaw. + void setAccount(ThirdPartyPlatform p, ThirdPartyAccount? acc) { + switch (p) { + case ThirdPartyPlatform.egate: + egate.setAccount(acc); + break; + case ThirdPartyPlatform.gradescope: + gradescope.setAccount(acc); + break; + case ThirdPartyPlatform.hydro: + hydro.setAccount(acc); + break; + } + } + + // -- The anti-storm 401-renew-retry helper -- + + /// Run [action] with the cookie view from [node]. On a response the caller + /// flags as expired (via [isExpired]), renew the node exactly once (shared + /// across all concurrent callers) and retry with the fresh cookie. If a + /// concurrent renew already advanced the cookie epoch since [action] + /// captured it, skip the renew entirely and just retry with the new cookie. + /// + /// [action] receives the current [CookieProvider] (non-null — callers + /// gate on [node.isAvailable] first) and returns its result + whether the + /// result should be treated as "cookie expired, please renew+retry". + /// + /// Returns the (possibly retried) result, or null if the node was + /// unavailable or renew failed. + Future withCookie( + SessionNode node, + Future> Function(CookieProvider provider) action, + ) async { + if (!node.isAvailable) return null; + var cp = node.cookieProvider; + if (cp == null) return null; + + var result = await action(cp); + if (!result.expired) return result.value; + + // 401: renew if the cookie hasn't already been refreshed since we read it. + final beforeEpoch = node.epoch; + final ok = await node.renewIfNeeded(beforeEpoch); + if (!ok) return result.value; + + cp = node.cookieProvider; + if (cp == null) return result.value; + final retried = await action(cp); + return retried.value; + } + + @override + void dispose() { + for (final n in [egate, ids, gradescope, hydro]) { + n.removeListener(notifyListeners); + n.dispose(); + } + super.dispose(); + } +} + +/// The result of a cookie-bearing action, tagged by the caller so +/// [SessionTree.withCookie] knows whether to trigger a renew+retry. +class CookieAction { + final T value; + final bool expired; + const CookieAction(this.value, {required this.expired}); +} diff --git a/lib/services/third_party_auth_service.dart b/lib/services/third_party_auth_service.dart index 15c6202..f411477 100644 --- a/lib/services/third_party_auth_service.dart +++ b/lib/services/third_party_auth_service.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import '../models/third_party_account.dart'; import 'api_base_url.dart'; import 'http_client.dart'; +import 'session/session_nodes.dart'; +import 'session/session_tree.dart'; import 'storage_service.dart'; class ThirdPartyBindException implements Exception { @@ -20,35 +22,84 @@ class ThirdPartyAuthService extends ChangeNotifier { final StorageService _storage; final LoggingHttpClient _http; - final Map _accounts = {}; bool _initialized = false; // SMS context for eGate binding flow (set by sendEgateSmsCode). Map? _egateSmsContext; - ThirdPartyAuthService(this._storage, this._http); + late final SessionTree _tree; + + ThirdPartyAuthService(this._storage, this._http) { + _tree = SessionTree( + persist: _persistAccount, + http: _http, + baseUrl: () => apiBaseUrl(_storage), + ); + // Tree node notifications propagate to this service's listeners (UI, + // AssignmentService auto-refetch, etc.) and the cloud-sync push hook. + _tree.addListener(_onTreeChanged); + } String get _baseUrl => apiBaseUrl(_storage); bool get initialized => _initialized; - List get boundPlatforms => _accounts.keys.toList(); - Iterable get accounts => _accounts.values; - ThirdPartyAccount? account(ThirdPartyPlatform p) => _accounts[p]; + + // -- SessionTree access (new unified API) -- + + /// The unified session tree. Callers that want node-level control (e.g. + /// [SessionTree.withCookie] for 401-renew-retry) go through here. + SessionTree get sessionTree => _tree; + + /// Convenience: the CpDaily (eGate) session node — parent of [idsNode]. + CpdailySessionNode get egateNode => _tree.egate; + + /// Convenience: the IDS session node — child of [egateNode], refreshed via + /// the CpDaily session. + IdsSessionNode get idsNode => _tree.ids; + + List get boundPlatforms => + _accountsSnapshot.map((a) => a.platform).toList(); + Iterable get accounts => _accountsSnapshot; + ThirdPartyAccount? account(ThirdPartyPlatform p) => _nodeAccount(p); + + List get _accountsSnapshot => [ + _tree.egate.account, + _tree.gradescope.account, + _tree.hydro.account, + ].whereType().toList(); + + ThirdPartyAccount? _nodeAccount(ThirdPartyPlatform p) => switch (p) { + ThirdPartyPlatform.egate => _tree.egate.account, + ThirdPartyPlatform.gradescope => _tree.gradescope.account, + ThirdPartyPlatform.hydro => _tree.hydro.account, + }; /// Post-construction hook fired after any binding mutation (bind / unbind / - /// raw update / replaceAll). Wired by main.dart to [SyncService.pushIfDue] - /// so the cloud backup stays current. Null until wired; safe to call. + /// raw update / replaceAll / node-initiated renew). Wired by main.dart to + /// [SyncService.pushIfDue] so the cloud backup stays current. Null until + /// wired; safe to call. Future Function()? onBindingsChanged; - void _notifyChanged() { + void _onTreeChanged() { + // A node changed (renew / setAccount) — re-notify our listeners and push + // to cloud sync. This is the single funnel for all mutations now. notifyListeners(); final hook = onBindingsChanged; if (hook != null) { - // Fire-and-forget; the hook is throttled and swallows its own errors. unawaited(hook()); } } + /// Persist a (possibly refreshed) account into secure storage AND sync the + /// matching [SessionNode]'s in-memory state. Installed as the tree's + /// [PersistAccount] callback so node-initiated renews flow through here. + Future _persistAccount(ThirdPartyAccount updated) async { + await _storage.saveThirdPartyAccount(updated); + // The node already updated its own _account before calling persist; we + // only need storage + notification here (notification fires via the tree + // listener → _onTreeChanged). + } + // -- eGate binding (single source of CASTGC / CpDaily session) -- // // The eGate binding is the ONLY place CASTGC lives in the new architecture. @@ -59,83 +110,36 @@ class ThirdPartyAuthService extends ChangeNotifier { /// True when an eGate / IDS binding exists. This is the gate every /// CASTGC-dependent feature must check before doing work. - bool get hasEgateBinding => _accounts[ThirdPartyPlatform.egate] != null; + bool get hasEgateBinding => _tree.egate.account != null; /// The bound eGate account, or null. - ThirdPartyAccount? get egateBinding => _accounts[ThirdPartyPlatform.egate]; + ThirdPartyAccount? get egateBinding => _tree.egate.account; /// Cookie string for campus-system requests, always ending with /// `CASTGC=` when a tgc is present (the form CpDaily/EAMS expects). /// Returns '' when there is no binding or no tgc — callers should treat /// that as "session unavailable". - String egateCookies() { - final acc = _accounts[ThirdPartyPlatform.egate]; - if (acc == null) return ''; - final raw = acc.raw; - final baseCookies = (raw['cookies'] as String?) ?? ''; - final tgc = (raw['tgc'] as String?) ?? ''; - return tgc.isEmpty - ? baseCookies - : (baseCookies.isNotEmpty - ? '$baseCookies; CASTGC=$tgc' - : 'CASTGC=$tgc'); - } + String egateCookies() => _tree.egate.cookieProvider?.cookies ?? ''; /// Student id surfaced by the eGate binding, or '' if unbound. - String get egateStudentId => - _accounts[ThirdPartyPlatform.egate]?.sid ?? ''; - - /// Best-effort renewal of the eGate binding's CpDaily session via - /// `/api/auth/renew`. On success the refreshed raw data is persisted back - /// into the binding and listeners are notified. Returns true on success. - Future renewEgateBinding() async { - final acc = _accounts[ThirdPartyPlatform.egate]; - if (acc == null) return false; - try { - final resp = await _http.post( - Uri.parse('$_baseUrl/auth/renew'), - headers: {'Content-Type': 'application/json; charset=UTF-8'}, - body: jsonEncode({ - 'sessionToken': acc.raw['sessionToken'] ?? '', - 'tgc': acc.raw['tgc'] ?? '', - 'userId': acc.raw['userId'] ?? '', - 'tenantId': acc.raw['tenantId'] ?? '', - }), - tag: 'egateCpDailyRenew', - ); + String get egateStudentId => _tree.egate.account?.sid ?? ''; - if (resp.statusCode != 200) return false; - final data = jsonDecode(resp.body) as Map; - if (data['success'] != true) return false; - - await updateRaw( - ThirdPartyPlatform.egate, - { - ...acc.raw, - 'sessionToken': - data['sessionToken'] as String? ?? acc.raw['sessionToken'] ?? '', - 'tgc': data['tgc'] as String? ?? acc.raw['tgc'] ?? '', - 'userId': data['userId'] as String? ?? acc.raw['userId'] ?? '', - 'tenantId': - data['tenantId'] as String? ?? acc.raw['tenantId'] ?? '', - 'cookies': data['cookies'] as String? ?? acc.raw['cookies'] ?? '', - }, - ); - return true; - } catch (_) { - return false; - } - } + /// Best-effort renewal of the eGate binding's CpDaily session. Delegates to + /// [CpdailySessionNode.renew], which is single-flighted: concurrent callers + /// (two services hitting 401 at once) share ONE `/auth/renew` POST. On + /// success the refreshed raw is persisted and listeners notified via the + /// tree → [Service._onTreeChanged] path. Returns true on success. + Future renewEgateBinding() => _tree.egate.renew(); Future initialize() async { final loaded = await _storage.loadAllThirdPartyAccounts(); - _accounts - ..clear() - ..addEntries(loaded.map((a) => MapEntry(a.platform, a))); + // Seed each node with its persisted account. setAccount routes to the + // matching node and notifies (boot hydration — the sync hook is not wired + // yet, so no cloud push fires). + for (final acc in loaded) { + _tree.setAccount(acc.platform, acc); + } _initialized = true; - // Boot hydration is not a user-driven mutation — notify listeners but do - // NOT trigger a cloud push (the hook is not wired yet at this point, and - // a pull may follow that should take precedence). notifyListeners(); } @@ -208,32 +212,33 @@ class ThirdPartyAuthService extends ChangeNotifier { ); await _storage.saveThirdPartyAccount(acc); - _accounts[platform] = acc; - _notifyChanged(); + _tree.setAccount(platform, acc); return acc; } Future unbind(ThirdPartyPlatform platform) async { - _accounts.remove(platform); + _tree.setAccount(platform, null); await _storage.clearThirdPartyAccount(platform); - _notifyChanged(); } - /// Replace the entire in-memory + persisted binding set in one shot. Used by /// [SyncService.pull] to restore a cloud-fetched snapshot: clears every /// existing platform binding, writes each entry in [next] to secure storage, /// and rebuilds the in-memory map. Fires a single notification. Future replaceAll(List next) async { + // Clear storage for every platform first. for (final p in ThirdPartyPlatform.values) { await _storage.clearThirdPartyAccount(p); } - _accounts - ..clear() - ..addEntries(next.map((a) => MapEntry(a.platform, a))); - for (final a in next) { - await _storage.saveThirdPartyAccount(a); + // Then seed each node + persist. setAccount notifies per-node; the tree + // listener funnels into a single _onTreeChanged (throttled by SyncService). + final byPlatform = { + for (final a in next) a.platform: a, + }; + for (final p in ThirdPartyPlatform.values) { + _tree.setAccount(p, byPlatform[p]); + final a = byPlatform[p]; + if (a != null) await _storage.saveThirdPartyAccount(a); } - _notifyChanged(); } /// Update the raw data of a bound account (e.g. after CpDaily session renewal). @@ -241,7 +246,7 @@ class ThirdPartyAuthService extends ChangeNotifier { ThirdPartyPlatform platform, Map newRaw, ) async { - final acc = _accounts[platform]; + final acc = _nodeAccount(platform); if (acc == null) return; final updated = ThirdPartyAccount( platform: acc.platform, @@ -259,8 +264,7 @@ class ThirdPartyAuthService extends ChangeNotifier { password: acc.password, ); await _storage.saveThirdPartyAccount(updated); - _accounts[platform] = updated; - _notifyChanged(); + _tree.setAccount(platform, updated); } // -- eGate SMS binding flow -- @@ -350,9 +354,8 @@ class ThirdPartyAuthService extends ChangeNotifier { ); await _storage.saveThirdPartyAccount(acc); - _accounts[ThirdPartyPlatform.egate] = acc; + _tree.setAccount(ThirdPartyPlatform.egate, acc); _egateSmsContext = null; - _notifyChanged(); return acc; } @@ -366,7 +369,7 @@ class ThirdPartyAuthService extends ChangeNotifier { Duration window = const Duration(hours: 48), }) async { final cutoff = DateTime.now().add(window); - final snapshot = _accounts.values.toList(); + final snapshot = _accountsSnapshot; final failed = []; for (final acc in snapshot) { if (!acc.autoRenew) continue; @@ -395,8 +398,16 @@ class ThirdPartyAuthService extends ChangeNotifier { } Future clearAll() async { - _accounts.clear(); + for (final p in ThirdPartyPlatform.values) { + _tree.setAccount(p, null); + } await _storage.clearAllThirdPartyAccounts(); - _notifyChanged(); + } + + @override + void dispose() { + _tree.removeListener(_onTreeChanged); + _tree.dispose(); + super.dispose(); } } diff --git a/test/session_storm_test.dart b/test/session_storm_test.dart new file mode 100644 index 0000000..a6f7306 --- /dev/null +++ b/test/session_storm_test.dart @@ -0,0 +1,210 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:techpie/models/third_party_account.dart'; +import 'package:techpie/services/debug_logger.dart'; +import 'package:techpie/services/http_client.dart'; +import 'package:techpie/services/session/session_tree.dart'; + +/// Verifies the anti-renew-storm guarantees of the SessionNode tree: +/// 1. Concurrent renew() calls share ONE in-flight renew (single-flight). +/// 2. renewIfNeeded(beforeEpoch) skips when a concurrent renew already +/// advanced the epoch — the caller re-reads fresh cookies instead. +/// 3. withCookie retries a 401 exactly once with the fresh cookie, and two +/// concurrent withCookie callers share a single renew. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _CountingClient client; + late LoggingHttpClient httpClient; + late SessionTree tree; + + /// Seed an egate account so the cpdaily node is available. The renew + /// endpoint (/auth/renew) is mocked to rotate tgc + cookies each call. + ThirdPartyAccount seedAccount({String tgc = 'tgc-v0'}) => + ThirdPartyAccount( + platform: ThirdPartyPlatform.egate, + account: 'student', + sid: '2024xxxx', + name: 'Test', + token: 'tok', + raw: { + 'sessionToken': 'st-v0', + 'tgc': tgc, + 'userId': 'u1', + 'tenantId': 't1', + 'cookies': 'JSESSIONID=js-v0', + }, + boundAt: DateTime.now(), + ); + + setUp(() { + client = _CountingClient(); + final logger = DebugLogger(); + httpClient = LoggingHttpClient(logger, inner: client); + tree = SessionTree( + persist: (_) async {}, + http: httpClient, + baseUrl: () => 'https://backend.test', + ); + tree.egate.setAccount(seedAccount()); + }); + + test('concurrent renew() calls share one in-flight POST (single-flight)', + () async { + // Three concurrent renews — must collapse to ONE /auth/renew call. + final results = await Future.wait([ + tree.egate.renew(), + tree.egate.renew(), + tree.egate.renew(), + ]); + expect(results, [true, true, true]); + expect(client.renewCalls, 1); + // Cookie advanced to v1. + expect(tree.egate.cookieProvider!.cookies, contains('CASTGC=tgc-v1')); + }); + + test('renewIfNeeded skips when epoch already advanced by a concurrent renew', + () async { + final epochBefore = tree.egate.epoch; + // Fire a renew in the background (don't await yet). + final pending = tree.egate.renew(); + // While it's in-flight, renewIfNeeded(epochBefore) should join the same + // in-flight renew rather than starting a second one. + final second = tree.egate.renewIfNeeded(epochBefore); + await Future.wait([pending, second]); + expect(client.renewCalls, 1); + }); + + test('renewIfNeeded does NOT skip when epoch is still current', () async { + final epochBefore = tree.egate.epoch; + // No concurrent renew — renewIfNeeded must actually run. + final ok = await tree.egate.renewIfNeeded(epochBefore); + expect(ok, true); + expect(client.renewCalls, 1); + expect(tree.egate.epoch, greaterThan(epochBefore)); + }); + + test('withCookie retries 401 once with refreshed cookie', () async { + // First request to /fetch returns 401; retry (after renew) returns 200. + client.fetchStatuses = [401, 200]; + client.fetchBodies = ['{}', jsonEncode({'ok': true})]; + + final resp = await tree.withCookie( + tree.egate, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + expect(resp, isNotNull); + expect(resp!.statusCode, 200); + expect(client.renewCalls, 1); + expect(client.fetchCalls, 2); + }); + + test('two concurrent withCookie callers share ONE renew on simultaneous 401', + () async { + // Both callers get 401 on first attempt, 200 after renew. + client.fetchStatuses = [401, 401, 200, 200]; + client.fetchBodies = ['{}', '{}', '{"a":1}', '{"b":2}']; + + + final results = await Future.wait([ + tree.withCookie( + tree.egate, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ), + tree.withCookie( + tree.egate, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ), + ]); + + // Exactly one renew across both concurrent 401-retry cycles. + expect(client.renewCalls, 1); + expect( + results.every((r) => r != null && r.statusCode == 200), + true, + reason: 'both callers should succeed after the shared renew', + ); + }); + + test('ids node cookieProvider falls back to parent cpdaily cookies', () { + final cp = tree.ids.cookieProvider; + expect(cp, isNotNull); + // Falls back to parent's CASTGC-bearing cookie set. + expect(cp!.cookies, contains('CASTGC=tgc-v0')); + }); + + test('ids renew delegates to parent cpdaily renew (single-flight at parent)', + () async { + final ok = await tree.ids.renew(); + expect(ok, true); + expect(client.renewCalls, 1); + expect(tree.egate.cookieProvider!.cookies, contains('CASTGC=tgc-v1')); + }); +} + +/// Mock HTTP client that counts /auth/renew calls and serves a configurable +/// sequence of statuses/bodies for other paths. The renew response rotates +/// tgc + cookies by appending a version suffix so each renew is observable. +class _CountingClient extends http.BaseClient { + int renewCalls = 0; + int fetchCalls = 0; + List fetchStatuses = const []; + List fetchBodies = const []; + int _renewVersion = 0; + + @override + Future send(http.BaseRequest request) async { + final url = request.url.toString(); + + if (url.endsWith('/auth/renew')) { + renewCalls++; + _renewVersion++; + final resp = jsonEncode({ + 'success': true, + 'sessionToken': 'st-v$_renewVersion', + 'tgc': 'tgc-v$_renewVersion', + 'userId': 'u1', + 'tenantId': 't1', + 'cookies': 'JSESSIONID=js-v$_renewVersion', + }); + return _resp(resp, 200); + } + + // Other paths (fetch) consume from the configured sequences. + fetchCalls++; + final idx = fetchCalls - 1; + final status = idx < fetchStatuses.length ? fetchStatuses[idx] : 200; + final respBody = idx < fetchBodies.length ? fetchBodies[idx] : '{}'; + return _resp(respBody, status); + } + + http.StreamedResponse _resp(String body, int status) { + final bytes = utf8.encode(body); + return http.StreamedResponse( + http.ByteStream.fromBytes(bytes), + status, + request: null, + headers: {'content-type': 'application/json'}, + ); + } +} From bf2bcf1c990bb6a1679ea7059add53490ff9330d Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 06:06:39 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20unify=20SessionNode=20tree=20?= =?UTF-8?q?=E2=80=94=20egate=E2=86=92cpdaily=20+=20eams/elearning=20downst?= =?UTF-8?q?ream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapse CpdailySessionNode/IdsSessionNode/LeafSessionNode into single SessionNode class with RenewMode enum (cpdailySession|password|parentCookie) - 5-node topology: cpdaily (top, /auth/renew) → eams + elearning children (/auth/third-party/, parent tgc); gradescope + hydro (top, password) - withCookie two-level retry: initial minting → 401 renew-retry → parent renew + child re-mint (only on credential 401, not server 500) - markRenewed cascades to children (clears derived cookies) - enum ThirdPartyPlatform.egate→cpdaily, fromId('egate') alias, apiPath getter (cpdaily→'egate' backend route unchanged) - Storage migrates legacy third_party_egate key → third_party_cpdaily - Facade: egateNode→cpdailyNode, idsNode deleted, eamsNode/elearningNode/ gradescopeNode/hydroNode added; all egate* accessors renamed cpdaily* - Callers: schedule→eamsNode, blackboard→elearningNode, gradescope/hydro→ dedicated nodes via withCookie; home_page idsNode→cpdailyNode - DebugLogger.log() debugPrints [HTTP] method url status tag in debug mode - Tests: session_storm rewrite (eams/elearning downstream + two-level retry + 500-no-escalation), all platform refs .egate→.cpdaily, fromId alias --- lib/main.dart | 2 +- lib/models/third_party_account.dart | 17 +- lib/pages/home_page.dart | 9 +- lib/pages/oa_gym_page.dart | 14 +- lib/pages/schedule_page.dart | 2 +- lib/pages/settings_page.dart | 14 +- lib/pages/third_party_accounts_page.dart | 2 +- lib/pages/third_party_bind_page.dart | 68 ++-- lib/services/assignment_service.dart | 72 +++-- lib/services/debug_logger.dart | 27 +- lib/services/oa_gym_service.dart | 22 +- lib/services/schedule_service.dart | 21 +- lib/services/session/session_node.dart | 356 +++++++++++++++++++-- lib/services/session/session_nodes.dart | 274 ---------------- lib/services/session/session_tree.dart | 152 ++++++--- lib/services/storage_service.dart | 21 +- lib/services/third_party_auth_service.dart | 107 ++++--- test/assignment_service_test.dart | 33 +- test/oa_gym_service_test.dart | 2 +- test/session_storm_test.dart | 200 ++++++++++-- test/sync_crypto_test.dart | 8 +- test/sync_service_test.dart | 4 +- 22 files changed, 893 insertions(+), 534 deletions(-) delete mode 100644 lib/services/session/session_nodes.dart diff --git a/lib/main.dart b/lib/main.dart index 0065bca..72d5d49 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -166,7 +166,7 @@ Future _realMain(SharedPreferences prefs) async { ); } - if (thirdPartyAuthService.hasEgateBinding) { + if (thirdPartyAuthService.hasCpdailyBinding) { await scheduleService.fetchAll(); } if (authService.isLoggedIn || diff --git a/lib/models/third_party_account.dart b/lib/models/third_party_account.dart index 90f8ad2..b7247df 100644 --- a/lib/models/third_party_account.dart +++ b/lib/models/third_party_account.dart @@ -1,16 +1,28 @@ enum ThirdPartyPlatform { gradescope, hydro, - egate; + cpdaily; + /// Stable id used for storage keys and cloud-sync payloads. Note: the + /// backend bind route for cpdaily is still 'egate' (unrenamed); see + /// [apiPath]. String get id => name; String get label => switch (this) { ThirdPartyPlatform.gradescope => 'Gradescope', ThirdPartyPlatform.hydro => 'Hydro', - ThirdPartyPlatform.egate => 'eGate / IDS', + ThirdPartyPlatform.cpdaily => 'CpDaily / IDS', }; + /// Backend bind/renew route name. cpdaily maps to 'egate' (the backend + /// route was not renamed); all others map to their own [id]. + String get apiPath => switch (this) { + ThirdPartyPlatform.cpdaily => 'egate', + _ => id, + }; + + /// Parse a platform id, accepting the legacy 'egate' alias for cpdaily. static ThirdPartyPlatform? fromId(String id) { + if (id == 'egate') return ThirdPartyPlatform.cpdaily; for (final p in ThirdPartyPlatform.values) { if (p.id == id) return p; } @@ -18,6 +30,7 @@ enum ThirdPartyPlatform { } } + class ThirdPartyAccount { final ThirdPartyPlatform platform; final String account; diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index d5eef05..9d2408d 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -395,11 +395,10 @@ class _HomePageState extends State with TickerProviderStateMixin { final sp = ServiceProvider.of(context); // ecourse / student-leave / eams webviews all authenticate against the - // IDS SSO, whose cookies are exposed by the IDS session node (a child of - // the CpDaily node — it falls back to the parent CpDaily CASTGC-bearing - // cookie set when no dedicated IDS cookie set has been minted). Read it - // through the unified [CookieProvider] view so the source is abstracted. - final cp = sp.thirdPartyAuthService.idsNode.cookieProvider; + // CpDaily session, whose cookies are exposed by the cpdaily session node + // (the CASTGC-bearing cookie set that webviews consume directly). Read + // it through the unified [CookieProvider] view so the source is abstracted. + final cp = sp.thirdPartyAuthService.cpdailyNode.cookieProvider; if (cp == null || cp.isEmpty) return cookies; final domain = cp.domain.isNotEmpty diff --git a/lib/pages/oa_gym_page.dart b/lib/pages/oa_gym_page.dart index b9ed739..da30ff7 100644 --- a/lib/pages/oa_gym_page.dart +++ b/lib/pages/oa_gym_page.dart @@ -92,7 +92,7 @@ class _OaGymPageState extends State body: ListenableBuilder( listenable: Listenable.merge([auth, tpAuth]), builder: (context, _) { - final ready = auth.isLoggedIn && tpAuth.hasEgateBinding; + final ready = auth.isLoggedIn && tpAuth.hasCpdailyBinding; return ready ? Column( children: [ @@ -973,15 +973,15 @@ class _ProfileTabState extends State<_ProfileTab> { Widget build(BuildContext context) { final sp = ServiceProvider.of(context); final tpAuth = sp.thirdPartyAuthService; - final egate = tpAuth.egateBinding; + final cpdaily = tpAuth.cpdailyBinding; - // Identity card prefers eGate binding (real name + student id) over the + // Identity card prefers cpdaily binding (real name + student id) over the // primary SSO account, whose userName/userId are Casdoor UUIDs with no // meaning in the OA booking context. - final displayName = egate?.name?.isNotEmpty == true - ? egate!.name! - : (egate?.account.isNotEmpty == true ? egate!.account : 'TechPie 用户'); - final studentId = egate?.sid ?? ''; + final displayName = cpdaily?.name?.isNotEmpty == true + ? cpdaily!.name! + : (cpdaily?.account.isNotEmpty == true ? cpdaily!.account : 'TechPie 用户'); + final studentId = cpdaily?.sid ?? ''; final avatarText = displayName.characters.firstOrNull ?? 'U'; return ListView( diff --git a/lib/pages/schedule_page.dart b/lib/pages/schedule_page.dart index 88b1c2a..78785ab 100644 --- a/lib/pages/schedule_page.dart +++ b/lib/pages/schedule_page.dart @@ -729,7 +729,7 @@ class _SchedulePageState extends State { ? 0 : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top, ), - child: !auth.isLoggedIn || !tpAuth.hasEgateBinding + child: !auth.isLoggedIn || !tpAuth.hasCpdailyBinding ? Center( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index af94773..95ce26e 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -106,7 +106,7 @@ class _SettingsPageState extends State { ].join(' · '), ), ), - _EgateBindingTile(tpAuth: tpAuth), + _CpdailyBindingTile(tpAuth: tpAuth), ListTile( leading: const Icon(Icons.account_tree_outlined), title: const Text('Linked accounts'), @@ -558,15 +558,15 @@ class _AdaptiveSwitchTile extends StatelessWidget { } } -class _EgateBindingTile extends StatelessWidget { +class _CpdailyBindingTile extends StatelessWidget { final ThirdPartyAuthService tpAuth; - const _EgateBindingTile({required this.tpAuth}); + const _CpdailyBindingTile({required this.tpAuth}); @override Widget build(BuildContext context) { - final egate = tpAuth.account(ThirdPartyPlatform.egate); - final bound = egate != null; + final cpdaily = tpAuth.account(ThirdPartyPlatform.cpdaily); + final bound = cpdaily != null; final theme = Theme.of(context); return ListTile( @@ -574,10 +574,10 @@ class _EgateBindingTile extends StatelessWidget { bound ? Icons.vpn_key : Icons.vpn_key_outlined, color: bound ? theme.colorScheme.primary : null, ), - title: const Text('eGate / IDS'), + title: const Text('CpDaily / IDS'), subtitle: Text( bound - ? '已绑定 · ${egate.name ?? egate.sid ?? egate.account}' + ? '已绑定 · ${cpdaily.name ?? cpdaily.sid ?? cpdaily.account}' : '未绑定 · 需要绑定以启用课表和考试功能', ), trailing: bound diff --git a/lib/pages/third_party_accounts_page.dart b/lib/pages/third_party_accounts_page.dart index d75ed1d..ee83e4f 100644 --- a/lib/pages/third_party_accounts_page.dart +++ b/lib/pages/third_party_accounts_page.dart @@ -120,7 +120,7 @@ class _ThirdPartyTile extends StatelessWidget { IconData get _icon => switch (platform) { ThirdPartyPlatform.gradescope => Icons.grading_outlined, ThirdPartyPlatform.hydro => Icons.terminal_outlined, - ThirdPartyPlatform.egate => Icons.vpn_key_outlined, + ThirdPartyPlatform.cpdaily => Icons.vpn_key_outlined, }; @override diff --git a/lib/pages/third_party_bind_page.dart b/lib/pages/third_party_bind_page.dart index eb8c22a..c409ce2 100644 --- a/lib/pages/third_party_bind_page.dart +++ b/lib/pages/third_party_bind_page.dart @@ -39,17 +39,17 @@ class _ThirdPartyBindPageState extends State { bool _autoRenew = false; String? _inlineError; - // eGate SMS state - int _egateLoginMethod = 0; // 0 = password, 1 = SMS - final _egatePhoneCtrl = TextEditingController(); - final _egateCodeCtrl = TextEditingController(); + // cpdaily SMS state + int _cpdailyLoginMethod = 0; // 0 = password, 1 = SMS + final _cpdailyPhoneCtrl = TextEditingController(); + final _cpdailyCodeCtrl = TextEditingController(); bool _sendingSms = false; int _smsCooldown = 0; Timer? _smsCooldownTimer; bool get _isHydro => widget.platform == ThirdPartyPlatform.hydro; bool get _isGradescope => widget.platform == ThirdPartyPlatform.gradescope; - bool get _isEgate => widget.platform == ThirdPartyPlatform.egate; + bool get _isCpdaily => widget.platform == ThirdPartyPlatform.cpdaily; Future _dismissKeyboard() async { FocusManager.instance.primaryFocus?.unfocus(); @@ -64,8 +64,8 @@ class _ThirdPartyBindPageState extends State { _passwordCtrl.dispose(); _hydroOriginCtrl.dispose(); _hydroDomainsCtrl.dispose(); - _egatePhoneCtrl.dispose(); - _egateCodeCtrl.dispose(); + _cpdailyPhoneCtrl.dispose(); + _cpdailyCodeCtrl.dispose(); _smsCooldownTimer?.cancel(); super.dispose(); } @@ -104,17 +104,17 @@ class _ThirdPartyBindPageState extends State { setState(() => _autoRenew = ok == true); } - // -- eGate SMS methods -- + // -- cpdaily SMS methods -- - Future _sendEgateSms() async { - final phone = _egatePhoneCtrl.text.trim(); + Future _sendCpdailySms() async { + final phone = _cpdailyPhoneCtrl.text.trim(); if (phone.isEmpty) return; setState(() => _sendingSms = true); try { await ServiceProvider.of(context) .thirdPartyAuthService - .sendEgateSmsCode(phone); + .sendCpdailySmsCode(phone); if (mounted) { setState(() => _inlineError = null); _smsCooldown = 60; @@ -159,11 +159,11 @@ class _ThirdPartyBindPageState extends State { } try { - if (_isEgate && _egateLoginMethod == 1) { + if (_isCpdaily && _cpdailyLoginMethod == 1) { // SMS mode - await tpAuth.bindEgateSms( - phone: _egatePhoneCtrl.text.trim(), - code: _egateCodeCtrl.text.trim(), + await tpAuth.bindCpdailySms( + phone: _cpdailyPhoneCtrl.text.trim(), + code: _cpdailyCodeCtrl.text.trim(), ); } else { await tpAuth.bind( @@ -215,12 +215,12 @@ class _ThirdPartyBindPageState extends State { } bool _validateForSubmit() { - if (_isEgate && _egateLoginMethod == 1) { + if (_isCpdaily && _cpdailyLoginMethod == 1) { // SMS mode validation String? message; - if (_egatePhoneCtrl.text.trim().isEmpty) { + if (_cpdailyPhoneCtrl.text.trim().isEmpty) { message = '请填写手机号码'; - } else if (_egateCodeCtrl.text.trim().isEmpty) { + } else if (_cpdailyCodeCtrl.text.trim().isEmpty) { message = '请填写验证码'; } setState(() => _inlineError = message); @@ -277,19 +277,19 @@ class _ThirdPartyBindPageState extends State { _InlineBindFeedback(message: _inlineError!), const SizedBox(height: 12), ], - if (_isEgate) ...[ - // eGate: tabbed password / SMS interface + if (_isCpdaily) ...[ + // cpdaily: tabbed password / SMS interface SegmentedButton( segments: const [ ButtonSegment(value: 0, label: Text('密码登录')), ButtonSegment(value: 1, label: Text('短信登录')), ], - selected: {_egateLoginMethod}, + selected: {_cpdailyLoginMethod}, onSelectionChanged: (v) => - setState(() => _egateLoginMethod = v.first), + setState(() => _cpdailyLoginMethod = v.first), ), const SizedBox(height: 16), - if (_egateLoginMethod == 0) ...[ + if (_cpdailyLoginMethod == 0) ...[ TextFormField( controller: _accountCtrl, decoration: const InputDecoration( @@ -317,7 +317,7 @@ class _ThirdPartyBindPageState extends State { ), ] else ...[ TextFormField( - controller: _egatePhoneCtrl, + controller: _cpdailyPhoneCtrl, keyboardType: TextInputType.phone, decoration: const InputDecoration( labelText: '手机号码', @@ -331,7 +331,7 @@ class _ThirdPartyBindPageState extends State { children: [ Expanded( child: TextFormField( - controller: _egateCodeCtrl, + controller: _cpdailyCodeCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '验证码', @@ -345,7 +345,7 @@ class _ThirdPartyBindPageState extends State { FilledButton.tonal( onPressed: (_smsCooldown > 0 || _sendingSms) ? null - : () => unawaited(_sendEgateSms()), + : () => unawaited(_sendCpdailySms()), style: FilledButton.styleFrom( minimumSize: const Size(100, 56), ), @@ -504,19 +504,19 @@ class _ThirdPartyBindPageState extends State { _InlineBindFeedback(message: _inlineError!), const SizedBox(height: 16), ], - if (_isEgate) ...[ + if (_isCpdaily) ...[ IosNativeSegmentedControl( - value: _egateLoginMethod, + value: _cpdailyLoginMethod, segments: const ['密码登录', '短信登录'], onChanged: (value) { setState(() { - _egateLoginMethod = value; + _cpdailyLoginMethod = value; _inlineError = null; }); }, ), const SizedBox(height: 18), - if (_egateLoginMethod == 0) + if (_cpdailyLoginMethod == 0) IosNativeTextFieldGroup( items: [ IosNativeTextFieldGroupItem( @@ -538,7 +538,7 @@ class _ThirdPartyBindPageState extends State { items: [ IosNativeTextFieldGroupItem( placeholder: '手机号码', - controller: _egatePhoneCtrl, + controller: _cpdailyPhoneCtrl, keyboardType: TextInputType.phone, textInputAction: TextInputAction.next, ), @@ -552,7 +552,7 @@ class _ThirdPartyBindPageState extends State { items: [ IosNativeTextFieldGroupItem( placeholder: '验证码', - controller: _egateCodeCtrl, + controller: _cpdailyCodeCtrl, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, onSubmitted: (_) => unawaited(_submit()), @@ -564,7 +564,7 @@ class _ThirdPartyBindPageState extends State { FilledButton.tonal( onPressed: (_smsCooldown > 0 || _sendingSms) ? null - : () => unawaited(_sendEgateSms()), + : () => unawaited(_sendCpdailySms()), style: FilledButton.styleFrom( minimumSize: const Size(100, 56), ), @@ -639,7 +639,7 @@ class _ThirdPartyBindPageState extends State { ), ), ], - if (!_isEgate || _egateLoginMethod == 0) ...[ + if (!_isCpdaily || _cpdailyLoginMethod == 0) ...[ const SizedBox(height: 16), MergeSemantics( child: ListTile( diff --git a/lib/services/assignment_service.dart b/lib/services/assignment_service.dart index a007b12..9bbe384 100644 --- a/lib/services/assignment_service.dart +++ b/lib/services/assignment_service.dart @@ -181,7 +181,7 @@ class AssignmentService extends ChangeNotifier { final futures = >[]; - if (_tpAuth.hasEgateBinding) { + if (_tpAuth.hasCpdailyBinding) { futures.add( _fetchBlackboard().then((items) { if (items != null) successfulResults['blackboard'] = items; @@ -210,8 +210,8 @@ class AssignmentService extends ChangeNotifier { }), ); break; - case ThirdPartyPlatform.egate: - // eGate provides CpDaily session, not deadline data — skip. + case ThirdPartyPlatform.cpdaily: + // cpdaily provides the CpDaily session, not deadline data — skip. break; } } @@ -258,11 +258,11 @@ class AssignmentService extends ChangeNotifier { final successfulResults = >{}; Future? future; - if (platformId == 'blackboard' && _tpAuth.hasEgateBinding) { + if (platformId == 'blackboard' && _tpAuth.hasCpdailyBinding) { future = _fetchBlackboard().then((items) { if (items != null) successfulResults['blackboard'] = items; }); - } else if (platformId == 'exam' && _tpAuth.hasEgateBinding) { + } else if (platformId == 'exam' && _tpAuth.hasCpdailyBinding) { future = _fetchExamTable().then((items) { if (items != null) successfulResults['exam'] = items; }); @@ -304,18 +304,21 @@ class AssignmentService extends ChangeNotifier { } Future?> _fetchBlackboard() async { - final node = _tpAuth.egateNode; - if (!node.isAvailable) return null; + final node = _tpAuth.elearningNode; + // withCookie handles initial minting if the downstream cookie isn't set. + if (!_tpAuth.hasCpdailyBinding) return null; try { final resp = await _tpAuth.sessionTree.withCookie( node, (cp) async { - final tgc = (node.rawFields['tgc'] as String?) ?? ''; + // cp.cookies is the elearning cookie string minted from the + // cpdaily CASTGC. The backend uses it directly to query + // Blackboard (no SSO bounce needed). final r = await _http.post( Uri.parse('$_baseUrl/deadlines/blackboard'), headers: _jsonHeaders(), - body: jsonEncode({'token': tgc}), + body: jsonEncode({'token': cp.cookies}), tag: 'deadlines:blackboard', ); return CookieAction(r, expired: r.statusCode == 401); @@ -331,8 +334,8 @@ class AssignmentService extends ChangeNotifier { Future?> _fetchExamTable() async { final semesterId = _selectedSemesterId(); - final node = _tpAuth.egateNode; - if (!node.isAvailable || + final node = _tpAuth.eamsNode; + if (!_tpAuth.hasCpdailyBinding || semesterId == null || semesterId.isEmpty) { return null; @@ -363,13 +366,23 @@ class AssignmentService extends ChangeNotifier { } Future?> _fetchGradescope(ThirdPartyAccount acc) async { + final node = _tpAuth.gradescopeNode; + if (!node.isAvailable) return null; try { - final resp = await _http.post( - Uri.parse('$_baseUrl/deadlines/gradescope'), - headers: _jsonHeaders(), - body: jsonEncode({'token': acc.token}), - tag: 'deadlines:gradescope', + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + // cp.cookies is the gradescope bearer token. + final r = await _http.post( + Uri.parse('$_baseUrl/deadlines/gradescope'), + headers: _jsonHeaders(), + body: jsonEncode({'token': cp.cookies}), + tag: 'deadlines:gradescope', + ); + return CookieAction(r, expired: r.statusCode == 401); + }, ); + if (resp == null) return null; if (resp.statusCode == 401) { await _tpAuth.unbind(ThirdPartyPlatform.gradescope); _platformErrors['gradescope'] = 'token 已失效,请重新绑定'; @@ -390,20 +403,31 @@ class AssignmentService extends ChangeNotifier { return null; } + final node = _tpAuth.hydroNode; + if (!node.isAvailable) return null; + final all = []; var hadError = false; for (final domain in domains) { final url = '${origin.replaceAll(RegExp(r'/+$'), '')}/d/$domain'; try { - final resp = await _http.post( - Uri.parse('$_baseUrl/deadlines/hydro'), - headers: _jsonHeaders(), - body: jsonEncode({ - 'token': acc.token, - 'args': {'url': url}, - }), - tag: 'deadlines:hydro:$domain', + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + // cp.cookies is the hydro sid cookie. + final r = await _http.post( + Uri.parse('$_baseUrl/deadlines/hydro'), + headers: _jsonHeaders(), + body: jsonEncode({ + 'token': cp.cookies, + 'args': {'url': url}, + }), + tag: 'deadlines:hydro:$domain', + ); + return CookieAction(r, expired: r.statusCode == 401); + }, ); + if (resp == null) return null; if (resp.statusCode == 401) { await _tpAuth.unbind(ThirdPartyPlatform.hydro); _platformErrors['hydro'] = 'token 已失效,请重新绑定'; diff --git a/lib/services/debug_logger.dart b/lib/services/debug_logger.dart index c508283..228e39d 100644 --- a/lib/services/debug_logger.dart +++ b/lib/services/debug_logger.dart @@ -51,19 +51,24 @@ class DebugLogger extends ChangeNotifier { if (_entries.length >= _maxEntries) { _entries.removeAt(0); } - _entries.add( - LogEntry( - timestamp: DateTime.now(), - method: method, - url: url, - statusCode: statusCode, - requestBody: redactSensitive(requestBody), - responseBody: redactSensitive(responseBody), - error: error, - tag: tag, - ), + final entry = LogEntry( + timestamp: DateTime.now(), + method: method, + url: url, + statusCode: statusCode, + requestBody: redactSensitive(requestBody), + responseBody: redactSensitive(responseBody), + error: error, + tag: tag, ); + _entries.add(entry); notifyListeners(); + if (kDebugMode) { + debugPrint( + '[HTTP] ${entry.method} ${entry.url} ' + '${entry.statusCode ?? '—'} ${entry.tag ?? ''}', + ); + } } static const _sensitiveKeys = { diff --git a/lib/services/oa_gym_service.dart b/lib/services/oa_gym_service.dart index 2a322ce..9c9256b 100644 --- a/lib/services/oa_gym_service.dart +++ b/lib/services/oa_gym_service.dart @@ -58,14 +58,14 @@ class OaGymService extends ChangeNotifier { OaBookingProfile bookingProfile() { final saved = _storage.loadOaBookingProfile(); - // Fall back to the eGate binding's real name (not the primary SSO + // Fall back to the cpdaily binding's real name (not the primary SSO // account, whose userName is a Casdoor UUID). Phone is not available - // from eGate, so the user must still fill it in manually. - final egateName = _tpAuth.egateBinding?.name; + // from cpdaily, so the user must still fill it in manually. + final cpdailyName = _tpAuth.cpdailyBinding?.name; return saved.copyWith( name: saved.name.isNotEmpty ? saved.name - : (egateName?.isNotEmpty == true ? egateName! : ''), + : (cpdailyName?.isNotEmpty == true ? cpdailyName! : ''), phone: saved.phone.isNotEmpty ? saved.phone : '', ); } @@ -126,7 +126,7 @@ class OaGymService extends ChangeNotifier { }) async { final auth = _requireAuth(); final profile = bookingProfile(); - final studentId = _tpAuth.egateStudentId; + final studentId = _tpAuth.cpdailyStudentId; final userName = profile.name.isNotEmpty ? profile.name : (auth.session?.userName ?? ''); final phone = profile.phone.isNotEmpty @@ -240,25 +240,25 @@ class OaGymService extends ChangeNotifier { } /// Guard for any gym call: requires a logged-in primary account AND a - /// bound eGate account (the source of the CpDaily/CASTGC session the OA + /// bound cpdaily account (the source of the CpDaily/CASTGC session the OA /// system authenticates against). Returns the AuthService so callers can /// also read identity fields (name/phone) from the primary session. AuthService _requireAuth() { if (!_auth.isLoggedIn) { throw OaGymException('请先登录 TechPie 主账号'); } - if (!_tpAuth.hasEgateBinding) { + if (!_tpAuth.hasCpdailyBinding) { throw OaGymException('场馆预约需要绑定 eGate 账号,请在「第三方账号」中绑定'); } return _auth; } /// CpDaily auth payload built from a [CookieProvider] snapshot plus the - /// egate node's raw session fields (tgc/sessionToken/userId/tenantId). The + /// cpdaily node's raw session fields (tgc/sessionToken/userId/tenantId). The /// cookie + epoch captured at request time drive the storm-safe renew-retry /// in [_postJson]. Map _authPayload(CookieProvider cp) { - final raw = _tpAuth.egateNode.rawFields; + final raw = _tpAuth.cpdailyNode.rawFields; return { 'tgc': (raw['tgc'] as String?) ?? '', 'cookies': cp.cookies, @@ -269,14 +269,14 @@ class OaGymService extends ChangeNotifier { } /// POST `$_baseUrl/[path]` with CpDaily auth + [extra] body fields. On 401 - /// the egate node is renewed exactly once (single-flighted across all + /// the cpdaily node is renewed exactly once (single-flighted across all /// concurrent callers) and the request retried with the fresh cookie. Future> _postJson( String path, Map extra, ) async { _requireAuth(); - final node = _tpAuth.egateNode; + final node = _tpAuth.cpdailyNode; final response = await _tpAuth.sessionTree.withCookie( node, (cp) async { diff --git a/lib/services/schedule_service.dart b/lib/services/schedule_service.dart index bc126b8..45d70c8 100644 --- a/lib/services/schedule_service.dart +++ b/lib/services/schedule_service.dart @@ -50,11 +50,12 @@ class ScheduleService extends ChangeNotifier { /// time. The epoch captured alongside is what makes the renew-retry /// storm-safe (see [_postWithRetry]). Map _authBody(CookieProvider cp) => { - 'studentId': cp.studentId, + // eams downstream cookie; studentId comes from the cpdaily binding. + 'studentId': _tpAuth.cpdailyNode.account?.sid ?? '', 'cookies': cp.cookies, }; - bool get _hasEgateBinding => _tpAuth.egateNode.isAvailable; + bool get _hasCpdailyBinding => _tpAuth.cpdailyNode.isAvailable; Future loadCachedData() async { _semesterInfo = _storage.loadSemesters(); @@ -69,7 +70,7 @@ class ScheduleService extends ChangeNotifier { Future fetchAll() async { if (_loading) return; // 避免启动时并发重复调用 - if (!_hasEgateBinding) return; + if (!_hasCpdailyBinding) return; _loading = true; _error = null; notifyListeners(); @@ -191,7 +192,7 @@ class ScheduleService extends ChangeNotifier { _termBegin = _storage.loadTermBegin(semesterId); notifyListeners(); - if (!_hasEgateBinding) return; + if (!_hasCpdailyBinding) return; _loading = true; _error = null; @@ -210,16 +211,18 @@ class ScheduleService extends ChangeNotifier { } } - /// POST [url] with CpDaily auth + [extra] body fields. On 401 the egate + /// POST [url] with CpDaily auth + [extra] body fields. On 401 the eams /// node is renewed exactly once (single-flighted across all concurrent - /// callers) and the request retried with the fresh cookie. Throws on any - /// non-200 after the retry budget is exhausted. + /// callers) and the request retried with the fresh cookie. For a stale + /// parent tgc, [SessionTree.withCookie] falls back to renewing the + /// cpdaily parent then re-minting the eams cookie. Throws on any non-200 + /// after the retry budget is exhausted. Future _postWithRetry( String url, Map extra, String tag, ) async { - final node = _tpAuth.egateNode; + final node = _tpAuth.eamsNode; final resp = await _tpAuth.sessionTree.withCookie( node, (cp) async { @@ -237,7 +240,7 @@ class ScheduleService extends ChangeNotifier { }, ); if (resp == null) { - throw Exception('eGate session unavailable'); + throw Exception('cpdaily session unavailable'); } if (resp.statusCode != 200) { throw Exception('Request failed with status ${resp.statusCode}'); diff --git a/lib/services/session/session_node.dart b/lib/services/session/session_node.dart index 98cfb88..f1d2bd7 100644 --- a/lib/services/session/session_node.dart +++ b/lib/services/session/session_node.dart @@ -1,23 +1,56 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; +import '../../models/third_party_account.dart'; +import '../http_client.dart'; import 'cookie_provider.dart'; +/// Callback the facade ([ThirdPartyAuthService]) installs so a top-level +/// node can persist a refreshed [ThirdPartyAccount] back into secure storage +/// and fire the external change notification (listeners + cloud-sync push +/// hook) in one place. Returns nothing; the node owns the in-memory account +/// afterwards. Only meaningful for top-level nodes (renewMode cpdailySession +/// or password); non-top-level nodes skip persist (derived cookies are +/// ephemeral). +typedef PersistAccount = Future Function(ThirdPartyAccount updated); + +/// Callback to read the current API base URL (depends on storage settings). +typedef BaseUrlGetter = String Function(); + +/// How a [SessionNode] renews its credentials. +enum RenewMode { + /// CpDaily session keep-alive: POST /auth/renew with stored + /// sessionToken/tgc/userId/tenantId. Top-level only. + cpdailySession, + + /// Password re-authentication: POST /auth/third-party/ with + /// account/password. Top-level only (gradescope, hydro). + password, + + /// Downstream cookie minting: POST /auth/third-party/ with the parent + /// node's tgc. Non-top-level only (eams, elearning). + parentCookie, +} + /// One node in the unified session tree. /// -/// Topology (master branch): +/// Topology: /// ``` /// SessionTree -/// ├── gradescope : LeafSessionNode (token-only, no cookies) -/// ├── hydro : LeafSessionNode (token-only, no cookies) -/// └── egate : CpdailySessionNode (CASTGC + CpDaily session) -/// └── ids : IdsSessionNode (IDS cookies refreshed via parent) +/// ├── cpdaily (top-level, account+password/SMS bind, /auth/renew) +/// │ ├── eams (child, /auth/third-party/eams, parent tgc) +/// │ └── elearning (child, /auth/third-party/elearning, parent tgc) +/// ├── gradescope (top-level, account+password bind, bearer token) +/// └── hydro (top-level, account+password bind, sid cookie) /// ``` /// /// Every node exposes: -/// - [cookieProvider] — the [CookieProvider] view downstream apps read. Null -/// for leaf nodes that authenticate via bearer tokens server-side. +/// - [cookieProvider] — the [CookieProvider] view downstream apps read. For +/// top-level nodes the credential comes from the bound account (cpdaily: +/// CASTGC+cookies; gradescope/hydro: bearer token). For non-top-level +/// nodes it comes from the derived downstream cookie string. /// - [renew()] — refresh this node's credentials. Single-flighted: concurrent /// callers share ONE in-flight renew and observe the same result. /// - [epoch] — monotonically increasing, bumped on every successful renew. @@ -25,21 +58,69 @@ import 'cookie_provider.dart'; /// [renewIfNeeded] so a concurrent renew that already refreshed the cookie /// is NOT re-triggered (anti-renew-storm). /// -/// The 401-renew-retry pattern lives in [SessionTree.withCookie] so individual -/// services stop hand-rolling it. -abstract class SessionNode extends ChangeNotifier { - SessionNode({required this.id}); +/// The 401-renew-retry pattern (with two-level parent fallback for child +/// nodes) lives in [SessionTree.withCookie]. +class SessionNode extends ChangeNotifier { + SessionNode({ + required this.id, + required this.persist, + required this.http, + required this.baseUrl, + this.parent, + this.renewPath, + this.renewMode, + this.apiPath, + }); - /// Stable identifier (matches storage key / platform id). + /// Stable identifier (matches storage key / platform id, except cpdaily + /// whose storage id is 'cpdaily' but whose backend bind route is 'egate'). final String id; /// Optional parent — set when this node's session is derived from another - /// (e.g. IDS cookies are minted from the CpDaily session). Null for roots. + /// (e.g. eams/elearning cookies are minted from the cpdaily CASTGC). Null + /// for roots. SessionNode? parent; + /// Full backend renew path (e.g. '/auth/renew', '/auth/third-party/eams'). + final String? renewPath; + + /// How this node renews. Null only for nodes that never renew. + final RenewMode? renewMode; + + /// Backend bind route name. For cpdaily this is 'egate' (the backend route + /// was not renamed); for gradescope/hydro it equals [id]. Null for + /// non-top-level nodes. + final String? apiPath; + + final PersistAccount persist; + final LoggingHttpClient http; + final BaseUrlGetter baseUrl; + final List _children = []; List get children => List.unmodifiable(_children); + /// Top-level nodes hold the bound account; non-top-level nodes leave this + /// null (their credential is the ephemeral [_derivedCookie]). + ThirdPartyAccount? _account; + ThirdPartyAccount? get account => _account; + + /// Non-top-level nodes hold the downstream cookie string minted by the + /// last successful renew; top-level nodes leave this null. + String? _derivedCookie; + + /// Raw fields from the bound account (top-level only). Downstream services + /// (OA gym) and child nodes read tgc/sessionToken/userId/tenantId through + /// this. Returns an empty map for non-top-level nodes. + Map get rawFields => _account?.raw ?? const {}; + + /// Set the bound account. Only meaningful for top-level nodes; calling on + /// a non-top-level node is a no-op. + void setAccount(ThirdPartyAccount? acc) { + if (parent != null) return; // non-top-level: no account + _account = acc; + notifyListeners(); + } + void attachChild(SessionNode child) { child.parent = this; _children.add(child); @@ -50,34 +131,261 @@ abstract class SessionNode extends ChangeNotifier { _children.remove(child); } - /// The cookie view exposed to downstream consumers, or null when this node - /// authenticates via bearer tokens (Gradescope/Hydro) and has no cookies. - CookieProvider? get cookieProvider => null; + // -- Unified cookieProvider -- - /// True when this node has a usable session (bound + non-empty credentials). - bool get isAvailable; + /// The cookie view exposed to downstream consumers. For top-level nodes + /// the credential is extracted from the bound account; for non-top-level + /// nodes it is the derived downstream cookie. Null when no usable + /// credential is available. + CookieProvider? get cookieProvider { + if (parent == null) { + // Top-level + final acc = _account; + if (acc == null) return null; + final cookie = _cookieFromAccount(acc); + if (cookie.isEmpty) return null; + return CookieProvider( + cookies: cookie, + studentId: acc.sid ?? '', + domain: _domain, + ); + } + // Non-top-level: derived cookie + final c = _derivedCookie; + if (c == null || c.isEmpty) return null; + return CookieProvider(cookies: c, domain: _domain); + } + + /// Top-level credential extraction: cpdaily concatenates CASTGC onto the + /// session cookies; gradescope/hydro use the bearer token directly. + String _cookieFromAccount(ThirdPartyAccount acc) { + if (id == 'cpdaily') { + final base = (acc.raw['cookies'] as String?) ?? ''; + final tgc = (acc.raw['tgc'] as String?) ?? ''; + return tgc.isEmpty + ? base + : (base.isNotEmpty ? '$base; CASTGC=$tgc' : 'CASTGC=$tgc'); + } + return acc.token; + } + + String get _domain => switch (id) { + 'cpdaily' => 'ids.shanghaitech.edu.cn', + 'eams' => 'eams.shanghaitech.edu.cn', + 'elearning' => 'elearning.shanghaitech.edu.cn', + _ => '', + }; + + /// True when this node has a usable session. + bool get isAvailable { + if (parent == null) { + final cp = cookieProvider; + return _account != null && cp != null && !cp.isEmpty; + } + return (parent?.isAvailable ?? false) && + _derivedCookie != null && + _derivedCookie!.isNotEmpty; + } // -- Renewal: single-flight + stale-epoch skip -- int _epoch = 0; Future? _renewInFlight; + /// Whether the last [doRenew] failure was a credential-level error + /// (HTTP 401 from the renew endpoint), as opposed to a server error (500) + /// or network issue. [SessionTree.withCookie] uses this to decide whether + /// the two-level parent-renew fallback is worth attempting: a 500 from the + /// downstream endpoint won't be fixed by re-minting the parent tgc, so we + /// skip the escalation entirely. + bool _lastRenewWasCredentialError = false; + bool get lastRenewWasCredentialError => _lastRenewWasCredentialError; + /// Current epoch. Bumped after every successful [renew]. Callers capture /// this when reading cookies and pass it to [renewIfNeeded]. int get epoch => _epoch; - /// Bump epoch and notify. Called by subclasses after persisting refreshed - /// credentials into the wrapped account. + /// Bump epoch, cascade to children (clear their derived cookies), and + /// notify. Called after persisting refreshed credentials (top-level) or + /// minting a downstream cookie (non-top-level). @protected void markRenewed() { _epoch++; + // Cascade: parent renewed → children's derived cookies are now stale. + for (final child in _children) { + child.onParentRenewed(); + } notifyListeners(); } - /// Subclass-specific renew. MUST persist refreshed credentials and call - /// [markRenewed] on success. Returns true on success, false on failure. + /// Called by the parent's [markRenewed] when the parent's credentials + /// changed. Non-top-level nodes clear their derived cookie (it was minted + /// from the old parent credential and is now invalid); top-level nodes + /// are unaffected. @protected - Future doRenew(); + void onParentRenewed() { + if (parent != null) { + _derivedCookie = null; + notifyListeners(); + } + } + + /// Mode-specific renew. MUST persist refreshed credentials (top-level) or + /// mint the downstream cookie (non-top-level) and call [markRenewed] on + /// success. Returns true on success, false on failure. + Future doRenew() async { + switch (renewMode) { + case RenewMode.cpdailySession: + return _renewCpdailySession(); + case RenewMode.password: + return _renewWithPassword(); + case RenewMode.parentCookie: + return _renewWithParentCookie(); + case null: + return false; + } + } + + /// CpDaily keep-alive: POST /auth/renew with stored session fields. + Future _renewCpdailySession() async { + final acc = _account; + if (acc == null) return false; + try { + final resp = await http.post( + Uri.parse('${baseUrl()}${renewPath ?? '/auth/renew'}'), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode({ + 'sessionToken': acc.raw['sessionToken'] ?? '', + 'tgc': acc.raw['tgc'] ?? '', + 'userId': acc.raw['userId'] ?? '', + 'tenantId': acc.raw['tenantId'] ?? '', + }), + tag: 'cpdailyRenew', + ); + if (resp.statusCode != 200) return false; + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) return false; + + final newRaw = { + ...acc.raw, + 'sessionToken': + data['sessionToken'] as String? ?? acc.raw['sessionToken'] ?? '', + 'tgc': data['tgc'] as String? ?? acc.raw['tgc'] ?? '', + 'userId': data['userId'] as String? ?? acc.raw['userId'] ?? '', + 'tenantId': + data['tenantId'] as String? ?? acc.raw['tenantId'] ?? '', + 'cookies': data['cookies'] as String? ?? acc.raw['cookies'] ?? '', + }; + final updated = ThirdPartyAccount( + platform: acc.platform, + account: acc.account, + sid: acc.sid, + name: acc.name, + email: acc.email, + token: acc.token, + expire: acc.expire, + raw: newRaw, + hydroOrigin: acc.hydroOrigin, + hydroDomains: acc.hydroDomains, + boundAt: acc.boundAt, + autoRenew: acc.autoRenew, + password: acc.password, + ); + _account = updated; + await persist(updated); + markRenewed(); + return true; + } catch (_) { + return false; + } + } + + /// Password re-authentication (gradescope, hydro): POST the stored + /// account+password, receive a fresh token. + Future _renewWithPassword() async { + final acc = _account; + if (acc == null || !acc.autoRenew) return false; + final pw = acc.password; + if (pw == null || pw.isEmpty) return false; + try { + final body = { + 'account': acc.account, + 'password': pw, + }; + if (id == 'hydro' && + acc.hydroOrigin != null && + acc.hydroOrigin!.isNotEmpty) { + body['args'] = {'url': acc.hydroOrigin}; + } + final resp = await http.post( + Uri.parse('${baseUrl()}${renewPath ?? '/auth/third-party/$id'}'), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode(body), + tag: 'thirdPartyRenew:$id', + ); + if (resp.statusCode != 200) return false; + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) return false; + final d = (data['data'] as Map?)?.cast() ?? const {}; + final token = d['token'] as String?; + if (token == null || token.isEmpty) return false; + final renewed = ThirdPartyAccount( + platform: acc.platform, + account: acc.account, + sid: d['sid'] as String? ?? acc.sid, + name: d['name'] as String? ?? acc.name, + email: d['email'] as String? ?? acc.email, + token: token, + expire: (d['expire'] as num?)?.toInt() ?? acc.expire, + raw: (d['raw'] as Map?)?.cast() ?? acc.raw, + hydroOrigin: acc.hydroOrigin, + hydroDomains: acc.hydroDomains, + boundAt: acc.boundAt, + autoRenew: true, + password: pw, + ); + _account = renewed; + await persist(renewed); + markRenewed(); + return true; + } catch (_) { + return false; + } + } + + /// Downstream cookie minting (eams, elearning): POST the parent's tgc, + /// receive a downstream cookie string. + Future _renewWithParentCookie() async { + final tgc = parent?.rawFields['tgc'] as String? ?? ''; + if (tgc.isEmpty) { + _lastRenewWasCredentialError = true; + return false; + } + try { + final resp = await http.post( + Uri.parse('${baseUrl()}${renewPath ?? '/auth/third-party/$id'}'), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode({'tgc': tgc}), + tag: 'downstreamRenew:$id', + ); + // 401 → parent tgc is stale/invalid → credential error (worth + // escalating to parent renew). 5xx → server/transient failure → + // NOT a credential error (escalation won't help). + _lastRenewWasCredentialError = resp.statusCode == 401; + if (resp.statusCode != 200) return false; + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) return false; + final d = (data['data'] as Map?)?.cast() ?? const {}; + final cookie = d['token'] as String?; + if (cookie == null || cookie.isEmpty) return false; + _derivedCookie = cookie; + markRenewed(); + return true; + } catch (_) { + _lastRenewWasCredentialError = false; + return false; + } + } /// Refresh this node's credentials. Single-flighted: concurrent callers /// share one in-flight renew and observe the same result. @@ -105,5 +413,5 @@ abstract class SessionNode extends ChangeNotifier { } @override - String toString() => '$runtimeType($id)'; + String toString() => 'SessionNode($id)'; } diff --git a/lib/services/session/session_nodes.dart b/lib/services/session/session_nodes.dart deleted file mode 100644 index 68ef1c1..0000000 --- a/lib/services/session/session_nodes.dart +++ /dev/null @@ -1,274 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - - -import '../../models/third_party_account.dart'; -import '../http_client.dart'; -import 'cookie_provider.dart'; -import 'session_node.dart'; - -/// Callback the facade ([ThirdPartyAuthService]) installs so a node can -/// persist a refreshed [ThirdPartyAccount] back into secure storage and fire -/// the external change notification (listeners + cloud-sync push hook) in one -/// place. Returns nothing; the node owns the in-memory account afterwards. -typedef PersistAccount = Future Function(ThirdPartyAccount updated); - -/// Callback to read the current API base URL (depends on storage settings). -typedef BaseUrlGetter = String Function(); - -/// The CpDaily session node — the single source of CASTGC / CpDaily cookies. -/// -/// Wraps the `egate` [ThirdPartyAccount]. Renewal POSTs to `/auth/renew` with -/// the stored session token + tgc; the response rotates the CpDaily session. -/// On success the refreshed raw is persisted via [persist] and [markRenewed] -/// bumps the epoch so concurrent 401-retry callers skip their own renew. -/// -/// This node is the **parent** of [IdsSessionNode]: the IDS SSO cookies are -/// derived from (and refreshed via) this CpDaily session. -class CpdailySessionNode extends SessionNode { - CpdailySessionNode({ - required this.persist, - required this.http, - required this.baseUrl, - }) : super(id: 'egate'); - - final PersistAccount persist; - final LoggingHttpClient http; - final BaseUrlGetter baseUrl; - ThirdPartyAccount? _account; - - /// The wrapped account. Set by the facade on bind/unbind/replaceAll. - ThirdPartyAccount? get account => _account; - - void setAccount(ThirdPartyAccount? acc) { - _account = acc; - notifyListeners(); - } - - @override - bool get isAvailable => _account != null && cookieProvider != null; - - @override - CookieProvider? get cookieProvider { - final acc = _account; - if (acc == null) return null; - final raw = acc.raw; - final baseCookies = (raw['cookies'] as String?) ?? ''; - final tgc = (raw['tgc'] as String?) ?? ''; - final cookies = tgc.isEmpty - ? baseCookies - : (baseCookies.isNotEmpty - ? '$baseCookies; CASTGC=$tgc' - : 'CASTGC=$tgc'); - if (cookies.isEmpty) return null; - return CookieProvider( - cookies: cookies, - studentId: acc.sid ?? '', - domain: 'ids.shanghaitech.edu.cn', - ); - } - - /// Raw fields downstream services (OA gym) read alongside cookies. - Map get rawFields => _account?.raw ?? const {}; - - @override - Future doRenew() async { - final acc = _account; - if (acc == null) return false; - try { - final resp = await http.post( - Uri.parse('${baseUrl()}/auth/renew'), - headers: {'Content-Type': 'application/json; charset=UTF-8'}, - body: jsonEncode({ - 'sessionToken': acc.raw['sessionToken'] ?? '', - 'tgc': acc.raw['tgc'] ?? '', - 'userId': acc.raw['userId'] ?? '', - 'tenantId': acc.raw['tenantId'] ?? '', - }), - tag: 'egateCpDailyRenew', - ); - if (resp.statusCode != 200) return false; - final data = jsonDecode(resp.body) as Map; - if (data['success'] != true) return false; - - final newRaw = { - ...acc.raw, - 'sessionToken': - data['sessionToken'] as String? ?? acc.raw['sessionToken'] ?? '', - 'tgc': data['tgc'] as String? ?? acc.raw['tgc'] ?? '', - 'userId': data['userId'] as String? ?? acc.raw['userId'] ?? '', - 'tenantId': - data['tenantId'] as String? ?? acc.raw['tenantId'] ?? '', - 'cookies': data['cookies'] as String? ?? acc.raw['cookies'] ?? '', - }; - final updated = ThirdPartyAccount( - platform: acc.platform, - account: acc.account, - sid: acc.sid, - name: acc.name, - email: acc.email, - token: acc.token, - expire: acc.expire, - raw: newRaw, - hydroOrigin: acc.hydroOrigin, - hydroDomains: acc.hydroDomains, - boundAt: acc.boundAt, - autoRenew: acc.autoRenew, - password: acc.password, - ); - _account = updated; - await persist(updated); - markRenewed(); - return true; - } catch (_) { - return false; - } - } -} - -/// The IDS SSO cookie node — a **child** of [CpdailySessionNode]. -/// -/// IDS cookies are minted from the CpDaily session (CASTGC) via an SSO bounce. -/// Renewal walks up to the parent CpDaily session, renews *it* if stale, then -/// re-derives the IDS cookies. This is the "ids 走 ids cpdaily renew" path: -/// independent renew implementation that delegates the underlying session -/// refresh to the parent. -/// -/// The IDS cookies live in the same egate [ThirdPartyAccount.raw] under -/// `idsCookies` / `idsDomain`, so no separate storage entry is needed. -class IdsSessionNode extends SessionNode { - IdsSessionNode({required CpdailySessionNode cpdaily}) - : _cpdaily = cpdaily, - super(id: 'ids') { - _cpdaily.addListener(_onParentChanged); - } - - final CpdailySessionNode _cpdaily; - - void _onParentChanged() { - // Parent renewed → our derived IDS cookies may be stale; bump epoch so a - // pending caller's renewIfNeeded re-reads fresh state. We do NOT clear - // idsCookies here (they may still be valid); renew will overwrite them. - notifyListeners(); - } - - @override - bool get isAvailable => _cpdaily.isAvailable; - - @override - CookieProvider? get cookieProvider { - final raw = _cpdaily.rawFields; - final idsCookies = (raw['idsCookies'] as String?) ?? ''; - if (idsCookies.isEmpty) { - // Fall back to the parent CpDaily cookies — IDS and CpDaily share the - // CASTGC-bearing cookie set for ecourse/egate webviews. When a dedicated - // IDS cookie set has been minted it takes precedence. - return _cpdaily.cookieProvider; - } - return CookieProvider( - cookies: idsCookies, - studentId: _cpdaily.cookieProvider?.studentId ?? '', - domain: (raw['idsDomain'] as String?) ?? 'ids.shanghaitech.edu.cn', - ); - } - - @override - Future doRenew() async { - // IDS cookies are refreshed via the parent CpDaily session. Renew the - // parent (single-flighted at its level); on success the parent's raw is - // updated and our cookieProvider re-derives from it. A dedicated IDS - // bounce endpoint can be wired here later; for now the CpDaily renew - // already rotates the CASTGC-bearing cookie set IDS depends on. - final ok = await _cpdaily.renew(); - if (ok) markRenewed(); - return ok; - } -} - -/// A leaf node for token-only third-party providers (Gradescope, Hydro). -/// -/// These authenticate via bearer tokens consumed server-side by the TechPie -/// backend — they expose NO [CookieProvider]. Renewal is password-based -/// re-authentication (only when [ThirdPartyAccount.autoRenew] is set with a -/// stored password); a 401 from the backend means the token is dead and the -/// binding should be unbound, not renewed. -class LeafSessionNode extends SessionNode { - LeafSessionNode({ - required this.platform, - required this.persist, - required this.http, - required this.baseUrl, - }) : super(id: platform.id); - - final ThirdPartyPlatform platform; - final PersistAccount persist; - final LoggingHttpClient http; - final BaseUrlGetter baseUrl; - - ThirdPartyAccount? _account; - - ThirdPartyAccount? get account => _account; - - void setAccount(ThirdPartyAccount? acc) { - _account = acc; - notifyListeners(); - } - - @override - bool get isAvailable => _account != null; - - @override - CookieProvider? get cookieProvider => null; - - @override - Future doRenew() async { - final acc = _account; - if (acc == null || !acc.autoRenew) return false; - final pw = acc.password; - if (pw == null || pw.isEmpty) return false; - try { - final body = { - 'account': acc.account, - 'password': pw, - }; - if (platform == ThirdPartyPlatform.hydro && - acc.hydroOrigin != null && - acc.hydroOrigin!.isNotEmpty) { - body['args'] = {'url': acc.hydroOrigin}; - } - final resp = await http.post( - Uri.parse('${baseUrl()}/auth/third-party/${platform.id}'), - headers: {'Content-Type': 'application/json; charset=UTF-8'}, - body: jsonEncode(body), - tag: 'thirdPartyRenew:${platform.id}', - ); - if (resp.statusCode != 200) return false; - final data = jsonDecode(resp.body) as Map; - if (data['success'] != true) return false; - final d = (data['data'] as Map?)?.cast() ?? const {}; - final token = d['token'] as String?; - if (token == null || token.isEmpty) return false; - final renewed = ThirdPartyAccount( - platform: acc.platform, - account: acc.account, - sid: d['sid'] as String? ?? acc.sid, - name: d['name'] as String? ?? acc.name, - email: d['email'] as String? ?? acc.email, - token: token, - expire: (d['expire'] as num?)?.toInt() ?? acc.expire, - raw: (d['raw'] as Map?)?.cast() ?? acc.raw, - hydroOrigin: acc.hydroOrigin, - hydroDomains: acc.hydroDomains, - boundAt: acc.boundAt, - autoRenew: true, - password: pw, - ); - _account = renewed; - await persist(renewed); - markRenewed(); - return true; - } catch (_) { - return false; - } - } -} diff --git a/lib/services/session/session_tree.dart b/lib/services/session/session_tree.dart index 4bdaf26..d6b6382 100644 --- a/lib/services/session/session_tree.dart +++ b/lib/services/session/session_tree.dart @@ -6,39 +6,79 @@ import '../../models/third_party_account.dart'; import '../http_client.dart'; import 'cookie_provider.dart'; import 'session_node.dart'; -import 'session_nodes.dart'; + +/// The unified session tree. +/// +/// Topology (built once at construction): +/// ``` +/// SessionTree +/// ├── cpdaily (top-level, account+password/SMS bind, /auth/renew) +/// │ ├── eams (child, /auth/third-party/eams, parent tgc) +/// │ └── elearning (child, /auth/third-party/elearning, parent tgc) +/// ├── gradescope (top-level, account+password bind, bearer token) +/// └── hydro (top-level, account+password bind, sid cookie) +/// ``` /// -/// Topology is built once at construction; the facade -/// ([ThirdPartyAuthService]) feeds account mutations in via [setAccount]. +/// The facade ([ThirdPartyAuthService]) feeds account mutations in via +/// [setAccount]. Child nodes (eams/elearning) carry no account — their +/// credential is a derived cookie minted on demand from the parent's tgc. class SessionTree extends ChangeNotifier { SessionTree({ required this.persist, required this.http, required this.baseUrl, }) { - egate = CpdailySessionNode( + cpdaily = SessionNode( + id: 'cpdaily', + persist: persist, + http: http, + baseUrl: baseUrl, + renewPath: '/auth/renew', + renewMode: RenewMode.cpdailySession, + apiPath: 'egate', + ); + gradescope = SessionNode( + id: 'gradescope', persist: persist, http: http, baseUrl: baseUrl, + renewPath: '/auth/third-party/gradescope', + renewMode: RenewMode.password, + apiPath: 'gradescope', ); - ids = IdsSessionNode(cpdaily: egate); - egate.attachChild(ids); - gradescope = LeafSessionNode( - platform: ThirdPartyPlatform.gradescope, + hydro = SessionNode( + id: 'hydro', persist: persist, http: http, baseUrl: baseUrl, + renewPath: '/auth/third-party/hydro', + renewMode: RenewMode.password, + apiPath: 'hydro', ); - hydro = LeafSessionNode( - platform: ThirdPartyPlatform.hydro, + eams = SessionNode( + id: 'eams', persist: persist, http: http, baseUrl: baseUrl, + parent: cpdaily, + renewPath: '/auth/third-party/eams', + renewMode: RenewMode.parentCookie, ); + elearning = SessionNode( + id: 'elearning', + persist: persist, + http: http, + baseUrl: baseUrl, + parent: cpdaily, + renewPath: '/auth/third-party/elearning', + renewMode: RenewMode.parentCookie, + ); + cpdaily.attachChild(eams); + cpdaily.attachChild(elearning); // Propagate child notifications up so listeners on the tree (facade → // UI / sync) fire on any node change. - for (final n in [egate, ids, gradescope, hydro]) { + for (final n in [cpdaily, gradescope, hydro, eams, elearning]) { n.addListener(notifyListeners); } } @@ -47,39 +87,31 @@ class SessionTree extends ChangeNotifier { final LoggingHttpClient http; final BaseUrlGetter baseUrl; - - late final CpdailySessionNode egate; - late final IdsSessionNode ids; - late final LeafSessionNode gradescope; - late final LeafSessionNode hydro; + late final SessionNode cpdaily; + late final SessionNode gradescope; + late final SessionNode hydro; + late final SessionNode eams; + late final SessionNode elearning; /// All top-level nodes in a stable order. - List get roots => [egate, gradescope, hydro]; + List get roots => [cpdaily, gradescope, hydro]; - /// Lookup a leaf/ root node by [ThirdPartyPlatform]. + /// Lookup a top-level node by [ThirdPartyPlatform]. Child nodes (eams/ + /// elearning) are accessed directly via [eams]/[elearning]. SessionNode nodeFor(ThirdPartyPlatform p) => switch (p) { - ThirdPartyPlatform.egate => egate, + ThirdPartyPlatform.cpdaily => cpdaily, ThirdPartyPlatform.gradescope => gradescope, ThirdPartyPlatform.hydro => hydro, }; - /// Feed an account mutation from the facade into the matching node. Used by - /// bind/unbind/replaceAll/updateRaw. + /// Feed an account mutation from the facade into the matching top-level + /// node. Used by bind/unbind/replaceAll/updateRaw. Child nodes ignore + /// this (they carry no account). void setAccount(ThirdPartyPlatform p, ThirdPartyAccount? acc) { - switch (p) { - case ThirdPartyPlatform.egate: - egate.setAccount(acc); - break; - case ThirdPartyPlatform.gradescope: - gradescope.setAccount(acc); - break; - case ThirdPartyPlatform.hydro: - hydro.setAccount(acc); - break; - } + nodeFor(p).setAccount(acc); } - // -- The anti-storm 401-renew-retry helper -- + // -- The anti-storm 401-renew-retry helper (two-level) -- /// Run [action] with the cookie view from [node]. On a response the caller /// flags as expired (via [isExpired]), renew the node exactly once (shared @@ -87,6 +119,17 @@ class SessionTree extends ChangeNotifier { /// concurrent renew already advanced the cookie epoch since [action] /// captured it, skip the renew entirely and just retry with the new cookie. /// + /// For non-top-level nodes (eams/elearning), a failed first-level retry + /// triggers a second-level fallback ONLY if the child's renew failure was + /// a credential error (HTTP 401 = parent tgc stale). Server errors (5xx) + /// and network failures do NOT escalate — re-minting the parent tgc won't + /// fix a broken backend, so the escalation is skipped to avoid wasteful + /// /auth/renew calls. + /// + /// Retry budget: at most 1 child renew (first level) + 1 parent renew + + /// 1 child re-mint (second level) per withCookie call. The single-flight + /// gate on each node ensures concurrent callers share these renews. + /// /// [action] receives the current [CookieProvider] (non-null — callers /// gate on [node.isAvailable] first) and returns its result + whether the /// result should be treated as "cookie expired, please renew+retry". @@ -97,7 +140,42 @@ class SessionTree extends ChangeNotifier { SessionNode node, Future> Function(CookieProvider provider) action, ) async { - if (!node.isAvailable) return null; + // First level: single-node renew-retry (handles initial minting + 401). + var result = await _renewRetry(node, action); + if (result != null) return result; + + // Second level: for child nodes whose first-level retry returned null. + // ONLY escalate to parent renew if the child's renew failure was a + // credential error (HTTP 401 = parent tgc stale). A 500 from the + // downstream endpoint is a server error — re-minting the parent tgc + // won't fix it, so we skip the escalation and return null immediately. + // This prevents wasteful /auth/renew calls on transient backend errors. + if (node.parent == null) return null; + if (!node.lastRenewWasCredentialError) return null; + final parentOk = await node.parent!.renewIfNeeded(node.parent!.epoch); + if (!parentOk) return null; + final childOk = await node.renew(); + if (!childOk) return null; + final cp = node.cookieProvider; + if (cp == null) return null; + final retried = await action(cp); + return retried.value; + } + + /// Single-node 401-renew-retry. If the node is not yet available (e.g. a + /// child node whose downstream cookie hasn't been minted), attempt an + /// initial renew first. Returns null if the renew failed (caller may + /// attempt two-level fallback for child nodes). + Future _renewRetry( + SessionNode node, + Future> Function(CookieProvider provider) action, + ) async { + // If not available, try to mint credentials first (initial minting for + // child nodes, or a no-op for top-level nodes that are already bound). + if (!node.isAvailable) { + final ok = await node.renew(); + if (!ok) return null; + } var cp = node.cookieProvider; if (cp == null) return null; @@ -107,17 +185,17 @@ class SessionTree extends ChangeNotifier { // 401: renew if the cookie hasn't already been refreshed since we read it. final beforeEpoch = node.epoch; final ok = await node.renewIfNeeded(beforeEpoch); - if (!ok) return result.value; + if (!ok) return null; cp = node.cookieProvider; - if (cp == null) return result.value; + if (cp == null) return null; final retried = await action(cp); return retried.value; } @override void dispose() { - for (final n in [egate, ids, gradescope, hydro]) { + for (final n in [cpdaily, gradescope, hydro, eams, elearning]) { n.removeListener(notifyListeners); n.dispose(); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ac5d084..6c664b4 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -76,7 +76,26 @@ class StorageService { Future> loadAllThirdPartyAccounts() async { final result = []; for (final p in ThirdPartyPlatform.values) { - final acc = await loadThirdPartyAccount(p); + var acc = await loadThirdPartyAccount(p); + // One-time migration: cpdaily was previously stored under the legacy + // 'egate' key (platform id before the rename). If the new key is empty + // but the legacy key has data, adopt it and delete the old key. + if (p == ThirdPartyPlatform.cpdaily && acc == null) { + const legacyKey = '${_thirdPartyKeyPrefix}egate'; + final raw = await _secure.read(key: legacyKey); + if (raw != null) { + try { + acc = ThirdPartyAccount.fromJson( + jsonDecode(raw) as Map, + ); + await _secure.write(key: _thirdPartyKey(p), value: raw); + await _secure.delete(key: legacyKey); + } catch (_) { + // Corrupt legacy entry — leave it; clearThirdPartyAccount can + // still remove it via the fromId alias path. + } + } + } if (acc != null) result.add(acc); } return result; diff --git a/lib/services/third_party_auth_service.dart b/lib/services/third_party_auth_service.dart index f411477..1343533 100644 --- a/lib/services/third_party_auth_service.dart +++ b/lib/services/third_party_auth_service.dart @@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart'; import '../models/third_party_account.dart'; import 'api_base_url.dart'; import 'http_client.dart'; -import 'session/session_nodes.dart'; +import 'session/session_node.dart'; import 'session/session_tree.dart'; import 'storage_service.dart'; @@ -24,8 +24,8 @@ class ThirdPartyAuthService extends ChangeNotifier { bool _initialized = false; - // SMS context for eGate binding flow (set by sendEgateSmsCode). - Map? _egateSmsContext; + // SMS context for cpdaily binding flow (set by sendCpdailySmsCode). + Map? _cpdailySmsContext; late final SessionTree _tree; @@ -50,12 +50,21 @@ class ThirdPartyAuthService extends ChangeNotifier { /// [SessionTree.withCookie] for 401-renew-retry) go through here. SessionTree get sessionTree => _tree; - /// Convenience: the CpDaily (eGate) session node — parent of [idsNode]. - CpdailySessionNode get egateNode => _tree.egate; + /// Convenience: the CpDaily session node — parent of [eamsNode] and + /// [elearningNode]. Source of CASTGC / CpDaily cookies. + SessionNode get cpdailyNode => _tree.cpdaily; - /// Convenience: the IDS session node — child of [egateNode], refreshed via - /// the CpDaily session. - IdsSessionNode get idsNode => _tree.ids; + /// Convenience: the EAMS downstream node (child of cpdaily). + SessionNode get eamsNode => _tree.eams; + + /// Convenience: the eLearning downstream node (child of cpdaily). + SessionNode get elearningNode => _tree.elearning; + + /// Convenience: the Gradescope top-level node. + SessionNode get gradescopeNode => _tree.gradescope; + + /// Convenience: the Hydro top-level node. + SessionNode get hydroNode => _tree.hydro; List get boundPlatforms => _accountsSnapshot.map((a) => a.platform).toList(); @@ -63,13 +72,13 @@ class ThirdPartyAuthService extends ChangeNotifier { ThirdPartyAccount? account(ThirdPartyPlatform p) => _nodeAccount(p); List get _accountsSnapshot => [ - _tree.egate.account, + _tree.cpdaily.account, _tree.gradescope.account, _tree.hydro.account, ].whereType().toList(); ThirdPartyAccount? _nodeAccount(ThirdPartyPlatform p) => switch (p) { - ThirdPartyPlatform.egate => _tree.egate.account, + ThirdPartyPlatform.cpdaily => _tree.cpdaily.account, ThirdPartyPlatform.gradescope => _tree.gradescope.account, ThirdPartyPlatform.hydro => _tree.hydro.account, }; @@ -100,36 +109,36 @@ class ThirdPartyAuthService extends ChangeNotifier { // listener → _onTreeChanged). } - // -- eGate binding (single source of CASTGC / CpDaily session) -- + // -- CpDaily binding (single source of CASTGC / CpDaily session) -- // - // The eGate binding is the ONLY place CASTGC lives in the new architecture. - // The primary GeekPie SSO session has no tgc/cookies; every campus-system - // feature (schedule, blackboard, exam, oa-gym, webview features) must read - // its CpDaily session through these accessors instead of touching - // `AuthService.session` fields directly. + // The cpdaily binding is the ONLY place CASTGC lives in the new + // architecture. The primary GeekPie SSO session has no tgc/cookies; every + // campus-system feature (schedule, blackboard, exam, oa-gym, webview + // features) must read its CpDaily session through these accessors instead + // of touching `AuthService.session` fields directly. - /// True when an eGate / IDS binding exists. This is the gate every + /// True when a cpdaily binding exists. This is the gate every /// CASTGC-dependent feature must check before doing work. - bool get hasEgateBinding => _tree.egate.account != null; + bool get hasCpdailyBinding => _tree.cpdaily.account != null; - /// The bound eGate account, or null. - ThirdPartyAccount? get egateBinding => _tree.egate.account; + /// The bound cpdaily account, or null. + ThirdPartyAccount? get cpdailyBinding => _tree.cpdaily.account; /// Cookie string for campus-system requests, always ending with /// `CASTGC=` when a tgc is present (the form CpDaily/EAMS expects). /// Returns '' when there is no binding or no tgc — callers should treat /// that as "session unavailable". - String egateCookies() => _tree.egate.cookieProvider?.cookies ?? ''; + String cpdailyCookies() => _tree.cpdaily.cookieProvider?.cookies ?? ''; - /// Student id surfaced by the eGate binding, or '' if unbound. - String get egateStudentId => _tree.egate.account?.sid ?? ''; + /// Student id surfaced by the cpdaily binding, or '' if unbound. + String get cpdailyStudentId => _tree.cpdaily.account?.sid ?? ''; - /// Best-effort renewal of the eGate binding's CpDaily session. Delegates to - /// [CpdailySessionNode.renew], which is single-flighted: concurrent callers + /// Best-effort renewal of the cpdaily binding's CpDaily session. Delegates + /// to [SessionNode.renew], which is single-flighted: concurrent callers /// (two services hitting 401 at once) share ONE `/auth/renew` POST. On /// success the refreshed raw is persisted and listeners notified via the /// tree → [Service._onTreeChanged] path. Returns true on success. - Future renewEgateBinding() => _tree.egate.renew(); + Future renewCpdailyBinding() => _tree.cpdaily.renew(); Future initialize() async { final loaded = await _storage.loadAllThirdPartyAccounts(); @@ -161,8 +170,10 @@ class ThirdPartyAuthService extends ChangeNotifier { body['args'] = {'url': hydroOrigin}; } + // cpdaily binds via the legacy 'egate' backend route; others use their id. + final route = platform.apiPath; final resp = await _http.post( - Uri.parse('$_baseUrl/auth/third-party/${platform.id}'), + Uri.parse('$_baseUrl/auth/third-party/$route'), headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode(body), tag: 'thirdPartyBind:${platform.id}', @@ -220,6 +231,7 @@ class ThirdPartyAuthService extends ChangeNotifier { _tree.setAccount(platform, null); await _storage.clearThirdPartyAccount(platform); } + /// Replace the entire in-memory + persisted binding set in one shot. Used by /// [SyncService.pull] to restore a cloud-fetched snapshot: clears every /// existing platform binding, writes each entry in [next] to secure storage, @@ -267,51 +279,52 @@ class ThirdPartyAuthService extends ChangeNotifier { _tree.setAccount(platform, updated); } - // -- eGate SMS binding flow -- + // -- CpDaily SMS binding flow -- - /// Step 1: Send an SMS verification code for eGate binding. + /// Step 1: Send an SMS verification code for cpdaily binding. /// Reuses the existing /api/auth/mobile/send-sms endpoint. - Future sendEgateSmsCode(String phone) async { + Future sendCpdailySmsCode(String phone) async { final resp = await _http.post( Uri.parse('$_baseUrl/auth/mobile/send-sms'), headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode({'phone': phone}), - tag: 'egateSendSms', + tag: 'cpdailySendSms', ); final data = jsonDecode(resp.body) as Map; if (data['success'] != true) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, data['error'] as String? ?? 'Failed to send SMS', ); } - _egateSmsContext = data['context'] as Map?; + _cpdailySmsContext = data['context'] as Map?; } - /// Step 2: Complete eGate binding via SMS verification code. - Future bindEgateSms({ + /// Step 2: Complete cpdaily binding via SMS verification code. + Future bindCpdailySms({ required String phone, required String code, bool autoRenew = false, }) async { - if (_egateSmsContext == null) { + if (_cpdailySmsContext == null) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, 'Send SMS code first', ); } + // cpdaily binds via the legacy 'egate' backend route. final resp = await _http.post( Uri.parse('$_baseUrl/auth/third-party/egate'), headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode({ 'phone': phone, 'code': code, - 'context': _egateSmsContext, + 'context': _cpdailySmsContext, }), - tag: 'egateBindSms', + tag: 'cpdailyBindSms', ); Map data; @@ -319,14 +332,14 @@ class ThirdPartyAuthService extends ChangeNotifier { data = jsonDecode(resp.body) as Map; } catch (_) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, 'Invalid response (status ${resp.statusCode})', ); } if (data['success'] != true) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, (data['error'] as String?) ?? 'login failed (${resp.statusCode})', ); } @@ -335,13 +348,13 @@ class ThirdPartyAuthService extends ChangeNotifier { final token = d['token'] as String?; if (token == null || token.isEmpty) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, 'response missing token', ); } final acc = ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: phone, sid: d['sid'] as String?, name: d['name'] as String?, @@ -354,8 +367,8 @@ class ThirdPartyAuthService extends ChangeNotifier { ); await _storage.saveThirdPartyAccount(acc); - _tree.setAccount(ThirdPartyPlatform.egate, acc); - _egateSmsContext = null; + _tree.setAccount(ThirdPartyPlatform.cpdaily, acc); + _cpdailySmsContext = null; return acc; } @@ -373,9 +386,9 @@ class ThirdPartyAuthService extends ChangeNotifier { final failed = []; for (final acc in snapshot) { if (!acc.autoRenew) continue; - // eGate tokens are renewed via /api/auth/renew using stored tgc, + // cpdaily tokens are renewed via /api/auth/renew using stored tgc, // not via password re-authentication — skip here. - if (acc.platform == ThirdPartyPlatform.egate) continue; + if (acc.platform == ThirdPartyPlatform.cpdaily) continue; final pw = acc.password; if (pw == null || pw.isEmpty) continue; final at = acc.expireAt; diff --git a/test/assignment_service_test.dart b/test/assignment_service_test.dart index 75cafd7..4d514af 100644 --- a/test/assignment_service_test.dart +++ b/test/assignment_service_test.dart @@ -49,7 +49,7 @@ void main() { // primary UserSession. Bind one with the tgc the test expects. await storage.saveThirdPartyAccount( ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: 'student', sid: 'student', token: 'session', @@ -73,6 +73,15 @@ void main() { ); final httpClient = _AssignmentHttpClient((url, _) { + if (url.path.endsWith('/auth/third-party/elearning')) { + return http.Response( + jsonEncode({ + 'success': true, + 'data': {'token': 'session_id=elearning-mock'}, + }), + 200, + ); + } if (url.path.endsWith('/deadlines/blackboard')) { return http.Response( jsonEncode({ @@ -141,7 +150,7 @@ void main() { // Exam fetch reads cookies from the eGate binding, not UserSession. await storage.saveThirdPartyAccount( ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: 'student', sid: 'student', token: 'session', @@ -159,6 +168,24 @@ void main() { final seenBodies = >[]; final httpClient = _AssignmentHttpClient((url, body) { seenBodies.add(body); + if (url.path.endsWith('/auth/third-party/elearning')) { + return http.Response( + jsonEncode({ + 'success': true, + 'data': {'token': 'session_id=elearning-mock'}, + }), + 200, + ); + } + if (url.path.endsWith('/auth/third-party/eams')) { + return http.Response( + jsonEncode({ + 'success': true, + 'data': {'token': 'JSESSIONID=eams-mock'}, + }), + 200, + ); + } if (url.path.endsWith('/deadlines/blackboard')) { return http.Response( jsonEncode({'success': true, 'data': []}), @@ -228,7 +255,7 @@ void main() { contains( predicate>((body) { return body['semester_id'] == '263' && - body['cookies'] == 'SESSION=abc; CASTGC=tgc'; + body['cookies'] == 'JSESSIONID=eams-mock'; }), ), ); diff --git a/test/oa_gym_service_test.dart b/test/oa_gym_service_test.dart index eab93a9..4392b90 100644 --- a/test/oa_gym_service_test.dart +++ b/test/oa_gym_service_test.dart @@ -235,7 +235,7 @@ Future<_Fixture> _serviceFixture({ if (bindEgate) { await storage.saveThirdPartyAccount( ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: '20240001', sid: '20240001', token: 'session', diff --git a/test/session_storm_test.dart b/test/session_storm_test.dart index a6f7306..1a516a9 100644 --- a/test/session_storm_test.dart +++ b/test/session_storm_test.dart @@ -7,12 +7,16 @@ import 'package:techpie/services/debug_logger.dart'; import 'package:techpie/services/http_client.dart'; import 'package:techpie/services/session/session_tree.dart'; -/// Verifies the anti-renew-storm guarantees of the SessionNode tree: +/// Verifies the anti-renew-storm guarantees of the unified SessionNode tree: /// 1. Concurrent renew() calls share ONE in-flight renew (single-flight). /// 2. renewIfNeeded(beforeEpoch) skips when a concurrent renew already /// advanced the epoch — the caller re-reads fresh cookies instead. /// 3. withCookie retries a 401 exactly once with the fresh cookie, and two /// concurrent withCookie callers share a single renew. +/// 4. Child nodes (eams/elearning) mint downstream cookies from the parent +/// cpdaily tgc; parent renew cascades to clear child derived cookies. +/// 5. Two-level retry: a child 401 caused by a stale parent tgc triggers +/// parent renew → child re-mint → retry. void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -20,11 +24,11 @@ void main() { late LoggingHttpClient httpClient; late SessionTree tree; - /// Seed an egate account so the cpdaily node is available. The renew + /// Seed a cpdaily account so the cpdaily node is available. The renew /// endpoint (/auth/renew) is mocked to rotate tgc + cookies each call. ThirdPartyAccount seedAccount({String tgc = 'tgc-v0'}) => ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: 'student', sid: '2024xxxx', name: 'Test', @@ -48,42 +52,44 @@ void main() { http: httpClient, baseUrl: () => 'https://backend.test', ); - tree.egate.setAccount(seedAccount()); + tree.cpdaily.setAccount(seedAccount()); }); test('concurrent renew() calls share one in-flight POST (single-flight)', () async { - // Three concurrent renews — must collapse to ONE /auth/renew call. final results = await Future.wait([ - tree.egate.renew(), - tree.egate.renew(), - tree.egate.renew(), + tree.cpdaily.renew(), + tree.cpdaily.renew(), + tree.cpdaily.renew(), ]); expect(results, [true, true, true]); expect(client.renewCalls, 1); // Cookie advanced to v1. - expect(tree.egate.cookieProvider!.cookies, contains('CASTGC=tgc-v1')); + expect( + tree.cpdaily.cookieProvider!.cookies, + contains('CASTGC=tgc-v1'), + ); }); test('renewIfNeeded skips when epoch already advanced by a concurrent renew', () async { - final epochBefore = tree.egate.epoch; + final epochBefore = tree.cpdaily.epoch; // Fire a renew in the background (don't await yet). - final pending = tree.egate.renew(); + final pending = tree.cpdaily.renew(); // While it's in-flight, renewIfNeeded(epochBefore) should join the same // in-flight renew rather than starting a second one. - final second = tree.egate.renewIfNeeded(epochBefore); + final second = tree.cpdaily.renewIfNeeded(epochBefore); await Future.wait([pending, second]); expect(client.renewCalls, 1); }); test('renewIfNeeded does NOT skip when epoch is still current', () async { - final epochBefore = tree.egate.epoch; + final epochBefore = tree.cpdaily.epoch; // No concurrent renew — renewIfNeeded must actually run. - final ok = await tree.egate.renewIfNeeded(epochBefore); + final ok = await tree.cpdaily.renewIfNeeded(epochBefore); expect(ok, true); expect(client.renewCalls, 1); - expect(tree.egate.epoch, greaterThan(epochBefore)); + expect(tree.cpdaily.epoch, greaterThan(epochBefore)); }); test('withCookie retries 401 once with refreshed cookie', () async { @@ -92,7 +98,7 @@ void main() { client.fetchBodies = ['{}', jsonEncode({'ok': true})]; final resp = await tree.withCookie( - tree.egate, + tree.cpdaily, (cp) async { final r = await httpClient.post( Uri.parse('https://backend.test/fetch'), @@ -113,10 +119,9 @@ void main() { client.fetchStatuses = [401, 401, 200, 200]; client.fetchBodies = ['{}', '{}', '{"a":1}', '{"b":2}']; - final results = await Future.wait([ tree.withCookie( - tree.egate, + tree.cpdaily, (cp) async { final r = await httpClient.post( Uri.parse('https://backend.test/fetch'), @@ -126,7 +131,7 @@ void main() { }, ), tree.withCookie( - tree.egate, + tree.cpdaily, (cp) async { final r = await httpClient.post( Uri.parse('https://backend.test/fetch'), @@ -146,31 +151,145 @@ void main() { ); }); - test('ids node cookieProvider falls back to parent cpdaily cookies', () { - final cp = tree.ids.cookieProvider; - expect(cp, isNotNull); - // Falls back to parent's CASTGC-bearing cookie set. - expect(cp!.cookies, contains('CASTGC=tgc-v0')); + // -- Child node (eams/elearning) downstream renew tests -- + + test('eams doRenew mints downstream cookie from parent tgc', () async { + // Downstream renew returns a token (cookie string). + final ok = await tree.eams.renew(); + expect(ok, true); + expect(client.eamsCalls, 1); + expect(tree.eams.cookieProvider, isNotNull); + expect(tree.eams.cookieProvider!.cookies, 'JSESSIONID=eams-v1'); + // The renew POST body used the parent's current tgc. + expect(client.lastEamsBody?['tgc'], 'tgc-v0'); }); - test('ids renew delegates to parent cpdaily renew (single-flight at parent)', - () async { - final ok = await tree.ids.renew(); + test('eams isAvailable is false until doRenew succeeds', () { + expect(tree.eams.isAvailable, false); + }); + + test('eams cookieProvider is null until doRenew succeeds', () { + expect(tree.eams.cookieProvider, isNull); + }); + + test('parent cpdaily renew clears eams derived cookie (cascade)', () async { + // First mint the eams cookie. + await tree.eams.renew(); + expect(tree.eams.isAvailable, true); + // Now renew the parent — the child's derived cookie must be cleared. + final ok = await tree.cpdaily.renew(); expect(ok, true); + expect(tree.eams.isAvailable, false); + expect(tree.eams.cookieProvider, isNull); + }); + + test('child 401 retry: eams renew succeeds (single-level, fresh parent)', + () async { + // Mint the eams cookie first. + await tree.eams.renew(); + expect(client.eamsCalls, 1); + + // Fetch returns 401 (eams cookie stale), then 200 after re-mint. + client.fetchStatuses = [401, 200]; + client.fetchBodies = ['{}', jsonEncode({'ok': true})]; + + final resp = await tree.withCookie( + tree.eams, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + + // Single-level retry: eams renew (2nd eams call) succeeds because the + // parent tgc is still fresh. No parent renew needed. + expect(resp, isNotNull); + expect(resp!.statusCode, 200); + expect(client.renewCalls, 0); + expect(client.eamsCalls, 2); + }); + + test( + 'two-level retry: eams renew 401 (stale parent tgc) triggers parent renew', + () async { + // Reset parent to a stale tgc that the eams endpoint rejects. + tree.cpdaily.setAccount(seedAccount(tgc: 'tgc-stale')); + // Configure eams renew to fail on the stale tgc, then succeed after the + // parent renews (rotating tgc to v1). + client.eamsStatuses = [401, 200]; + client.eamsTokens = [null, 'JSESSIONID=eams-fresh']; + + // Configure the fetch path to return 200 (the fetch itself is fine once + // the eams cookie is minted). + client.fetchStatuses = [200]; + client.fetchBodies = [jsonEncode({'ok': true})]; + + final resp = await tree.withCookie( + tree.eams, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + + expect(resp, isNotNull); + expect(resp!.statusCode, 200); + // Parent was renewed (tgc rotated to v1). expect(client.renewCalls, 1); - expect(tree.egate.cookieProvider!.cookies, contains('CASTGC=tgc-v1')); + // Eams was minted twice: first (failed 401), then after parent renew. + expect(client.eamsCalls, 2); + expect( + tree.eams.cookieProvider!.cookies, + 'JSESSIONID=eams-fresh', + ); + }); + + test('server error (500) does NOT trigger parent renew escalation', () async { + // Eams renew returns 500 (backend error). The two-level fallback must + // NOT escalate to parent renew — a server error isn't a credential + // issue, and re-minting the parent tgc won't fix it. + client.eamsStatuses = [500]; + + final resp = await tree.withCookie( + tree.eams, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + + expect(resp, isNull); + // Parent renew was NOT called (no escalation on 500). + expect(client.renewCalls, 0); + // Eams renew was attempted once (first-level minting). + expect(client.eamsCalls, 1); }); } -/// Mock HTTP client that counts /auth/renew calls and serves a configurable -/// sequence of statuses/bodies for other paths. The renew response rotates -/// tgc + cookies by appending a version suffix so each renew is observable. +/// Mock HTTP client that counts /auth/renew and /auth/third-party/eams calls +/// and serves a configurable sequence of statuses/bodies for other paths. +/// The renew response rotates tgc + cookies by appending a version suffix so +/// each renew is observable. class _CountingClient extends http.BaseClient { int renewCalls = 0; + int eamsCalls = 0; int fetchCalls = 0; List fetchStatuses = const []; List fetchBodies = const []; + List eamsStatuses = const []; + List eamsTokens = const []; + Map? lastEamsBody; int _renewVersion = 0; + int _eamsVersion = 0; @override Future send(http.BaseRequest request) async { @@ -190,6 +309,25 @@ class _CountingClient extends http.BaseClient { return _resp(resp, 200); } + if (url.endsWith('/auth/third-party/eams')) { + eamsCalls++; + final body = request is http.Request + ? jsonDecode(request.body) as Map + : {}; + lastEamsBody = body; + _eamsVersion++; + final idx = eamsCalls - 1; + final status = idx < eamsStatuses.length ? eamsStatuses[idx] : 200; + final token = idx < eamsTokens.length + ? eamsTokens[idx] + : 'JSESSIONID=eams-v$_eamsVersion'; + final resp = jsonEncode({ + 'success': true, + 'data': {'token': token}, + }); + return _resp(resp, status); + } + // Other paths (fetch) consume from the configured sequences. fetchCalls++; final idx = fetchCalls - 1; diff --git a/test/sync_crypto_test.dart b/test/sync_crypto_test.dart index 60e762a..5979f51 100644 --- a/test/sync_crypto_test.dart +++ b/test/sync_crypto_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/models/third_party_account.dart'; import 'package:techpie/services/sync_crypto.dart'; void main() { @@ -8,7 +9,7 @@ void main() { test('encryptWithSalt -> decryptWithSalt recovers plaintext', () async { const password = 'correct horse battery staple'; const payload = - '[{"platform":"egate","account":"13800000000","token":"tgc-secret"}]'; + '[{"platform":"cpdaily","account":"13800000000","token":"tgc-secret"}]'; final blob = await SyncCrypto.encryptWithSalt(payload, password); expect(blob.contains('.'), isTrue); @@ -16,6 +17,11 @@ void main() { final recovered = await SyncCrypto.decryptWithSalt(blob, password); expect(recovered, payload); }); + test('ThirdPartyPlatform.fromId resolves legacy egate alias to cpdaily', + () { + expect(ThirdPartyPlatform.fromId('egate'), ThirdPartyPlatform.cpdaily); + expect(ThirdPartyPlatform.fromId('cpdaily'), ThirdPartyPlatform.cpdaily); + }); test('wrong master password fails authentication (returns null)', () async { diff --git a/test/sync_service_test.dart b/test/sync_service_test.dart index 19a9f2f..7ed9cc2 100644 --- a/test/sync_service_test.dart +++ b/test/sync_service_test.dart @@ -59,7 +59,7 @@ void main() { final fx = await _Fixture.withSession(); await fx.tpAuth.replaceAll([ ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: '13800000000', sid: '20240001', token: 'tgc-secret', @@ -73,7 +73,7 @@ void main() { final outcome = await fx2.sync.restoreWithMasterPassword('wrong'); expect(outcome.ok, isFalse); expect(outcome.message, contains('不正确')); - expect(fx2.tpAuth.account(ThirdPartyPlatform.egate), isNull); + expect(fx2.tpAuth.account(ThirdPartyPlatform.cpdaily), isNull); }); test('push writes current bindings; disable clears the cloud blob', From ad8a57051f104123011e4423addafcc8bc28a75d Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 06:09:19 +0800 Subject: [PATCH 3/9] fix: stop eams/elearning notify from triggering auto-refetch loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Child node (eams/elearning) markRenewed → notifyListeners was propagating through tree → tpAuth → assignment _onDepsChanged → fetchAssignments, creating a feedback loop that re-minted downstream cookies on every fetch. Only top-level nodes (cpdaily/gradescope/hydro) now propagate to the tree — child notifications still fire for direct listeners (withCookie epoch checks). --- lib/services/session/session_tree.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/services/session/session_tree.dart b/lib/services/session/session_tree.dart index d6b6382..5310ce6 100644 --- a/lib/services/session/session_tree.dart +++ b/lib/services/session/session_tree.dart @@ -76,9 +76,13 @@ class SessionTree extends ChangeNotifier { cpdaily.attachChild(eams); cpdaily.attachChild(elearning); - // Propagate child notifications up so listeners on the tree (facade → - // UI / sync) fire on any node change. - for (final n in [cpdaily, gradescope, hydro, eams, elearning]) { + // Only top-level node notifications propagate up to the tree (facade → + // UI / sync / auto-refetch). Child nodes (eams/elearning) mint cookies + // as a side-effect of withCookie — their notifyListeners would otherwise + // trigger _onDepsChanged → fetchAssignments → re-mint, a feedback loop. + // Child notifications still fire for direct listeners on the node itself + // (e.g. withCookie's epoch checks read node state directly, not via tree). + for (final n in [cpdaily, gradescope, hydro]) { n.addListener(notifyListeners); } } @@ -195,8 +199,10 @@ class SessionTree extends ChangeNotifier { @override void dispose() { - for (final n in [cpdaily, gradescope, hydro, eams, elearning]) { + for (final n in [cpdaily, gradescope, hydro]) { n.removeListener(notifyListeners); + } + for (final n in [cpdaily, gradescope, hydro, eams, elearning]) { n.dispose(); } super.dispose(); From 89716885b0aa6fb9c1e788c3204acc32b44f857c Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 06:26:16 +0800 Subject: [PATCH 4/9] feat: persist eams/elearning derived cookies across cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Child node (eams/elearning) derived cookies are now persisted to secure storage so cold start skips the SSO bounce. Previously _derivedCookie was ephemeral — every app restart re-minted via /auth/third-party/, adding 3-20s latency (elearning SSO is especially slow). - StorageService: save/load/clear derived cookies keyed by node id - SessionNode: persistDerived callback, setDerivedCookie boot hydration, _renewWithParentCookie persists after mint, onParentRenewed clears on parent tgc rotation - SessionTree: persistDerived param wired to eams/elearning, setDerivedCookie method for boot hydration - ThirdPartyAuthService: _persistDerivedCookie callback, initialize loads derived cookies after accounts, unbind/replaceAll/clearAll clear them when cpdaily binding is removed --- lib/services/session/session_node.dart | 32 +++++++++++++++++++- lib/services/session/session_tree.dart | 17 ++++++++++- lib/services/storage_service.dart | 25 ++++++++++++++++ lib/services/third_party_auth_service.dart | 34 ++++++++++++++++++++-- 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/lib/services/session/session_node.dart b/lib/services/session/session_node.dart index f1d2bd7..00db4aa 100644 --- a/lib/services/session/session_node.dart +++ b/lib/services/session/session_node.dart @@ -16,6 +16,15 @@ import 'cookie_provider.dart'; /// ephemeral). typedef PersistAccount = Future Function(ThirdPartyAccount updated); +/// Callback a non-top-level node installs to persist its derived downstream +/// cookie into secure storage (so cold start can skip the SSO bounce) or +/// clear it (when the parent renews and invalidates it). Receives the node +/// id and the cookie string (null to clear). +typedef PersistDerivedCookie = Future Function( + String nodeId, + String? cookie, +); + /// Callback to read the current API base URL (depends on storage settings). typedef BaseUrlGetter = String Function(); @@ -70,6 +79,7 @@ class SessionNode extends ChangeNotifier { this.renewPath, this.renewMode, this.apiPath, + this.persistDerived, }); /// Stable identifier (matches storage key / platform id, except cpdaily @@ -91,8 +101,8 @@ class SessionNode extends ChangeNotifier { /// was not renamed); for gradescope/hydro it equals [id]. Null for /// non-top-level nodes. final String? apiPath; - final PersistAccount persist; + final PersistDerivedCookie? persistDerived; final LoggingHttpClient http; final BaseUrlGetter baseUrl; @@ -121,6 +131,15 @@ class SessionNode extends ChangeNotifier { notifyListeners(); } + /// Hydrate the derived cookie from persistent storage at boot. Only + /// meaningful for non-top-level nodes; calling on a top-level node is a + /// no-op. Does NOT notify — this is a boot-time hydration, not a state + /// change the UI needs to react to. + void setDerivedCookie(String? cookie) { + if (parent == null) return; // top-level: no derived cookie + _derivedCookie = (cookie != null && cookie.isNotEmpty) ? cookie : null; + } + void attachChild(SessionNode child) { child.parent = this; _children.add(child); @@ -226,6 +245,12 @@ class SessionNode extends ChangeNotifier { void onParentRenewed() { if (parent != null) { _derivedCookie = null; + // Clear persisted cookie too — it was minted from the old parent tgc + // and is now invalid. The next withCookie call will re-mint. + final pd = persistDerived; + if (pd != null) { + unawaited(pd(id, null)); + } notifyListeners(); } } @@ -379,6 +404,11 @@ class SessionNode extends ChangeNotifier { final cookie = d['token'] as String?; if (cookie == null || cookie.isEmpty) return false; _derivedCookie = cookie; + // Persist so cold start can skip the SSO bounce. + final pd = persistDerived; + if (pd != null) { + unawaited(pd(id, cookie)); + } markRenewed(); return true; } catch (_) { diff --git a/lib/services/session/session_tree.dart b/lib/services/session/session_tree.dart index 5310ce6..9c1b467 100644 --- a/lib/services/session/session_tree.dart +++ b/lib/services/session/session_tree.dart @@ -27,6 +27,7 @@ class SessionTree extends ChangeNotifier { required this.persist, required this.http, required this.baseUrl, + this.persistDerived, }) { cpdaily = SessionNode( id: 'cpdaily', @@ -63,6 +64,7 @@ class SessionTree extends ChangeNotifier { parent: cpdaily, renewPath: '/auth/third-party/eams', renewMode: RenewMode.parentCookie, + persistDerived: persistDerived, ); elearning = SessionNode( id: 'elearning', @@ -72,6 +74,7 @@ class SessionTree extends ChangeNotifier { parent: cpdaily, renewPath: '/auth/third-party/elearning', renewMode: RenewMode.parentCookie, + persistDerived: persistDerived, ); cpdaily.attachChild(eams); cpdaily.attachChild(elearning); @@ -86,8 +89,8 @@ class SessionTree extends ChangeNotifier { n.addListener(notifyListeners); } } - final PersistAccount persist; + final PersistDerivedCookie? persistDerived; final LoggingHttpClient http; final BaseUrlGetter baseUrl; @@ -115,6 +118,18 @@ class SessionTree extends ChangeNotifier { nodeFor(p).setAccount(acc); } + /// Hydrate a child node's derived cookie from persistent storage at boot. + /// Called by the facade after accounts are loaded so cold start can skip + /// the SSO bounce if a valid derived cookie is still on disk. + void setDerivedCookie(String nodeId, String? cookie) { + final node = switch (nodeId) { + 'eams' => eams, + 'elearning' => elearning, + _ => null, + }; + node?.setDerivedCookie(cookie); + } + // -- The anti-storm 401-renew-retry helper (two-level) -- /// Run [action] with the cookie view from [node]. On a response the caller diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 6c664b4..e9e6674 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -111,6 +111,31 @@ class StorageService { } } + // Derived downstream cookies + // CASTGC, persisted so cold start skips the SSO bounce. Keyed by node id. + static const _derivedCookieKeyPrefix = 'derived_cookie_'; + + Future saveDerivedCookie(String nodeId, String cookie) async { + await _secure.write( + key: '$_derivedCookieKeyPrefix$nodeId', + value: cookie, + ); + } + + Future loadDerivedCookie(String nodeId) async { + return _secure.read(key: '$_derivedCookieKeyPrefix$nodeId'); + } + + Future clearDerivedCookie(String nodeId) async { + await _secure.delete(key: '$_derivedCookieKeyPrefix$nodeId'); + } + + Future clearAllDerivedCookies() async { + for (final id in const ['eams', 'elearning']) { + await _secure.delete(key: '$_derivedCookieKeyPrefix$id'); + } + } + // SharedPreferences for non-sensitive data bool get debugMode => _prefs.getBool(_debugModeKey) ?? false; Future setDebugMode(bool value) => _prefs.setBool(_debugModeKey, value); diff --git a/lib/services/third_party_auth_service.dart b/lib/services/third_party_auth_service.dart index 1343533..ceffb36 100644 --- a/lib/services/third_party_auth_service.dart +++ b/lib/services/third_party_auth_service.dart @@ -34,6 +34,7 @@ class ThirdPartyAuthService extends ChangeNotifier { persist: _persistAccount, http: _http, baseUrl: () => apiBaseUrl(_storage), + persistDerived: _persistDerivedCookie, ); // Tree node notifications propagate to this service's listeners (UI, // AssignmentService auto-refetch, etc.) and the cloud-sync push hook. @@ -109,6 +110,17 @@ class ThirdPartyAuthService extends ChangeNotifier { // listener → _onTreeChanged). } + /// Persist/clear a child node's derived cookie. Installed as the tree's + /// [PersistDerivedCookie] callback so eams/elearning cookie minting and + /// parent-renew cascades flow through storage. + Future _persistDerivedCookie(String nodeId, String? cookie) async { + if (cookie == null) { + await _storage.clearDerivedCookie(nodeId); + } else { + await _storage.saveDerivedCookie(nodeId, cookie); + } + } + // -- CpDaily binding (single source of CASTGC / CpDaily session) -- // // The cpdaily binding is the ONLY place CASTGC lives in the new @@ -138,8 +150,6 @@ class ThirdPartyAuthService extends ChangeNotifier { /// (two services hitting 401 at once) share ONE `/auth/renew` POST. On /// success the refreshed raw is persisted and listeners notified via the /// tree → [Service._onTreeChanged] path. Returns true on success. - Future renewCpdailyBinding() => _tree.cpdaily.renew(); - Future initialize() async { final loaded = await _storage.loadAllThirdPartyAccounts(); // Seed each node with its persisted account. setAccount routes to the @@ -148,6 +158,13 @@ class ThirdPartyAuthService extends ChangeNotifier { for (final acc in loaded) { _tree.setAccount(acc.platform, acc); } + // Hydrate child node derived cookies so cold start can skip the SSO + // bounce. These are best-effort — if stale, withCookie's 401 retry will + // re-mint transparently. + for (final id in const ['eams', 'elearning']) { + final cookie = await _storage.loadDerivedCookie(id); + _tree.setDerivedCookie(id, cookie); + } _initialized = true; notifyListeners(); } @@ -230,6 +247,13 @@ class ThirdPartyAuthService extends ChangeNotifier { Future unbind(ThirdPartyPlatform platform) async { _tree.setAccount(platform, null); await _storage.clearThirdPartyAccount(platform); + // Unbinding cpdaily invalidates all downstream derived cookies. + if (platform == ThirdPartyPlatform.cpdaily) { + for (final id in const ['eams', 'elearning']) { + _tree.setDerivedCookie(id, null); + await _storage.clearDerivedCookie(id); + } + } } /// Replace the entire in-memory + persisted binding set in one shot. Used by @@ -241,6 +265,11 @@ class ThirdPartyAuthService extends ChangeNotifier { for (final p in ThirdPartyPlatform.values) { await _storage.clearThirdPartyAccount(p); } + // Downstream derived cookies are invalidated when bindings are replaced. + for (final id in const ['eams', 'elearning']) { + _tree.setDerivedCookie(id, null); + await _storage.clearDerivedCookie(id); + } // Then seed each node + persist. setAccount notifies per-node; the tree // listener funnels into a single _onTreeChanged (throttled by SyncService). final byPlatform = { @@ -415,6 +444,7 @@ class ThirdPartyAuthService extends ChangeNotifier { _tree.setAccount(p, null); } await _storage.clearAllThirdPartyAccounts(); + await _storage.clearAllDerivedCookies(); } @override From e7a7a0a9600f8aa90553e9c0f84e3c5f4d301b0d Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 06:37:12 +0800 Subject: [PATCH 5/9] fix: unbind/clearAll force-push to cloud, bypassing throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pushIfDue has a 30s throttle — if a user unbinds cpdaily within 30s of the last push (e.g. right after boot pull), the unbind is silently skipped and the stale binding stays in the cloud blob. Next boot's pull restores it, making the unbind appear to not have worked. - SyncService.forcePush(): bypasses throttle, used by unbind/clearAll - onBindingsChanged now accepts {bool force}; main.dart routes force→forcePush - unbind/clearAll call _onTreeChanged(force: true) after clearing state --- lib/main.dart | 4 ++-- lib/services/sync_service.dart | 15 +++++++++++++++ lib/services/third_party_auth_service.dart | 16 ++++++++++++---- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 72d5d49..10a8654 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,8 +91,8 @@ Future _realMain(SharedPreferences prefs) async { // Cloud-sync push hook: after any binding mutation, best-effort push the new // state (throttled). Wired via post-construction setter to avoid a circular // dependency between ThirdPartyAuthService and SyncService. - thirdPartyAuthService.onBindingsChanged = () { - return syncService.pushIfDue(); + thirdPartyAuthService.onBindingsChanged = ({force = false}) { + return force ? syncService.forcePush() : syncService.pushIfDue(); }; // Cloud-sync pull hook: after a fresh SSO login, pull cloud bindings onto diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index be88ed8..7867122 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -570,4 +570,19 @@ class SyncService extends ChangeNotifier { // Background sync failures are non-fatal; the next explicit action retries. } } + + /// Force-push current bindings to the cloud, bypassing the throttle. + /// Used after unbind/clearAll so a binding removal is immediately + /// reflected in the cloud blob — without this, a throttled pushIfDue + /// skip would leave the stale binding in the cloud, and the next boot's + /// pull would restore it. + Future forcePush() async { + if (!enabled || _cachedKey == null) return; + _lastPushAt = DateTime.now(); + try { + await push(); + } catch (_) { + // Non-fatal — next explicit sync retries. + } + } } diff --git a/lib/services/third_party_auth_service.dart b/lib/services/third_party_auth_service.dart index ceffb36..387c163 100644 --- a/lib/services/third_party_auth_service.dart +++ b/lib/services/third_party_auth_service.dart @@ -87,16 +87,18 @@ class ThirdPartyAuthService extends ChangeNotifier { /// Post-construction hook fired after any binding mutation (bind / unbind / /// raw update / replaceAll / node-initiated renew). Wired by main.dart to /// [SyncService.pushIfDue] so the cloud backup stays current. Null until - /// wired; safe to call. - Future Function()? onBindingsChanged; + /// wired; safe to call. When [force] is true the caller (unbind/clearAll) + /// wants the push to bypass the throttle so a removal is immediately + /// reflected in the cloud blob. + Future Function({bool force})? onBindingsChanged; - void _onTreeChanged() { + void _onTreeChanged({bool force = false}) { // A node changed (renew / setAccount) — re-notify our listeners and push // to cloud sync. This is the single funnel for all mutations now. notifyListeners(); final hook = onBindingsChanged; if (hook != null) { - unawaited(hook()); + unawaited(hook(force: force)); } } @@ -254,6 +256,11 @@ class ThirdPartyAuthService extends ChangeNotifier { await _storage.clearDerivedCookie(id); } } + // Force-push so the removal is immediately reflected in the cloud blob, + // bypassing the pushIfDue throttle. Without this, a throttled skip would + // leave the stale binding in the cloud and the next boot's pull would + // restore it. + _onTreeChanged(force: true); } /// Replace the entire in-memory + persisted binding set in one shot. Used by @@ -445,6 +452,7 @@ class ThirdPartyAuthService extends ChangeNotifier { } await _storage.clearAllThirdPartyAccounts(); await _storage.clearAllDerivedCookies(); + _onTreeChanged(force: true); } @override From b9adc29b22ebaa8e08545c98033c338326979cbc Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 07:40:26 +0800 Subject: [PATCH 6/9] feat(sync): schema-versioned envelope + per-account LWW merge with tombstones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace blind last-write-wins pull (replaceAll) with a per-platform LWW merge driven by (updatedAt, deviceId). Deletions now carry tombstones so an unbind on device A is not resurrected when device B pulls an older cloud copy — fixing the root cause of the forcePush workaround. Schema: - New SyncEnvelope {v, accounts, tombstones} with migration ladder (v0 bare array → v1 → v2 current). Migrations are pure functions; legacy blobs auto-upgrade on read and are written back on next push. - Schema version is separate from crypto version (blob salt/nonce layout). LWW merge: - ThirdPartyAccount gains updatedAt + deviceId. boundAt stays semantic (bind moment); updatedAt bumps on every mutation (bind/rebind/renew/ raw update). deviceId is a stable per-device id (16-byte CSPRNG → hex, persisted in SharedPreferences, generated lazily at boot). - Timestamp tie-break: larger deviceId wins; empty deviceId always loses so legacy data yields to any real device write. - Tombstones: {platform, deletedAt, deviceId}. Deletion wins iff deletedAt > account.updatedAt (same instant → tombstone preferred). Wiring: - touchLocal() stamps accounts on every mutation path (bind, bindCpdailySms, updateRaw, _persistAccount/renew). - onUnbind hook records tombstones; clearAll records per-platform. - applySyncMerge() applies merge results without re-triggering the push hook (sync path writes the merged envelope itself). - pull()/restoreWithMasterPassword() merge instead of overwrite; push the merged envelope back if local had winners. Tests: 17 new (8 envelope pure-logic + 2 multi-device LWW/tombstone integration + 7 existing compatible via v0 auto-migration). 44 total pass. --- lib/main.dart | 3 + lib/models/third_party_account.dart | 67 ++++- lib/services/storage_service.dart | 19 ++ lib/services/sync_envelope.dart | 267 ++++++++++++++++++ lib/services/sync_service.dart | 174 ++++++++++-- lib/services/third_party_auth_service.dart | 127 +++++++-- test/sync_envelope_test.dart | 298 +++++++++++++++++++++ test/sync_service_test.dart | 78 ++++++ 8 files changed, 968 insertions(+), 65 deletions(-) create mode 100644 lib/services/sync_envelope.dart create mode 100644 test/sync_envelope_test.dart diff --git a/lib/main.dart b/lib/main.dart index 10a8654..c9dabcc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -94,6 +94,9 @@ Future _realMain(SharedPreferences prefs) async { thirdPartyAuthService.onBindingsChanged = ({force = false}) { return force ? syncService.forcePush() : syncService.pushIfDue(); }; + // Cloud-sync tombstone hook: record a deletion so the next LWW merge does + // not resurrect an older remote copy of the unbound platform. + thirdPartyAuthService.onUnbind = syncService.recordTombstone; // Cloud-sync pull hook: after a fresh SSO login, pull cloud bindings onto // this device (or surface the master-password restore prompt). diff --git a/lib/models/third_party_account.dart b/lib/models/third_party_account.dart index b7247df..52c4c68 100644 --- a/lib/models/third_party_account.dart +++ b/lib/models/third_party_account.dart @@ -22,11 +22,12 @@ enum ThirdPartyPlatform { /// Parse a platform id, accepting the legacy 'egate' alias for cpdaily. static ThirdPartyPlatform? fromId(String id) { - if (id == 'egate') return ThirdPartyPlatform.cpdaily; - for (final p in ThirdPartyPlatform.values) { - if (p.id == id) return p; - } - return null; + return switch (id) { + 'gradescope' => ThirdPartyPlatform.gradescope, + 'hydro' => ThirdPartyPlatform.hydro, + 'cpdaily' || 'egate' => ThirdPartyPlatform.cpdaily, + _ => null, + }; } } @@ -49,6 +50,20 @@ class ThirdPartyAccount { final bool autoRenew; final String? password; + /// Wall-clock timestamp of the most recent local mutation of this account + /// (bind / rebind / renew / raw update). Used by the cloud-sync merge to do + /// per-account last-writer-wins with [deviceId] as a deterministic + /// tie-breaker. Defaults to [boundAt] for accounts created before this + /// field existed (back-compat: treated as "ancient" so any newer write + /// wins). + final DateTime updatedAt; + + /// Stable id of the device that produced the current [updatedAt] bump. Used + /// as the LWW tie-breaker so two devices with skewed clocks still converge + /// deterministically. Empty for legacy accounts; newer devices always win + /// against an empty deviceId. + final String deviceId; + const ThirdPartyAccount({ required this.platform, required this.account, @@ -63,24 +78,36 @@ class ThirdPartyAccount { required this.boundAt, this.autoRenew = false, this.password, - }); + DateTime? updatedAt, + String? deviceId, + }) : updatedAt = updatedAt ?? boundAt, + deviceId = deviceId ?? ''; DateTime? get expireAt => expire == null ? null : DateTime.fromMillisecondsSinceEpoch(expire! * 1000); - bool get isExpired { - final at = expireAt; - return at != null && at.isBefore(DateTime.now()); - } + bool get isExpired => + expireAt != null && DateTime.now().isAfter(expireAt!); String get displayName { if (name != null && name!.isNotEmpty) return name!; - if (email != null && email!.isNotEmpty) return email!; - if (sid != null && sid!.isNotEmpty) return sid!; return account; } + /// Comparison key for LWW merge: newer [updatedAt] wins; on a tie the + /// lexicographically-larger [deviceId] wins (deterministic, total order). + /// An empty deviceId is treated as oldest of all so legacy data loses to + /// any real device write. + int compareVersionTo(ThirdPartyAccount other) { + final c = updatedAt.compareTo(other.updatedAt); + if (c != 0) return c; + // Empty deviceId always loses. + if (deviceId.isEmpty && other.deviceId.isNotEmpty) return -1; + if (deviceId.isNotEmpty && other.deviceId.isEmpty) return 1; + return deviceId.compareTo(other.deviceId); + } + Map toJson() => { 'platform': platform.id, 'account': account, @@ -95,9 +122,13 @@ class ThirdPartyAccount { 'boundAt': boundAt.toIso8601String(), 'autoRenew': autoRenew, if (password != null) 'password': password, + 'updatedAt': updatedAt.toIso8601String(), + 'deviceId': deviceId, }; factory ThirdPartyAccount.fromJson(Map json) { + final boundAt = + DateTime.tryParse(json['boundAt'] as String? ?? '') ?? DateTime.now(); return ThirdPartyAccount( platform: ThirdPartyPlatform.fromId(json['platform'] as String? ?? '') ?? ThirdPartyPlatform.gradescope, @@ -111,10 +142,14 @@ class ThirdPartyAccount { hydroOrigin: json['hydroOrigin'] as String?, hydroDomains: (json['hydroDomains'] as List?)?.map((e) => e as String).toList(), - boundAt: - DateTime.tryParse(json['boundAt'] as String? ?? '') ?? DateTime.now(), + boundAt: boundAt, autoRenew: json['autoRenew'] as bool? ?? false, password: json['password'] as String?, + // Back-compat: pre-v2 blobs had no updatedAt/deviceId. Fall back to + // boundAt / empty so they merge as "older than any real write". + updatedAt: + DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? boundAt, + deviceId: json['deviceId'] as String? ?? '', ); } @@ -129,6 +164,8 @@ class ThirdPartyAccount { List? hydroDomains, bool? autoRenew, String? password, + DateTime? updatedAt, + String? deviceId, }) { return ThirdPartyAccount( platform: platform, @@ -144,6 +181,8 @@ class ThirdPartyAccount { boundAt: boundAt, autoRenew: autoRenew ?? this.autoRenew, password: password ?? this.password, + updatedAt: updatedAt ?? this.updatedAt, + deviceId: deviceId ?? this.deviceId, ); } } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index e9e6674..7a8d898 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; // NOTE: import the OHOS package, not the upstream `flutter_secure_storage`. // Despite the name, `flutter_secure_storage_ohos` is a hard fork (declares @@ -26,6 +27,8 @@ class StorageService { static const _syncEnabledKey = 'sync_enabled'; static const _syncLastAtKey = 'sync_last_at'; static const _syncMasterKeyKey = 'sync_master_key'; // secure storage + static const _deviceIdKey = 'device_id'; + final FlutterSecureStorage _secure; final SharedPreferences _prefs; @@ -175,6 +178,22 @@ class StorageService { _secure.write(key: _syncMasterKeyKey, value: serialized); Future clearSyncMasterKey() => _secure.delete(key: _syncMasterKeyKey); + // Stable per-device identifier used as the cloud-sync LWW tie-breaker. + // Generated lazily on first access (16 random bytes → 32 hex chars) and + // persisted in SharedPreferences; never leaves the device except inside + // encrypted sync blobs. + Future ensureDeviceId() async { + var id = _prefs.getString(_deviceIdKey); + if (id == null || id.isEmpty) { + final rnd = Random.secure(); + id = List.generate(16, (_) => rnd.nextInt(256)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + await _prefs.setString(_deviceIdKey, id); + } + return id; + } + // Schedule cache static const _semestersKey = 'schedule_semesters'; static const _courseTablePrefix = 'schedule_course_table_'; diff --git a/lib/services/sync_envelope.dart b/lib/services/sync_envelope.dart new file mode 100644 index 0000000..9e045b1 --- /dev/null +++ b/lib/services/sync_envelope.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; + +import '../models/third_party_account.dart'; + +/// Schema version of the cloud-sync blob's plaintext envelope. +/// +/// History: +/// v0 — legacy: a bare JSON array of [ThirdPartyAccount.toJson] objects, +/// no envelope. Auto-migrated to v1 on first read. +/// v1 — introduced the `{v, accounts}` envelope. No tombstones; deletions +/// were represented by absence (the "deleted binding resurrects on +/// next pull" bug). Auto-migrated to v2 on first read. +/// v2 — current: `{v, accounts, tombstones}`. Deletions carry a tombstone +/// so per-platform LWW merge can distinguish "deleted on device A" +/// from "never had it on device A". Per-account `updatedAt` + +/// `deviceId` drive the merge. +class SyncSchema { + /// Current schema version produced by this build. + static const int current = 2; + + SyncSchema._(); + + /// Migrate a decoded plaintext JSON value to [current] and return the + /// normalized envelope. Accepts any historical shape: + /// - a [List] (v0 bare array) → wrapped as v1 then migrated + /// - a [Map] with `v` → run the migration ladder up to [current] + /// Returns `null` only if the input is structurally unrecognizable. + static SyncEnvelope? migrate(dynamic decoded) { + // v0: bare array of account objects. + if (decoded is List) { + final accounts = decoded + .whereType>() + .map((e) => ThirdPartyAccount.fromJson(e.cast())) + .toList(); + return SyncEnvelope( + v: current, + accounts: accounts, + tombstones: const [], + ); + } + if (decoded is! Map) return null; + final m = decoded.cast(); + var v = (m['v'] as num?)?.toInt() ?? 1; + // v1 → v2: add empty tombstones list. Accounts already carry + // updatedAt/deviceId with back-compat defaults from fromJson. + if (v < 2) { + // Nothing to transform in the account objects themselves; the v2 + // shape only adds the tombstones field. + v = 2; + } + final rawAccounts = + m['accounts'] is List ? m['accounts'] as List : const []; + final accounts = rawAccounts + .whereType>() + .map((e) => ThirdPartyAccount.fromJson(e.cast())) + .toList(); + final rawTombs = + m['tombstones'] is List ? m['tombstones'] as List : const []; + final tombstones = rawTombs + .whereType>() + .map((e) => SyncTombstone.fromJson(e.cast())) + .toList(); + return SyncEnvelope(v: current, accounts: accounts, tombstones: tombstones); + } +} + +/// A record that a platform was deliberately unbound on some device at +/// [deletedAt]. Used by the LWW merge so a deletion on device A is not +/// silently undone when device B (which never had the binding) pushes its +/// older state. +class SyncTombstone { + final ThirdPartyPlatform platform; + final DateTime deletedAt; + final String deviceId; + + const SyncTombstone({ + required this.platform, + required this.deletedAt, + required this.deviceId, + }); + + /// Compare two tombstones by recency (newer wins); tie-break by deviceId + /// identically to [ThirdPartyAccount.compareVersionTo]. + int compareVersionTo(SyncTombstone other) { + final c = deletedAt.compareTo(other.deletedAt); + if (c != 0) return c; + if (deviceId.isEmpty && other.deviceId.isNotEmpty) return -1; + if (deviceId.isNotEmpty && other.deviceId.isEmpty) return 1; + return deviceId.compareTo(other.deviceId); + } + + Map toJson() => { + 'platform': platform.id, + 'deletedAt': deletedAt.toIso8601String(), + 'deviceId': deviceId, + }; + + factory SyncTombstone.fromJson(Map json) { + return SyncTombstone( + platform: + ThirdPartyPlatform.fromId(json['platform'] as String? ?? '') ?? + ThirdPartyPlatform.gradescope, + deletedAt: + DateTime.tryParse(json['deletedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + deviceId: json['deviceId'] as String? ?? '', + ); + } +} + +/// The normalized cloud-sync plaintext: a versioned envelope of accounts + +/// deletion tombstones. Always at [SyncSchema.current] after [SyncSchema.migrate]. +class SyncEnvelope { + final int v; + final List accounts; + final List tombstones; + + const SyncEnvelope({ + required this.v, + required this.accounts, + required this.tombstones, + }); + + String encode() => jsonEncode({ + 'v': v, + 'accounts': accounts.map((a) => a.toJson()).toList(), + 'tombstones': tombstones.map((t) => t.toJson()).toList(), + }); + + static SyncEnvelope? decode(String plaintext) { + dynamic decoded; + try { + decoded = jsonDecode(plaintext); + } catch (_) { + return null; + } + return SyncSchema.migrate(decoded); + } + + /// Build an envelope from the local account list, preserving tombstones + /// the caller already tracks. Accounts with no deviceId/updatedAt are + /// left as-is — the caller is expected to have touched them. + factory SyncEnvelope.fromLocal({ + required Iterable accounts, + required Iterable tombstones, + }) { + return SyncEnvelope( + v: SyncSchema.current, + accounts: accounts.toList(), + tombstones: tombstones.toList(), + ); + } + + /// Per-platform LWW merge of this (local) envelope with [remote] (cloud). + /// + /// For each platform in [ThirdPartyPlatform.values]: + /// - gather the local account (or its absence), the remote account (or + /// its absence), the local tombstone (if any), and the remote tombstone + /// (if any); + /// - pick the newest event across {account.updatedAt, tombstone.deletedAt} + /// using [ThirdPartyAccount.compareVersionTo] / + /// [SyncTombstone.compareVersionTo] with deviceId tie-break; + /// - if a tombstone wins, the platform is absent in the result and the + /// tombstone is carried forward; + /// - if an account wins, that account is in the result and any older + /// tombstone for the platform is dropped. + /// + /// Returns a new envelope at [SyncSchema.current]. The caller is responsible + /// for applying the account side-effects locally and for pushing the merged + /// envelope to the cloud. + SyncEnvelope mergeWith(SyncEnvelope remote) { + final localByPlatform = { + for (final a in accounts) a.platform: a, + }; + final remoteByPlatform = { + for (final a in remote.accounts) a.platform: a, + }; + final localTombByPlatform = { + for (final t in tombstones) t.platform: t, + }; + final remoteTombByPlatform = { + for (final t in remote.tombstones) t.platform: t, + }; + + final mergedAccounts = []; + final mergedTombstones = []; + + for (final p in ThirdPartyPlatform.values) { + final localAcc = localByPlatform[p]; + final remoteAcc = remoteByPlatform[p]; + final localTomb = localTombByPlatform[p]; + final remoteTomb = remoteTombByPlatform[p]; + + // Collect candidate events: (kind, version-key, payload). + // kind 0 = tombstone, kind 1 = account. + final candidates = <_MergeCandidate>[]; + if (localAcc != null) { + candidates.add(_MergeCandidate(1, localAcc.deviceId, localAcc.updatedAt, localAcc)); + } + if (remoteAcc != null) { + candidates.add(_MergeCandidate(1, remoteAcc.deviceId, remoteAcc.updatedAt, remoteAcc)); + } + if (localTomb != null) { + candidates.add(_MergeCandidate(0, localTomb.deviceId, localTomb.deletedAt, localTomb)); + } + if (remoteTomb != null) { + candidates.add(_MergeCandidate(0, remoteTomb.deviceId, remoteTomb.deletedAt, remoteTomb)); + } + if (candidates.isEmpty) continue; + + candidates.sort((a, b) { + final c = a.ts.compareTo(b.ts); + if (c != 0) return c; + // Empty deviceId loses; else lexicographic. + final ad = a.deviceId, bd = b.deviceId; + if (ad.isEmpty && bd.isNotEmpty) return -1; + if (ad.isNotEmpty && bd.isEmpty) return 1; + return ad.compareTo(bd); + }); + + // The sort put the newest event last (ts asc, then deviceId asc). The + // winner is that last element. One policy override: if the newest + // tombstone shares the winning (ts, deviceId) with an account, prefer + // the tombstone (err toward not resurrecting a deletion). + final winner = candidates.last; + final hasAccount = candidates.any( + (c) => c.kind == 1 && c.ts == winner.ts && c.deviceId == winner.deviceId, + ); + final hasTomb = candidates.any( + (c) => + c.kind == 0 && c.ts == winner.ts && c.deviceId == winner.deviceId, + ); + if (winner.kind == 1 && hasTomb) { + // Account won the sort but a tombstone shares its version key → + // the tombstone wins by policy. + final tomb = candidates.firstWhere( + (c) => + c.kind == 0 && + c.ts == winner.ts && + c.deviceId == winner.deviceId, + ); + mergedTombstones.add(tomb.payload as SyncTombstone); + } else if (winner.kind == 0) { + mergedTombstones.add(winner.payload as SyncTombstone); + } else { + mergedAccounts.add(winner.payload as ThirdPartyAccount); + } + // hasAccount is tracked for the policy check above; no further use. + assert(hasAccount || winner.kind == 0); + } + + return SyncEnvelope( + v: SyncSchema.current, + accounts: mergedAccounts, + tombstones: mergedTombstones, + ); + } + +} + +class _MergeCandidate { + final int kind; // 0 = tombstone, 1 = account + final String deviceId; + final DateTime ts; + final Object payload; + _MergeCandidate(this.kind, this.deviceId, this.ts, this.payload); +} diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 7867122..62fa9f2 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -7,6 +7,7 @@ import '../models/third_party_account.dart'; import 'auth_service.dart'; import 'storage_service.dart'; import 'sync_crypto.dart'; +import 'sync_envelope.dart'; import 'third_party_auth_service.dart'; /// Thrown by [SyncService.pull] when the cloud has a sync blob but this device @@ -75,11 +76,17 @@ class SyncService extends ChangeNotifier { final ThirdPartyAuthService _tpAuth; final StorageService _storage; final http.Client _client; - CachedSyncKey? _cachedKey; bool _needsRestore = false; DateTime? _lastSyncAt; String? _lastError; + // Stable per-device id, loaded once at boot and stamped onto every + // locally-touched account / tombstone so the LWW merge converges. + String _deviceId = ''; + // In-memory tombstones for platforms unbound on THIS device since the last + // push. Cleared after a successful push. Survives across pulls so a remote + // account older than our deletion is not resurrected. + final List _tombstones = []; SyncService(this._auth, this._tpAuth, this._storage, {http.Client? client}) : _client = client ?? http.Client(); @@ -92,12 +99,14 @@ class SyncService extends ChangeNotifier { /// "立即备份/恢复" toasts). Null when the last call succeeded. String? get lastError => _lastError; - /// Load the cached derived key (if any) from secure storage. Call at boot. + /// Load the cached derived key (if any) from secure storage and the stable + /// device id from SharedPreferences. Call at boot. Future loadCachedKey() async { final s = await _storage.loadSyncMasterKey(); _cachedKey = await CachedSyncKey.fromStorageString(s); final iso = _storage.syncLastAt; _lastSyncAt = iso == null ? null : DateTime.tryParse(iso); + _deviceId = await _storage.ensureDeviceId(); notifyListeners(); } @@ -369,29 +378,53 @@ class SyncService extends ChangeNotifier { /// Does not decrypt — just checks presence. Cheap probe for the UI. Future cloudHasBlob() async => (await _readBlob()) != null; - String _serializeAccounts() { - final list = _tpAuth.accounts.map((a) => a.toJson()).toList(); - return jsonEncode(list); + /// Serialize the current local bindings + pending tombstones into the v2 + /// envelope plaintext. Accounts are stamped with this device's id and a + /// fresh [updatedAt] only via [touchLocal]; already-stamped accounts pass + /// through unchanged. + String _serializeEnvelope() { + final env = SyncEnvelope.fromLocal( + accounts: _tpAuth.accounts, + tombstones: _tombstones, + ); + return env.encode(); } - List _parseAccounts(String json) { - final list = jsonDecode(json) as List; - return list - .map( - (e) => - ThirdPartyAccount.fromJson((e as Map).cast()), - ) - .toList(); + /// Stamp [acc] with this device's id and the current wall clock, producing + /// a new [ThirdPartyAccount] that wins LWW against any older copy. Called + /// by [ThirdPartyAuthService] mutation paths via the public [touchLocal]. + ThirdPartyAccount touchLocal(ThirdPartyAccount acc) { + return acc.copyWith( + updatedAt: DateTime.now(), + deviceId: _deviceId, + ); + } + + /// Record that [platform] was deliberately unbound on this device. The + /// tombstone is carried in the next push and participates in LWW merge so a + /// remote account older than this deletion is not resurrected on pull. + void recordTombstone(ThirdPartyPlatform platform) { + final now = DateTime.now(); + // Replace any existing tombstone for the same platform (keep newest). + _tombstones.removeWhere((t) => t.platform == platform); + _tombstones.add( + SyncTombstone( + platform: platform, + deletedAt: now, + deviceId: _deviceId, + ), + ); } /// Push current local bindings to the cloud (encrypted). Requires a cached /// key (i.e. the device has already been set up / restored). Returns a result - /// whose [msg] carries Casdoor's error text on failure. + /// whose [msg] carries Casdoor's error text on failure. On success pending + /// tombstones are cleared (they are now persisted in the cloud blob). Future push() async { if (!enabled || _cachedKey == null) { return const SyncCasdoorResult(false, msg: '云同步未开启或缺少主密码'); } - final payload = _serializeAccounts(); + final payload = _serializeEnvelope(); // Reuse the cached salt so other devices' cached keys keep working. final salted = base64.encode(_cachedKey!.salt); final inner = await SyncCrypto.encrypt(payload, _cachedKey!.key); @@ -399,6 +432,7 @@ class SyncService extends ChangeNotifier { final res = await _writeBlob(blob); _lastError = res.ok ? null : res.msg; if (res.ok) { + _tombstones.clear(); _lastSyncAt = DateTime.now(); await _storage.setSyncLastAt(_lastSyncAt!.toIso8601String()); notifyListeners(); @@ -406,8 +440,12 @@ class SyncService extends ChangeNotifier { return res; } - /// Pull the cloud blob and restore bindings locally. Throws - /// [NeedMasterPassword] if no key is cached on this device. + /// Pull the cloud blob and merge it with the local state using per-platform + /// last-writer-wins (with [deviceId] tie-break and tombstones for + /// deletions). Unlike a blind overwrite, a local binding newer than the + /// cloud copy is preserved, and a cloud deletion (tombstone) newer than a + /// local binding removes it locally. Throws [NeedMasterPassword] if no key + /// is cached on this device. Future pull() async { if (!enabled) return; final blob = await _readBlob(); @@ -429,14 +467,83 @@ class SyncService extends ChangeNotifier { notifyListeners(); throw NeedMasterPassword(); } - final accounts = _parseAccounts(plain); - await _tpAuth.replaceAll(accounts); + final remote = SyncEnvelope.decode(plain); + if (remote == null) return; + final local = SyncEnvelope.fromLocal( + accounts: _tpAuth.accounts, + tombstones: _tombstones, + ); + final merged = local.mergeWith(remote); + // Apply the merged account set locally. applySyncMerge does NOT record + // tombstones (the merge already accounted for them); it just writes the + // winning account per platform and clears platforms whose tombstone won. + final removed = ThirdPartyPlatform.values + .where( + (p) => !merged.accounts.any((a) => a.platform == p), + ) + .toSet(); + await _tpAuth.applySyncMerge(merged.accounts, removed); + // Adopt the merged tombstone set so the next push propagates any remote + // tombstones this device didn't have. Local tombstones that won are + // already in [merged.tombstones]; ones that lost (an account won) are + // correctly absent. + _tombstones + ..clear() + ..addAll(merged.tombstones); _lastSyncAt = DateTime.now(); await _storage.setSyncLastAt(_lastSyncAt!.toIso8601String()); _needsRestore = false; + // If the merge produced a state that differs from the cloud (e.g. a local + // account won), push the merged envelope back so other devices converge. + if (!_envelopeEquals(local, merged)) { + await _writeMergedEnvelope(merged); + } notifyListeners(); } + /// Encrypt [env] and write it to the cloud, reusing the cached salt. + Future _writeMergedEnvelope(SyncEnvelope env) async { + final payload = env.encode(); + final salted = base64.encode(_cachedKey!.salt); + final inner = await SyncCrypto.encrypt(payload, _cachedKey!.key); + final blob = '$salted.$inner'; + final res = await _writeBlob(blob); + _lastError = res.ok ? null : res.msg; + if (res.ok) { + _lastSyncAt = DateTime.now(); + await _storage.setSyncLastAt(_lastSyncAt!.toIso8601String()); + } + return res; + } + + /// Cheap structural equality between two envelopes — enough to decide + /// whether a pull changed anything worth pushing back. Compares the + /// encoded form; envelope encode is deterministic. + bool _envelopeEquals(SyncEnvelope a, SyncEnvelope b) { + if (a.accounts.length != b.accounts.length) return false; + if (a.tombstones.length != b.tombstones.length) return false; + // Account order is platform-stable within an envelope. + for (var i = 0; i < a.accounts.length; i++) { + final ax = a.accounts[i], bx = b.accounts[i]; + if (ax.platform != bx.platform) return false; + if (ax.token != bx.token || + ax.account != bx.account || + ax.updatedAt != bx.updatedAt || + ax.deviceId != bx.deviceId) { + return false; + } + } + for (var i = 0; i < a.tombstones.length; i++) { + final ta = a.tombstones[i], tb = b.tombstones[i]; + if (ta.platform != tb.platform || + ta.deletedAt != tb.deletedAt || + ta.deviceId != tb.deviceId) { + return false; + } + } + return true; + } + /// First-time setup on a device that has no cloud blob yet: derive a key /// from [password] (fresh salt), cache it, and push current bindings. Future setupWithMasterPassword(String password) async { @@ -450,7 +557,7 @@ class SyncService extends ChangeNotifier { message: '云端已存在备份,请改用「恢复」并输入主密码', ); } - final payload = _serializeAccounts(); + final payload = _serializeEnvelope(); final blob = await SyncCrypto.encryptWithSalt(payload, password); final salt = SyncCrypto.extractSalt(blob)!; final key = await SyncCrypto.deriveKey(password, salt); @@ -472,7 +579,10 @@ class SyncService extends ChangeNotifier { } /// Restore on a device that has a cloud blob but no cached key: verify - /// [password] decrypts the blob, cache the key, pull bindings. + /// [password] decrypts the blob, cache the key, then merge (same LWW as + /// [pull]) so a non-empty local state is not blindly overwritten. On a + /// truly fresh device the local side is empty and the merge result equals + /// the cloud snapshot. Future restoreWithMasterPassword(String password) async { final blob = await _readBlob(); if (blob == null) { @@ -485,13 +595,30 @@ class SyncService extends ChangeNotifier { final salt = SyncCrypto.extractSalt(blob)!; final key = await SyncCrypto.deriveKey(password, salt); await _cacheKey(CachedSyncKey(salt, key)); - final accounts = _parseAccounts(plain); - await _tpAuth.replaceAll(accounts); + final remote = SyncEnvelope.decode(plain); + if (remote == null) { + return const SyncOutcome(ok: false, message: '云端备份格式损坏'); + } + final local = SyncEnvelope.fromLocal( + accounts: _tpAuth.accounts, + tombstones: _tombstones, + ); + final merged = local.mergeWith(remote); + final removed = ThirdPartyPlatform.values + .where((p) => !merged.accounts.any((a) => a.platform == p)) + .toSet(); + await _tpAuth.applySyncMerge(merged.accounts, removed); + _tombstones + ..clear() + ..addAll(merged.tombstones); await _storage.setSyncEnabled(true); _lastSyncAt = DateTime.now(); await _storage.setSyncLastAt(_lastSyncAt!.toIso8601String()); _needsRestore = false; _lastError = null; + // Push the merged envelope back so the cloud reflects this device's + // local winners (and so other devices converge on the next pull). + await _writeMergedEnvelope(merged); notifyListeners(); return const SyncOutcome(ok: true, message: '已从云端恢复绑定'); } @@ -539,6 +666,7 @@ class SyncService extends ChangeNotifier { return SyncOutcome(ok: false, message: res.describe('清除云端备份失败')); } await _clearCachedKey(); + _tombstones.clear(); await _storage.setSyncEnabled(false); await _storage.setSyncLastAt(''); _lastSyncAt = null; diff --git a/lib/services/third_party_auth_service.dart b/lib/services/third_party_auth_service.dart index 387c163..3743141 100644 --- a/lib/services/third_party_auth_service.dart +++ b/lib/services/third_party_auth_service.dart @@ -23,11 +23,15 @@ class ThirdPartyAuthService extends ChangeNotifier { final LoggingHttpClient _http; bool _initialized = false; - + bool _suppressSyncPush = false; // SMS context for cpdaily binding flow (set by sendCpdailySmsCode). Map? _cpdailySmsContext; late final SessionTree _tree; + // Stable per-device id, loaded in [initialize]. Stamped onto every locally + // mutated account so the cloud-sync LWW merge converges. + String _deviceId = ''; + ThirdPartyAuthService(this._storage, this._http) { _tree = SessionTree( @@ -91,11 +95,20 @@ class ThirdPartyAuthService extends ChangeNotifier { /// wants the push to bypass the throttle so a removal is immediately /// reflected in the cloud blob. Future Function({bool force})? onBindingsChanged; + /// Hook fired when a platform is deliberately unbound. Wired by + /// [SyncService] to record a tombstone so the deletion survives the next + /// LWW merge (instead of being resurrected by an older remote copy). + /// Null until wired; safe to call. + void Function(ThirdPartyPlatform platform)? onUnbind; void _onTreeChanged({bool force = false}) { // A node changed (renew / setAccount) — re-notify our listeners and push // to cloud sync. This is the single funnel for all mutations now. notifyListeners(); + // Skip the cloud-push hook while [applySyncMerge] is replaying a merged + // snapshot — the sync path writes the merged envelope itself, so a + // throttled pushIfDue here would be redundant (and could race). + if (_suppressSyncPush) return; final hook = onBindingsChanged; if (hook != null) { unawaited(hook(force: force)); @@ -106,10 +119,13 @@ class ThirdPartyAuthService extends ChangeNotifier { /// matching [SessionNode]'s in-memory state. Installed as the tree's /// [PersistAccount] callback so node-initiated renews flow through here. Future _persistAccount(ThirdPartyAccount updated) async { - await _storage.saveThirdPartyAccount(updated); - // The node already updated its own _account before calling persist; we - // only need storage + notification here (notification fires via the tree - // listener → _onTreeChanged). + // Stamp the renewed/refreshed account with this device's id + a fresh + // updatedAt so the cloud-sync LWW merge treats it as the newest version. + final touched = _touch(updated); + await _storage.saveThirdPartyAccount(touched); + // Re-sync the node's in-memory copy so UI + cookieProvider see the + // stamped version. setAccount notifies; _onTreeChanged funnels it. + _tree.setAccount(touched.platform, touched); } /// Persist/clear a child node's derived cookie. Installed as the tree's @@ -123,6 +139,11 @@ class ThirdPartyAuthService extends ChangeNotifier { } } + /// Stamp [acc] with this device's id + current time, for LWW merge. + ThirdPartyAccount _touch(ThirdPartyAccount acc) { + return acc.copyWith(updatedAt: DateTime.now(), deviceId: _deviceId); + } + // -- CpDaily binding (single source of CASTGC / CpDaily session) -- // // The cpdaily binding is the ONLY place CASTGC lives in the new @@ -153,6 +174,7 @@ class ThirdPartyAuthService extends ChangeNotifier { /// success the refreshed raw is persisted and listeners notified via the /// tree → [Service._onTreeChanged] path. Returns true on success. Future initialize() async { + _deviceId = await _storage.ensureDeviceId(); final loaded = await _storage.loadAllThirdPartyAccounts(); // Seed each node with its persisted account. setAccount routes to the // matching node and notifies (boot hydration — the sync hook is not wired @@ -241,9 +263,10 @@ class ThirdPartyAuthService extends ChangeNotifier { password: autoRenew ? password : null, ); - await _storage.saveThirdPartyAccount(acc); - _tree.setAccount(platform, acc); - return acc; + final touched = _touch(acc); + await _storage.saveThirdPartyAccount(touched); + _tree.setAccount(platform, touched); + return touched; } Future unbind(ThirdPartyPlatform platform) async { @@ -256,6 +279,10 @@ class ThirdPartyAuthService extends ChangeNotifier { await _storage.clearDerivedCookie(id); } } + // Record a tombstone for the cloud-sync merge so this deletion is not + // resurrected by an older remote copy on the next pull. + final unbindHook = onUnbind; + if (unbindHook != null) unbindHook(platform); // Force-push so the removal is immediately reflected in the cloud blob, // bypassing the pushIfDue throttle. Without this, a throttled skip would // leave the stale binding in the cloud and the next boot's pull would @@ -289,6 +316,59 @@ class ThirdPartyAuthService extends ChangeNotifier { } } + /// Apply a merged account set coming from the cloud-sync LWW merge. For + /// each platform: if a kept account is present, persist + set it; if the + /// platform is in [removed], clear it. Unlike [replaceAll], this does NOT + /// clear every platform first — it writes per-platform diffs and skips the + /// cloud-sync push hook (the sync path pushes the merged envelope itself), + /// avoiding a redundant throttled push right after an explicit one. + Future applySyncMerge( + List kept, + Set removed, + ) async { + _suppressSyncPush = true; + try { + final byPlatform = { + for (final a in kept) a.platform: a, + }; + for (final p in ThirdPartyPlatform.values) { + final next = byPlatform[p]; + final cur = _nodeAccount(p); + // Skip no-op writes: if the merged account equals the current one + // (same token, same updatedAt), don't touch storage or fire notifies. + if (next != null && cur != null && _accountEqual(cur, next)) continue; + if (next == null && cur == null) continue; + if (next != null) { + await _storage.saveThirdPartyAccount(next); + _tree.setAccount(p, next); + } else { + // Clearing cpdaily invalidates downstream derived cookies. + if (p == ThirdPartyPlatform.cpdaily && cur != null) { + for (final id in const ['eams', 'elearning']) { + _tree.setDerivedCookie(id, null); + await _storage.clearDerivedCookie(id); + } + } + _tree.setAccount(p, null); + await _storage.clearThirdPartyAccount(p); + } + } + } finally { + _suppressSyncPush = false; + } + // One notification for the whole merge. + notifyListeners(); + } + + /// Cheap structural equality used by [applySyncMerge] to skip no-op writes. + static bool _accountEqual(ThirdPartyAccount a, ThirdPartyAccount b) { + return a.token == b.token && + a.account == b.account && + a.expire == b.expire && + a.updatedAt == b.updatedAt && + a.deviceId == b.deviceId; + } + /// Update the raw data of a bound account (e.g. after CpDaily session renewal). Future updateRaw( ThirdPartyPlatform platform, @@ -296,23 +376,9 @@ class ThirdPartyAuthService extends ChangeNotifier { ) async { final acc = _nodeAccount(platform); if (acc == null) return; - final updated = ThirdPartyAccount( - platform: acc.platform, - account: acc.account, - sid: acc.sid, - name: acc.name, - email: acc.email, - token: acc.token, - expire: acc.expire, - raw: newRaw, - hydroOrigin: acc.hydroOrigin, - hydroDomains: acc.hydroDomains, - boundAt: acc.boundAt, - autoRenew: acc.autoRenew, - password: acc.password, - ); - await _storage.saveThirdPartyAccount(updated); - _tree.setAccount(platform, updated); + final touched = _touch(acc.copyWith(raw: newRaw)); + await _storage.saveThirdPartyAccount(touched); + _tree.setAccount(platform, touched); } // -- CpDaily SMS binding flow -- @@ -402,10 +468,11 @@ class ThirdPartyAuthService extends ChangeNotifier { autoRenew: false, // SMS binding does not support auto-renew ); - await _storage.saveThirdPartyAccount(acc); - _tree.setAccount(ThirdPartyPlatform.cpdaily, acc); + final touched = _touch(acc); + await _storage.saveThirdPartyAccount(touched); + _tree.setAccount(ThirdPartyPlatform.cpdaily, touched); _cpdailySmsContext = null; - return acc; + return touched; } /// Boot-time best-effort renewal: for each bound account whose token is @@ -447,7 +514,11 @@ class ThirdPartyAuthService extends ChangeNotifier { } Future clearAll() async { + final unbindHook = onUnbind; for (final p in ThirdPartyPlatform.values) { + if (_nodeAccount(p) != null) { + if (unbindHook != null) unbindHook(p); + } _tree.setAccount(p, null); } await _storage.clearAllThirdPartyAccounts(); diff --git a/test/sync_envelope_test.dart b/test/sync_envelope_test.dart new file mode 100644 index 0000000..bc0b5d7 --- /dev/null +++ b/test/sync_envelope_test.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/models/third_party_account.dart'; +import 'package:techpie/services/sync_envelope.dart'; + +ThirdPartyAccount _acc({ + required ThirdPartyPlatform platform, + String token = 't', + DateTime? updatedAt, + String deviceId = '', + DateTime? boundAt, +}) { + final b = boundAt ?? DateTime.utc(2026); + return ThirdPartyAccount( + platform: platform, + account: 'a', + token: token, + boundAt: b, + updatedAt: updatedAt ?? b, + deviceId: deviceId, + ); +} + +void main() { + group('SyncSchema.migrate', () { + test('v0 bare array is wrapped into a v2 envelope', () { + final v0 = jsonEncode([ + {'platform': 'gradescope', 'account': 'a', 'token': 't1'}, + ]); + final env = SyncEnvelope.decode(v0); + expect(env, isNotNull); + expect(env!.v, SyncSchema.current); + expect(env.accounts, hasLength(1)); + expect(env.accounts.first.platform, ThirdPartyPlatform.gradescope); + expect(env.accounts.first.token, 't1'); + expect(env.tombstones, isEmpty); + // v0 accounts have no updatedAt/deviceId → back-compat defaults. + expect(env.accounts.first.deviceId, ''); + }); + + test('v1 envelope (no tombstones) migrates to v2 with empty tombstones', () { + final v1 = jsonEncode({ + 'v': 1, + 'accounts': [ + {'platform': 'hydro', 'account': 'h', 'token': 'tok'}, + ], + }); + final env = SyncEnvelope.decode(v1); + expect(env!.v, SyncSchema.current); + expect(env.accounts.first.platform, ThirdPartyPlatform.hydro); + expect(env.tombstones, isEmpty); + }); + + test('v2 envelope round-trips accounts + tombstones', () { + final original = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'tgc', + updatedAt: DateTime.utc(2026, 1, 2), + deviceId: 'devA', + ), + ], + tombstones: [ + SyncTombstone( + platform: ThirdPartyPlatform.gradescope, + deletedAt: DateTime.utc(2026, 1, 3), + deviceId: 'devB', + ), + ], + ); + final decoded = SyncEnvelope.decode(original.encode()); + expect(decoded!.v, SyncSchema.current); + expect(decoded.accounts, hasLength(1)); + expect(decoded.accounts.first.token, 'tgc'); + expect(decoded.accounts.first.deviceId, 'devA'); + expect(decoded.tombstones, hasLength(1)); + expect(decoded.tombstones.first.platform, ThirdPartyPlatform.gradescope); + expect(decoded.tombstones.first.deviceId, 'devB'); + }); + + test('garbage plaintext returns null', () { + expect(SyncEnvelope.decode('not json'), isNull); + expect(SyncEnvelope.decode('123'), isNull); + }); + }); + + group('SyncEnvelope.mergeWith (LWW)', () { + test('newer local account wins over older remote', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.gradescope, + token: 'local-new', + updatedAt: DateTime.utc(2026, 1, 5), + deviceId: 'A', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.gradescope, + token: 'remote-old', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, hasLength(1)); + expect(merged.accounts.first.token, 'local-new'); + }); + + test('remote account on a platform absent locally is adopted', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: const [], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.hydro, + token: 'remote-only', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, hasLength(1)); + expect(merged.accounts.first.token, 'remote-only'); + }); + + test('tombstone newer than account removes the account', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: const [], + tombstones: [ + SyncTombstone( + platform: ThirdPartyPlatform.gradescope, + deletedAt: DateTime.utc(2026, 1, 5), + deviceId: 'A', + ), + ], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.gradescope, + token: 'stale', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, isEmpty); + expect(merged.tombstones, hasLength(1)); + expect(merged.tombstones.first.platform, ThirdPartyPlatform.gradescope); + }); + + test('account newer than tombstone resurrects the account', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.hydro, + token: 'rebind', + updatedAt: DateTime.utc(2026, 1, 10), + deviceId: 'A', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: const [], + tombstones: [ + SyncTombstone( + platform: ThirdPartyPlatform.hydro, + deletedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, hasLength(1)); + expect(merged.accounts.first.token, 'rebind'); + // Old tombstone is dropped — the account won. + expect(merged.tombstones, isEmpty); + }); + + test('equal timestamps tie-break by deviceId (larger wins)', () { + final ts = DateTime.utc(2026, 1, 1); + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'A', + updatedAt: ts, + deviceId: 'aaa', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'B', + updatedAt: ts, + deviceId: 'zzz', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + // 'zzz' > 'aaa' → remote wins. + expect(merged.accounts.first.token, 'B'); + }); + + test('empty deviceId always loses to a real one', () { + final ts = DateTime.utc(2026, 1, 1); + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'legacy', + updatedAt: ts, + deviceId: '', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'real', + updatedAt: ts, + deviceId: 'dev', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts.first.token, 'real'); + }); + + test('independent platforms on both sides are both kept', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.gradescope, + token: 'gs', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'A', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.hydro, + token: 'hy', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, hasLength(2)); + final platforms = merged.accounts.map((a) => a.platform).toSet(); + expect(platforms, contains(ThirdPartyPlatform.gradescope)); + expect(platforms, contains(ThirdPartyPlatform.hydro)); + }); + }); +} diff --git a/test/sync_service_test.dart b/test/sync_service_test.dart index 7ed9cc2..2fc6864 100644 --- a/test/sync_service_test.dart +++ b/test/sync_service_test.dart @@ -124,6 +124,79 @@ void main() { throwsA(isA()), ); }); + // -- LWW merge behavior (the bug these guard against) ---------------------- + + test( + 'pull does NOT overwrite a locally-newer binding with an older cloud copy', + () async { + // Device A: set up sync with a gradescope binding (old updatedAt). + final fx = await _Fixture.withSession(); + await fx.tpAuth.replaceAll([ + ThirdPartyAccount( + platform: ThirdPartyPlatform.gradescope, + account: 'a@b', + token: 'cloud-old', + boundAt: DateTime.utc(2026, 1, 1), + ), + ]); + await fx.sync.setupWithMasterPassword('pw'); + + // Device B: restore, then locally rebind a NEWER token. + final fx2 = await _Fixture.withSession(server: fx.server); + await fx2.sync.restoreWithMasterPassword('pw'); + // Bump the local binding's updatedAt to "now" via the real bind path. + await fx2.tpAuth.replaceAll([ + ThirdPartyAccount( + platform: ThirdPartyPlatform.gradescope, + account: 'a@b', + token: 'local-new', + boundAt: DateTime.utc(2026, 1, 10), + updatedAt: DateTime.utc(2026, 1, 10), + deviceId: 'devB', + ), + ]); + // Manually stamp deviceId on device 2 so the touch helper works. The + // fixture loads deviceId lazily; ensureDeviceId already ran in init. + + // Pull from cloud (which still has 'cloud-old'). The merge must keep + // 'local-new' because its updatedAt is newer. + await fx2.sync.pull(); + expect( + fx2.tpAuth.account(ThirdPartyPlatform.gradescope)?.token, + 'local-new', + reason: 'a newer local binding must survive a pull of older cloud data', + ); + }); + + test( + 'a deletion (tombstone) on device A is not resurrected when device B pulls', + () async { + // Device A: bind gradescope, set up sync. + final fx = await _Fixture.withSession(); + await fx.tpAuth.replaceAll([ + ThirdPartyAccount( + platform: ThirdPartyPlatform.gradescope, + account: 'a@b', + token: 't', + boundAt: DateTime.utc(2026, 1, 1), + ), + ]); + await fx.sync.setupWithMasterPassword('pw'); + + // Device A: unbind gradescope. This records a tombstone + force-pushes. + await fx.tpAuth.unbind(ThirdPartyPlatform.gradescope); + // Cloud blob now carries a tombstone, no gradescope account. + + // Device B: restore (gets the post-deletion state) — should have no + // gradescope binding. + final fx2 = await _Fixture.withSession(server: fx.server); + await fx2.sync.restoreWithMasterPassword('pw'); + expect( + fx2.tpAuth.account(ThirdPartyPlatform.gradescope), + isNull, + reason: 'tombstone on device A must remove the binding on device B', + ); + }); } // --------------------------------------------------------------------------- @@ -227,6 +300,11 @@ class _Fixture { final auth = AuthService(storage, httpClient, uniAuth); final tpAuth = ThirdPartyAuthService(storage, httpClient); final sync = SyncService(auth, tpAuth, storage, client: srv.toHttpClient()); + // Mirror main.dart wiring so tombstones are recorded + pushes fire. + tpAuth.onBindingsChanged = ({force = false}) { + return force ? sync.forcePush() : sync.pushIfDue(); + }; + tpAuth.onUnbind = sync.recordTombstone; await auth.loadSession(); await tpAuth.initialize(); await sync.loadCachedKey(); From c99b6828df0600e9c984c6721d58d74c33f7a59a Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 14:05:34 +0800 Subject: [PATCH 7/9] fix: eliminate notify storms in renew and applySyncMerge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two redundant-notify cascades found by auditing all notifyListeners → refetch paths: 1. SessionNode.renew double-notify: persist()→setAccount fired notify #1, then markRenewed() fired notify #2 on the same node. Each cascaded through SessionTree → tpAuth → AssignmentService.fetchAssignments + SyncService.pushIfDue. Fix: markRenewed no longer notifies the node itself (persist→setAccount already did); child-renew paths (_renewWithParentCookie) now notify explicitly after markRenewed. 2. applySyncMerge N+1 notify: _suppressSyncPush only blocked the push hook, not notifyListeners(). Each setAccount in the merge loop fired _onTreeChanged → notifyListeners → AssignmentService refetch attempt. Fix: _suppressSyncPush now suppresses ALL downstream effects (notify + push hook); applySyncMerge notifies once at the end. --- lib/services/session/session_node.dart | 15 +++++++++++---- lib/services/third_party_auth_service.dart | 10 ++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/services/session/session_node.dart b/lib/services/session/session_node.dart index 00db4aa..cf9010a 100644 --- a/lib/services/session/session_node.dart +++ b/lib/services/session/session_node.dart @@ -224,9 +224,14 @@ class SessionNode extends ChangeNotifier { /// this when reading cookies and pass it to [renewIfNeeded]. int get epoch => _epoch; - /// Bump epoch, cascade to children (clear their derived cookies), and - /// notify. Called after persisting refreshed credentials (top-level) or - /// minting a downstream cookie (non-top-level). + /// Bump epoch and cascade to children (clear their derived cookies). + /// Does NOT call notifyListeners() on this node — the caller is + /// responsible for notifying: top-level nodes are notified by + /// [persist]→[setAccount] (which fires before this), and non-top-level + /// nodes call notifyListeners() explicitly after this. This avoids a + /// double-notify storm where both persist and markRenewed fire on the + /// same node, each cascading through SessionTree → ThirdPartyAuthService + /// → AssignmentService.fetchAssignments + SyncService.pushIfDue. @protected void markRenewed() { _epoch++; @@ -234,7 +239,6 @@ class SessionNode extends ChangeNotifier { for (final child in _children) { child.onParentRenewed(); } - notifyListeners(); } /// Called by the parent's [markRenewed] when the parent's credentials @@ -409,7 +413,10 @@ class SessionNode extends ChangeNotifier { if (pd != null) { unawaited(pd(id, cookie)); } + // markRenewed cascades to children (none here) but does NOT notify + // this node — notify explicitly for the downstream cookie change. markRenewed(); + notifyListeners(); return true; } catch (_) { _lastRenewWasCredentialError = false; diff --git a/lib/services/third_party_auth_service.dart b/lib/services/third_party_auth_service.dart index 3743141..6d6fe4e 100644 --- a/lib/services/third_party_auth_service.dart +++ b/lib/services/third_party_auth_service.dart @@ -102,13 +102,15 @@ class ThirdPartyAuthService extends ChangeNotifier { void Function(ThirdPartyPlatform platform)? onUnbind; void _onTreeChanged({bool force = false}) { + // While [applySyncMerge] is replaying a merged snapshot, suppress ALL + // downstream effects — the merge path notifies once at the end and + // writes the merged envelope itself. This avoids an N+1 notify storm + // (one per setAccount) that would each cascade into + // AssignmentService.fetchAssignments + SyncService.pushIfDue. + if (_suppressSyncPush) return; // A node changed (renew / setAccount) — re-notify our listeners and push // to cloud sync. This is the single funnel for all mutations now. notifyListeners(); - // Skip the cloud-push hook while [applySyncMerge] is replaying a merged - // snapshot — the sync path writes the merged envelope itself, so a - // throttled pushIfDue here would be redundant (and could race). - if (_suppressSyncPush) return; final hook = onBindingsChanged; if (hook != null) { unawaited(hook(force: force)); From 2623a6418b9ded9d141f8158536f68d19d7be13c Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 14:31:21 +0800 Subject: [PATCH 8/9] fix: defer exam_table refetch until after course_table primes EAMS session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching semesters fired fetchAssignments (exam_table) concurrently with fetchCourseTable. Both hit courseTableForStd.action on EAMS's stateful Spring/Struts session; concurrent access returns a partially initialized page → 'Failed to extract numeric ids' on exam_table, then succeeds on retry (the first course_table request primed the session). On boot this didn't happen because main.dart awaits fetchAll() before fetchAssignments() — sequential, not concurrent. Fix: selectSemester sets _suppressAssignmentRefetch during its own network fetch. AssignmentService._onScheduleChanged checks the flag and skips the refetch. When selectSemester's fetch completes, it clears the flag and fires one final notify — AssignmentService refetches then, with the EAMS session already primed by fetchCourseTable. exam_table succeeds on the first try. --- lib/services/assignment_service.dart | 44 +++++++++++++++++++++++----- lib/services/schedule_service.dart | 28 ++++++++++++++++-- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/lib/services/assignment_service.dart b/lib/services/assignment_service.dart index 9bbe384..5808150 100644 --- a/lib/services/assignment_service.dart +++ b/lib/services/assignment_service.dart @@ -61,30 +61,58 @@ class AssignmentService extends ChangeNotifier { // Refetch when bindings or auth change *after* initial app boot. // The initial fetch is kicked off explicitly from main.dart so we // don't double-fire during service initialization. - _tpAuth.addListener(_onDepsChanged); - _auth.addListener(_onDepsChanged); - _schedule.addListener(_onDepsChanged); + _tpAuth.addListener(_onBindingsOrAuthChanged); + _auth.addListener(_onBindingsOrAuthChanged); + _schedule.addListener(_onScheduleChanged); } bool _autoRefetchEnabled = false; + // Track the last semester we refetched for, so schedule notifies that + // don't change the semester (loading flips, course_table updates, errors) + // do NOT trigger a redundant assignment refetch. + String? _lastRefetchedSemesterId; /// Allow auto-refetch on auth/binding changes. Call after the first /// explicit fetch from app boot has been kicked off. - void enableAutoRefetch() => _autoRefetchEnabled = true; + void enableAutoRefetch() { + _autoRefetchEnabled = true; + // Seed so the first schedule notify (which doesn't change the semester) + // doesn't trigger a redundant refetch of the same semester. + _lastRefetchedSemesterId = _schedule.selectedSemesterId; + } + + /// Auth or binding changed — always refetch (tokens, accounts differ). + void _onBindingsOrAuthChanged() { + if (!_autoRefetchEnabled) return; + _lastRefetchedSemesterId = _schedule.selectedSemesterId; + unawaited(fetchAssignments()); + } - void _onDepsChanged() { + /// Schedule changed — only refetch if the selected semester actually + /// changed, not on every loading/error/course_table flip. This prevents + /// a cascade of redundant blackboard+exam fetches during a semester switch. + /// Also defers the refetch while selectSemester is mid-fetch (its + /// course_table request primes the EAMS session; firing exam_table + /// concurrently would race on EAMS's stateful session and fail with + /// "Failed to extract numeric ids"). + void _onScheduleChanged() { if (!_autoRefetchEnabled) return; + if (_schedule.suppressAssignmentRefetch) return; + final currentSemester = _schedule.selectedSemesterId; + if (currentSemester == _lastRefetchedSemesterId) return; + _lastRefetchedSemesterId = currentSemester; unawaited(fetchAssignments()); } @override void dispose() { - _tpAuth.removeListener(_onDepsChanged); - _auth.removeListener(_onDepsChanged); - _schedule.removeListener(_onDepsChanged); + _tpAuth.removeListener(_onBindingsOrAuthChanged); + _auth.removeListener(_onBindingsOrAuthChanged); + _schedule.removeListener(_onScheduleChanged); super.dispose(); } + /// Clear cached + in-memory deadlines (called on primary logout). Future clearCache() async { _assignments = []; diff --git a/lib/services/schedule_service.dart b/lib/services/schedule_service.dart index 45d70c8..1fc813a 100644 --- a/lib/services/schedule_service.dart +++ b/lib/services/schedule_service.dart @@ -23,6 +23,11 @@ class ScheduleService extends ChangeNotifier { String? _selectedSemesterId; bool _loading = false; String? _error; + // While true, AssignmentService should NOT refetch on our notifies — + // selectSemester sets this during its own network fetch to avoid a + // concurrent exam_table + course_table race on EAMS's stateful session. + bool _suppressAssignmentRefetch = false; + bool get suppressAssignmentRefetch => _suppressAssignmentRefetch; String get _baseUrl => apiBaseUrl(_storage); @@ -183,16 +188,28 @@ class ScheduleService extends ChangeNotifier { } Future selectSemester(String semesterId) async { + // No-op if the semester is already selected. + if (_selectedSemesterId == semesterId) return; _selectedSemesterId = semesterId; await _storage.setSelectedSemester(semesterId); - notifyListeners(); - // Load cached data for new semester first + // Load cached data for the new semester so the UI updates instantly. _courseTable = _storage.loadCourseTable(semesterId); _termBegin = _storage.loadTermBegin(semesterId); + // Notify the cached-swap UI update. AssignmentService._onScheduleChanged + // sees the semester changed and would fire fetchAssignments here — but + // that would race our own fetchCourseTable below (both hit + // courseTableForStd.action on the same EAMS session, and concurrent + // access to EAMS's stateful Spring/Struts session returns a partially + // initialized page → "Failed to extract numeric ids"). We suppress the + // assignment refetch during our own fetch and fire it once at the end. + _suppressAssignmentRefetch = true; notifyListeners(); - if (!_hasCpdailyBinding) return; + if (!_hasCpdailyBinding) { + _suppressAssignmentRefetch = false; + return; + } _loading = true; _error = null; @@ -207,6 +224,11 @@ class ScheduleService extends ChangeNotifier { _error = e.toString(); } finally { _loading = false; + _suppressAssignmentRefetch = false; + // This final notify fires _onScheduleChanged again. Because + // _suppressAssignmentRefetch is now false, AssignmentService will + // refetch (the EAMS session is primed by our fetchCourseTable above, + // so exam_table succeeds on the first try). notifyListeners(); } } From 16d504b280960ebd0f19e94c472c9cf9d5b4c191 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 18:50:28 +0800 Subject: [PATCH 9/9] fix: resolve analyzer lints (doc-comment angle brackets, const constructor) Co-Authored-By: Claude Fable 5 --- lib/services/session/session_node.dart | 4 ++-- test/sync_envelope_test.dart | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/services/session/session_node.dart b/lib/services/session/session_node.dart index cf9010a..3d98dec 100644 --- a/lib/services/session/session_node.dart +++ b/lib/services/session/session_node.dart @@ -34,11 +34,11 @@ enum RenewMode { /// sessionToken/tgc/userId/tenantId. Top-level only. cpdailySession, - /// Password re-authentication: POST /auth/third-party/ with + /// Password re-authentication: POST /auth/third-party/`` with /// account/password. Top-level only (gradescope, hydro). password, - /// Downstream cookie minting: POST /auth/third-party/ with the parent + /// Downstream cookie minting: POST /auth/third-party/`` with the parent /// node's tgc. Non-top-level only (eams, elearning). parentCookie, } diff --git a/test/sync_envelope_test.dart b/test/sync_envelope_test.dart index bc0b5d7..dde6212 100644 --- a/test/sync_envelope_test.dart +++ b/test/sync_envelope_test.dart @@ -119,10 +119,10 @@ void main() { }); test('remote account on a platform absent locally is adopted', () { - final local = SyncEnvelope( + final local = const SyncEnvelope( v: SyncSchema.current, - accounts: const [], - tombstones: const [], + accounts: [], + tombstones: [], ); final remote = SyncEnvelope( v: SyncSchema.current,