From 72704ddebbef048325e02d7bf1d5ead21a2c78fa Mon Sep 17 00:00:00 2001 From: lowrt Date: Wed, 9 Sep 2026 18:52:08 +0800 Subject: [PATCH 1/3] fix(eew): continue replay when historical RTS data is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正歷史 RTS 資料不存在時地震重播無法繼續 Fix(en-US): fix earthquake replay stopping when historical RTS data is unavailable --- lib/core/network/api_exception.dart | 12 +++++++++--- lib/core/realtime/realtime_channel.dart | 3 +++ lib/core/realtime/realtime_source.dart | 6 ++++++ lib/features/earthquake/data/rts_replay_source.dart | 7 ++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart index d46f47dfa..b6db943c0 100644 --- a/lib/core/network/api_exception.dart +++ b/lib/core/network/api_exception.dart @@ -10,15 +10,21 @@ import 'package:dpip/core/logging/log.dart'; /// every repository method is a one-liner and none can accidentally forget the /// `try` or return `Ok` on failure — which for a safety feed would turn a dead /// source into a false all-clear. -Future> guardResult(Future Function() body) async { +Future> guardResult( + Future Function() body, { + bool Function(Failure failure)? shouldLog, +}) async { try { return Ok(await body()); } catch (error, stackTrace) { // Silent failures are the worst kind: the UI shows its error state and // nothing else records WHY. One line per failure, here at the single // choke point every repository passes through. - Log.handle(error, stackTrace, 'repository fetch/decode'); - return Err(mapException(error)); + final failure = mapException(error); + if (shouldLog?.call(failure) ?? true) { + Log.handle(error, stackTrace, 'repository fetch/decode'); + } + return Err(failure); } } diff --git a/lib/core/realtime/realtime_channel.dart b/lib/core/realtime/realtime_channel.dart index 27c68c168..40864f6c3 100644 --- a/lib/core/realtime/realtime_channel.dart +++ b/lib/core/realtime/realtime_channel.dart @@ -234,6 +234,9 @@ class RealtimeChannel implements RealtimeChannelBase { ); if (changed) _publish(); case Err(:final failure): + if (_source.isIgnorableFailure(failure)) { + return; + } final status = _classify(); final changed = status != _current.status; _current = _current.copyWith( diff --git a/lib/core/realtime/realtime_source.dart b/lib/core/realtime/realtime_source.dart index 12a7710e2..9b17d30f0 100644 --- a/lib/core/realtime/realtime_source.dart +++ b/lib/core/realtime/realtime_source.dart @@ -1,5 +1,7 @@ import 'package:dpip/core/error/result.dart'; +import '../error/failure.dart'; + /// The transport + freshness-reference seam a [RealtimeChannel] polls. /// /// One implementation per feed (EEW now, RTS later). Implementing this is the @@ -23,6 +25,10 @@ abstract class RealtimeSource { /// whose default `==` is identity (e.g. `List`). bool sameData(T? a, T? b) => identical(a, b) || a == b; + /// Returns true when a fetch failure means there is simply no data + /// for the requested point in time, rather than a realtime failure. + bool isIgnorableFailure(Failure failure) => false; + /// Drops any transport the source is holding open while the app is in the /// background, where nothing is watching the feed. /// diff --git a/lib/features/earthquake/data/rts_replay_source.dart b/lib/features/earthquake/data/rts_replay_source.dart index d7f0687d2..55cc1202f 100644 --- a/lib/features/earthquake/data/rts_replay_source.dart +++ b/lib/features/earthquake/data/rts_replay_source.dart @@ -5,6 +5,8 @@ import 'package:dpip/core/realtime/replay_clock.dart'; import 'package:dpip/features/earthquake/data/earthquake_api.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; +import '../../../core/error/failure.dart'; + /// Replays the RTS feed at a fixed point in the past — a plain **polling** /// source (not SSE): each [fetch] asks [EarthquakeApi.getRtsAt] for the /// snapshot at the current second of [clock], which ticks 1:1 with real time @@ -25,7 +27,10 @@ class RtsReplaySource extends RealtimeSource { final seconds = clock.now().millisecondsSinceEpoch ~/ 1000; final json = await _api.getRtsAt(seconds); return Rts.fromJson(json as Map); - }); + }, shouldLog: (failure) => failure is! NotFoundFailure); + + @override + bool isIgnorableFailure(Failure failure) => failure is NotFoundFailure; /// Null: freshness is "did the last poll succeed", not payload age — the /// payload's own [Rts.time] is *intentionally* historical, so keying off it From b47cad9d3efb45b8ebce91736fa7fc7399685351 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 10 Sep 2026 11:03:27 +0800 Subject: [PATCH 2/3] fix(eew): say an old replay is replaying, not disconnected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 重播較舊的地震時,狀態列不再寫「連線中斷」,改成「重播中」 Fix(en-US): replaying an older earthquake no longer reads "connection lost" --- lib/core/network/api_exception.dart | 9 ++- lib/core/realtime/realtime_channel.dart | 40 +++++++++++--- lib/core/realtime/realtime_source.dart | 19 +++++-- .../earthquake/data/rts_replay_source.dart | 11 +++- .../pages/report_replay_page.dart | 18 ++++-- .../presentation/rts_realtime_controller.dart | 9 +++ lib/l10n/app_en.arb | 4 ++ lib/l10n/app_fil.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_yue.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hans.arb | 1 + lib/l10n/app_zh_Hant_HK.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + lib/l10n/gen/app_localizations.dart | 6 ++ lib/l10n/gen/app_localizations_en.dart | 3 + lib/l10n/gen/app_localizations_fil.dart | 3 + lib/l10n/gen/app_localizations_id.dart | 3 + lib/l10n/gen/app_localizations_ja.dart | 3 + lib/l10n/gen/app_localizations_ko.dart | 3 + lib/l10n/gen/app_localizations_th.dart | 3 + lib/l10n/gen/app_localizations_vi.dart | 3 + lib/l10n/gen/app_localizations_yue.dart | 3 + lib/l10n/gen/app_localizations_zh.dart | 12 ++++ test/core/network/api_exception_test.dart | 38 +++++++++++++ test/core/realtime/realtime_channel_test.dart | 55 +++++++++++++++++++ 30 files changed, 233 insertions(+), 23 deletions(-) diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart index b6db943c0..83ef63c80 100644 --- a/lib/core/network/api_exception.dart +++ b/lib/core/network/api_exception.dart @@ -10,6 +10,12 @@ import 'package:dpip/core/logging/log.dart'; /// every repository method is a one-liner and none can accidentally forget the /// `try` or return `Ok` on failure — which for a safety feed would turn a dead /// source into a false all-clear. +/// +/// [shouldLog] is the narrow exception to the logging below, for a caller whose +/// failure is an expected shape rather than a fault — a replay polling past the +/// end of a feed's retention, say, where the 404 arrives once a second for the +/// whole session. It drops the log line only; the [Err] is returned either way, +/// so no caller can mistake a suppressed log for a success. Future> guardResult( Future Function() body, { bool Function(Failure failure)? shouldLog, @@ -19,7 +25,8 @@ Future> guardResult( } catch (error, stackTrace) { // Silent failures are the worst kind: the UI shows its error state and // nothing else records WHY. One line per failure, here at the single - // choke point every repository passes through. + // choke point every repository passes through — unless the caller has + // said this particular failure is expected (see [shouldLog]). final failure = mapException(error); if (shouldLog?.call(failure) ?? true) { Log.handle(error, stackTrace, 'repository fetch/decode'); diff --git a/lib/core/realtime/realtime_channel.dart b/lib/core/realtime/realtime_channel.dart index 40864f6c3..fabc938f3 100644 --- a/lib/core/realtime/realtime_channel.dart +++ b/lib/core/realtime/realtime_channel.dart @@ -224,6 +224,10 @@ class RealtimeChannel implements RealtimeChannelBase { final status = _classify(); final changed = status != _current.status || + // A recovery is always worth telling: the state being replaced + // carries the reason the feed was empty, which the replay page + // shows even while the status word itself hasn't moved. + _current.lastFailure != null || !_source.sameData(value, _current.data); _current = RealtimeState( status: status, @@ -234,20 +238,38 @@ class RealtimeChannel implements RealtimeChannelBase { ); if (changed) _publish(); case Err(:final failure): - if (_source.isIgnorableFailure(failure)) { - return; - } + // "There is no data for that instant" is an answer, not a fault. It + // is still *recorded* — a replay page reads it to say "重播中" rather + // than "連線中斷" — but it is not counted and not logged: the poll + // runs at 1 Hz, so an old replay would otherwise file one crash + // report a second for its whole length. + // + // Freshness is untouched either way. The tick that preceded this + // fetch already aged the status (see [_onTick]), so an ignored + // failure still reaches stale and then offline on schedule — nothing + // here can hold a feed that is receiving nothing at `live`. + final ignorable = _source.isIgnorableFailure(failure); final status = _classify(); - final changed = status != _current.status; + // A change of failure *kind* is published even when the status word + // hasn't moved, because that is what the replay page switches on. + // Repeats of one kind are not: at 1 Hz that would be a rebuild a + // second for the length of an outage. + final changed = + status != _current.status || + _current.lastFailure.runtimeType != failure.runtimeType; _current = _current.copyWith( status: status, lastFailure: failure, - consecutiveFailures: _current.consecutiveFailures + 1, - ); - Log.warning( - '[$_label] poll failed ' - '(${_current.consecutiveFailures}×): ${failure.message}', + consecutiveFailures: ignorable + ? _current.consecutiveFailures + : _current.consecutiveFailures + 1, ); + if (!ignorable) { + Log.warning( + '[$_label] poll failed ' + '(${_current.consecutiveFailures}×): ${failure.message}', + ); + } if (changed) _publish(); } } catch (error, stackTrace) { diff --git a/lib/core/realtime/realtime_source.dart b/lib/core/realtime/realtime_source.dart index 9b17d30f0..707a104a3 100644 --- a/lib/core/realtime/realtime_source.dart +++ b/lib/core/realtime/realtime_source.dart @@ -1,7 +1,6 @@ +import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; -import '../error/failure.dart'; - /// The transport + freshness-reference seam a [RealtimeChannel] polls. /// /// One implementation per feed (EEW now, RTS later). Implementing this is the @@ -25,8 +24,20 @@ abstract class RealtimeSource { /// whose default `==` is identity (e.g. `List`). bool sameData(T? a, T? b) => identical(a, b) || a == b; - /// Returns true when a fetch failure means there is simply no data - /// for the requested point in time, rather than a realtime failure. + /// Whether a fetch failure means there is simply no data for the requested + /// point in time, rather than a fault worth counting. The channel still + /// records it as `lastFailure` — that is what a replay page reads to say the + /// instant has no snapshot instead of calling itself disconnected — but does + /// not count it toward `consecutiveFailures` and does not log it. + /// + /// **A noise switch, not a liveness one.** Freshness is unaffected either + /// way: the channel ages its status from elapsed time alone, so a source that + /// ignores every failure still goes stale and then offline on schedule and + /// nothing here can present a dead feed as current. + /// + /// Only a replay source has cause to override it — it polls a fixed instant + /// in the past, where "this far back is no longer retained" is an answer, not + /// a fault. For a live feed every failure is a real one; leave this alone. bool isIgnorableFailure(Failure failure) => false; /// Drops any transport the source is holding open while the app is in the diff --git a/lib/features/earthquake/data/rts_replay_source.dart b/lib/features/earthquake/data/rts_replay_source.dart index 55cc1202f..caee7b2d4 100644 --- a/lib/features/earthquake/data/rts_replay_source.dart +++ b/lib/features/earthquake/data/rts_replay_source.dart @@ -1,3 +1,4 @@ +import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_exception.dart'; import 'package:dpip/core/realtime/realtime_source.dart'; @@ -5,8 +6,6 @@ import 'package:dpip/core/realtime/replay_clock.dart'; import 'package:dpip/features/earthquake/data/earthquake_api.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; -import '../../../core/error/failure.dart'; - /// Replays the RTS feed at a fixed point in the past — a plain **polling** /// source (not SSE): each [fetch] asks [EarthquakeApi.getRtsAt] for the /// snapshot at the current second of [clock], which ticks 1:1 with real time @@ -27,8 +26,14 @@ class RtsReplaySource extends RealtimeSource { final seconds = clock.now().millisecondsSinceEpoch ~/ 1000; final json = await _api.getRtsAt(seconds); return Rts.fromJson(json as Map); - }, shouldLog: (failure) => failure is! NotFoundFailure); + }, shouldLog: (failure) => !isIgnorableFailure(failure)); + /// A 404 is the ordinary shape of an old replay, not a fault: RTS snapshots + /// are retained for far less time than the EEW history, so an event old + /// enough (0403, say) still has alerts to replay and no shaking left to draw. + /// Counted as a failure it would be one crash report and one log line **per + /// second** for the whole session — the poll runs at 1 Hz, which never trips + /// `Log`'s repeat suppression (8 within 5s). @override bool isIgnorableFailure(Failure failure) => failure is NotFoundFailure; diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 9e347d8ac..53e8c96b4 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -1342,12 +1342,18 @@ class _ReplayStatusBar extends StatelessWidget { final taipeiTime = AppTime.taipei(clock.now()); final timeText = _clockFormat.format(taipeiTime); - final (Color dot, String? statusWord) = switch (rts.status) { - RealtimeStatus.live => (Colors.green, null), - RealtimeStatus.stale => (Colors.amber, l10n.feedStale), - RealtimeStatus.offline => (Colors.red, l10n.feedOffline), - RealtimeStatus.connecting => (Colors.grey, l10n.feedConnecting), - }; + // RTS snapshots age out of the server long before the EEW history does, so + // an old enough event replays as alerts over a map with no shaking on it. + // That feed is not broken and saying "連線中斷" reads as a broken app — + // the replay is running, there is just nothing recorded that far back. + final (Color dot, String? statusWord) = rts.isMissingHistory + ? (Colors.orange, l10n.feedReplaying) + : switch (rts.status) { + RealtimeStatus.live => (Colors.green, null), + RealtimeStatus.stale => (Colors.amber, l10n.feedStale), + RealtimeStatus.offline => (Colors.red, l10n.feedOffline), + RealtimeStatus.connecting => (Colors.grey, l10n.feedConnecting), + }; final alertCount = eew.alerts.length; final hasActiveEew = alertCount > 0; diff --git a/lib/features/earthquake/presentation/rts_realtime_controller.dart b/lib/features/earthquake/presentation/rts_realtime_controller.dart index ecda87ab7..6df35c7b9 100644 --- a/lib/features/earthquake/presentation/rts_realtime_controller.dart +++ b/lib/features/earthquake/presentation/rts_realtime_controller.dart @@ -1,3 +1,4 @@ +import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; @@ -29,4 +30,12 @@ class RtsRealtimeController extends RealtimeNotifier { /// Whether the feed has aged past the freshness threshold. bool get isStale => status == RealtimeStatus.stale; + + /// Whether the last poll found no snapshot for the instant it asked for. + /// + /// Only a replay reaches this: RTS snapshots are retained for far less time + /// than the EEW history, so an old enough event still has alerts to replay + /// and no shaking left to draw. The feed is not broken, so a UI must not call + /// it disconnected — there is simply nothing recorded that far back. + bool get isMissingHistory => state.lastFailure is NotFoundFailure; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b0bb8156a..b2018d793 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -831,6 +831,7 @@ }, "moreSectionLinks": "Links", "feedOffline": "Connection lost", + "feedReplaying": "Replaying", "mapLayerStyleBd": "Dvorak BD", "@mapLayerSatelliteB09": { "description": "Himawari mid-level water-vapour channel (B09, 6.9 µm) layer name" @@ -1136,6 +1137,9 @@ "@feedOffline": { "description": "Banner/headline when a realtime feed has gone offline" }, + "@feedReplaying": { + "description": "Status word on the replay page when the replayed instant is older than the RTS retention window: the replay is running, the server just has no shaking snapshot that far back. Never 'offline' — the feed is not broken" + }, "reportFilterIntensityInfoModernTitle": "Current (from 2020)", "@mapAppGoogleMaps": { "description": "External map app choice: Google Maps" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 02e6014d1..b38b0f46b 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -284,6 +284,7 @@ "reportListYesterday": "Kahapon", "moreSectionLinks": "Mga Link", "feedOffline": "Nawala ang koneksyon", + "feedReplaying": "Nire-replay", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "Display", "rainInterval3d": "3 araw", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 0b4c57c32..2860d3038 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -284,6 +284,7 @@ "reportListYesterday": "Kemarin", "moreSectionLinks": "Tautan", "feedOffline": "Koneksi terputus", + "feedReplaying": "Memutar ulang", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "Tampilan", "rainInterval3d": "3 hr", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index cfe219f27..0ebbe5d6f 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -284,6 +284,7 @@ "reportListYesterday": "昨日", "moreSectionLinks": "関連リンク", "feedOffline": "接続が切断されました", + "feedReplaying": "再生中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "表示", "rainInterval3d": "3日", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index a5f5e97e3..787c107f9 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -284,6 +284,7 @@ "reportListYesterday": "어제", "moreSectionLinks": "링크", "feedOffline": "연결이 끊어졌습니다", + "feedReplaying": "재생 중", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "표시", "rainInterval3d": "3일", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index b6c184c6d..705871ac6 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -284,6 +284,7 @@ "reportListYesterday": "เมื่อวาน", "moreSectionLinks": "ลิงก์ที่เกี่ยวข้อง", "feedOffline": "การเชื่อมต่อขาดหาย", + "feedReplaying": "กำลังเล่นซ้ำ", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "การแสดงผล", "rainInterval3d": "3 วัน", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 0e25756c1..13d337b5f 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -284,6 +284,7 @@ "reportListYesterday": "Hôm qua", "moreSectionLinks": "Liên kết", "feedOffline": "Mất kết nối", + "feedReplaying": "Đang phát lại", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "Hiển thị", "rainInterval3d": "3 ngày", diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 8df7c7431..4b05be91c 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -286,6 +286,7 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相關連結", "feedOffline": "連接中斷", + "feedReplaying": "重播緊", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "顯示", "rainInterval3d": "3 日", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 06cd6a355..3d94cd0fd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -284,6 +284,7 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相關連結", "feedOffline": "連線中斷", + "feedReplaying": "重播中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "顯示", "rainInterval3d": "3 日", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 318a6dff7..cb72be38e 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -284,6 +284,7 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相关链接", "feedOffline": "连接中断", + "feedReplaying": "重播中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "显示", "rainInterval3d": "3 日", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index d55860c70..cb9c46e94 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -284,6 +284,7 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相關連結", "feedOffline": "連接中斷", + "feedReplaying": "重播中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "顯示", "rainInterval3d": "3 日", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 712899bbf..b24e0505f 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -284,6 +284,7 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相關連結", "feedOffline": "連線中斷", + "feedReplaying": "重播中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "顯示", "rainInterval3d": "3 日", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 3061da64b..e9912f770 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1265,6 +1265,12 @@ abstract class AppLocalizations { /// **'Connection lost'** String get feedOffline; + /// Status word on the replay page when the replayed instant is older than the RTS retention window: the replay is running, the server just has no shaking snapshot that far back. Never 'offline' — the feed is not broken + /// + /// In en, this message translates to: + /// **'Replaying'** + String get feedReplaying; + /// Colour-style option: Dvorak BD curve stepped grayscale /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 3b682ae57..aeafddcc2 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -631,6 +631,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get feedOffline => 'Connection lost'; + @override + String get feedReplaying => 'Replaying'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 65604e5bc..5baed22f8 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -634,6 +634,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get feedOffline => 'Nawala ang koneksyon'; + @override + String get feedReplaying => 'Nire-replay'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index bfe1d624f..273b7834a 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -632,6 +632,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get feedOffline => 'Koneksi terputus'; + @override + String get feedReplaying => 'Memutar ulang'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index b2b84b383..1fbb99eae 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -623,6 +623,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get feedOffline => '接続が切断されました'; + @override + String get feedReplaying => '再生中'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index c9f516da2..8377fe20b 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -622,6 +622,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get feedOffline => '연결이 끊어졌습니다'; + @override + String get feedReplaying => '재생 중'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 4346de7fa..288d3b439 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -629,6 +629,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get feedOffline => 'การเชื่อมต่อขาดหาย'; + @override + String get feedReplaying => 'กำลังเล่นซ้ำ'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 9817eef11..9ab556fe5 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -629,6 +629,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get feedOffline => 'Mất kết nối'; + @override + String get feedReplaying => 'Đang phát lại'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 5a5af9485..27e8bbbac 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -619,6 +619,9 @@ class AppLocalizationsYue extends AppLocalizations { @override String get feedOffline => '連接中斷'; + @override + String get feedReplaying => '重播緊'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index e158b2952..3b965b2e1 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -619,6 +619,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get feedOffline => '連線中斷'; + @override + String get feedReplaying => '重播中'; + @override String get mapLayerStyleBd => 'Dvorak BD'; @@ -3854,6 +3857,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get feedOffline => '连接中断'; + @override + String get feedReplaying => '重播中'; + @override String get mapLayerStyleBd => 'Dvorak BD'; @@ -7089,6 +7095,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get feedOffline => '連接中斷'; + @override + String get feedReplaying => '重播中'; + @override String get mapLayerStyleBd => 'Dvorak BD'; @@ -10324,6 +10333,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get feedOffline => '連線中斷'; + @override + String get feedReplaying => '重播中'; + @override String get mapLayerStyleBd => 'Dvorak BD'; diff --git a/test/core/network/api_exception_test.dart b/test/core/network/api_exception_test.dart index 5cfc13ad2..799b3bd98 100644 --- a/test/core/network/api_exception_test.dart +++ b/test/core/network/api_exception_test.dart @@ -1,9 +1,22 @@ import 'package:dio/dio.dart'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/api_exception.dart'; import 'package:flutter_test/flutter_test.dart'; +Future> _throwing({bool Function(Failure)? shouldLog}) => + guardResult(() async { + throw DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(), + statusCode: 404, + ), + ); + }, shouldLog: shouldLog); + void main() { group('guardResult', () { test('returns Ok with the body value on success', () async { @@ -30,5 +43,30 @@ void main() { }); expect(result.failureOrNull, isA()); }); + + test('logs the failure by default', () async { + Log.talker.cleanHistory(); + final result = await _throwing(); + expect(result.failureOrNull, isA()); + expect(Log.talker.history, isNotEmpty); + }); + + test('shouldLog:false drops the log line but still returns Err', () async { + Log.talker.cleanHistory(); + Failure? seen; + final result = await _throwing( + shouldLog: (failure) { + seen = failure; + return false; + }, + ); + // The suppression is of the log line only — a caller can never mistake a + // quiet failure for a success. + expect(result, isA>()); + expect(result.failureOrNull, isA()); + // And it decides on the classified failure, not the raw exception. + expect(seen, isA()); + expect(Log.talker.history, isEmpty); + }); }); } diff --git a/test/core/realtime/realtime_channel_test.dart b/test/core/realtime/realtime_channel_test.dart index 8bcb24969..a1cfa1bed 100644 --- a/test/core/realtime/realtime_channel_test.dart +++ b/test/core/realtime/realtime_channel_test.dart @@ -53,6 +53,11 @@ class _FakeSource extends RealtimeSource { Result next = const Ok(0); Completer>? pending; + /// Only a replay source overrides this in production; here it lets a test + /// pick which failures the channel should treat as "no data for that + /// instant" rather than as a fault. + bool Function(Failure)? ignorable; + @override Future> fetch() async { fetchCount++; @@ -62,6 +67,9 @@ class _FakeSource extends RealtimeSource { @override DateTime? timestampOf(int value) => null; // fetch-freshness + + @override + bool isIgnorableFailure(Failure failure) => ignorable?.call(failure) ?? false; } /// Flushes pending microtasks so broadcast emissions and unawaited fetches land. @@ -180,6 +188,53 @@ void main() { expect(channel.state.lastFailure, isNull); }); + test('an ignorable failure is recorded but not counted', () async { + source.ignorable = (failure) => failure is NotFoundFailure; + source.next = const Ok(9); + await channel.refreshNow(); + await pump(); + final count = events.length; + + source.next = const Err(NotFoundFailure('nothing at that instant')); + await channel.refreshNow(); + await pump(); + expect(channel.state.data, 9); // the last snapshot is kept + // The reason is kept for the UI (a replay says so instead of calling + // itself disconnected) without being charged to the feed as a fault. + expect(channel.state.lastFailure, isA()); + expect(channel.state.consecutiveFailures, 0); + expect(events.length, count + 1); + + // A repeat of the same kind is not republished: at 1 Hz that would be a + // rebuild a second for the length of the replay. + await channel.refreshNow(); + await pump(); + expect(events.length, count + 1); + + // Recovering clears the reason, and says so even though the status word + // never moved. + source.next = const Ok(10); + await channel.refreshNow(); + await pump(); + expect(channel.state.lastFailure, isNull); + expect(events.length, count + 2); + }); + + test('an ignored failure still ages the feed to offline', () async { + // The safety property: ignoring a failure silences the *record* of it, and + // must never keep a feed that is receiving nothing looking current. + source.ignorable = (failure) => failure is NotFoundFailure; + source.next = const Err(NotFoundFailure('retention ran out')); + channel.start(); + await pump(); + expect(channel.state.status, RealtimeStatus.connecting); + + elapsed.advance(const Duration(seconds: 11)); // > offlineAfter(10) + ticker.fire(); + await pump(); + expect(channel.state.status, RealtimeStatus.offline); + }); + test('a slow poll is not stacked by the next tick', () async { source.next = const Ok(1); channel.start(); From 1619934ac42465d941cb7851700ca34c736705f3 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 10 Sep 2026 11:03:44 +0800 Subject: [PATCH 3/3] fix(network): stop blaming the region for a missing resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 伺服器狀態不再因為某筆資料找不到,就把整個連線區域標成離線 Fix(en-US): a missing resource no longer marks a whole API region as down --- lib/core/network/api_client.dart | 17 +++++++++++++---- test/core/network/api_client_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index b92c4ee79..b707869e4 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -188,9 +188,15 @@ class ApiClient { _health?.success(tier, hosts[i], path); return response; } on DioException catch (e) { - _health?.failure(tier, hosts[i], path); + final retryable = _isRetryable(e); + // Only what the *host* is answerable for, which is what [_isRetryable] + // already separates. A 404 says the route or the resource is wrong, not + // that the region is sick: replaying an event older than the RTS + // retention 404s once a second, and charging those to the host parked + // `api-1` at `down` on the status screen for the rest of the session. + if (retryable) _health?.failure(tier, hosts[i], path); final isLastHost = i == hosts.length - 1; - if (isLastHost || !_isRetryable(e)) rethrow; + if (isLastHost || !retryable) rethrow; Log.warning( 'ApiClient: ${tier.name} ${hosts[i]} failed (${_describe(e)}); ' 'failing over to ${hosts[i + 1]}', @@ -239,9 +245,12 @@ class ApiClient { _health?.success(tier, hosts[i], path); return StreamedResponse(response.data!.stream, cancelToken.cancel); } on DioException catch (e) { - _health?.failure(tier, hosts[i], path); + // Same split as [request]: a host is only charged for what it is + // answerable for. + final retryable = _isRetryable(e); + if (retryable) _health?.failure(tier, hosts[i], path); final isLastHost = i == hosts.length - 1; - if (isLastHost || !_isRetryable(e)) rethrow; + if (isLastHost || !retryable) rethrow; Log.warning( 'ApiClient: ${tier.name} stream ${hosts[i]} failed (${_describe(e)}); ' 'failing over to ${hosts[i + 1]}', diff --git a/test/core/network/api_client_test.dart b/test/core/network/api_client_test.dart index b4e940346..e579f1640 100644 --- a/test/core/network/api_client_test.dart +++ b/test/core/network/api_client_test.dart @@ -175,6 +175,28 @@ void main() { expect(health.summary, EndpointState.degraded); }); + test('a 4xx is not charged to the host', () async { + // A 404 says the route or the resource is wrong, not that the region is + // sick. Replaying an event older than the RTS retention 404s once a second, + // and counting those parked the host at `down` for the rest of the session. + final health = EndpointHealthMonitor(); + final adapter = _FakeAdapter((_, _) => _json('{"err":"bad"}', 404)); + await expectLater( + () => monitoredClient(adapter, health).request(ApiTier.lbApi, '/x'), + throwsA(isA()), + ); + + expect( + health.of( + EndpointService.other, + ApiTier.lbApi, + 'api.lb-tpe1.exptech.dev', + ), + isNull, // untouched, not merely still healthy + ); + expect(health.summary, EndpointState.unknown); + }); + test('exclusive and core tiers track the same host separately', () async { final health = EndpointHealthMonitor(); final adapter = _FakeAdapter((_, _) => _json('{"ok":true}', 200));