implemented a 1-minute in-memory cache for the EmergencyNoticeClient to prevent redundant network requests on app resume
This commit is contained in:
@@ -1,21 +1,10 @@
|
|||||||
/// A backend-independent emergency notice, loaded from a foreign server URL.
|
/// A backend-independent emergency notice loaded from a foreign server URL —
|
||||||
///
|
/// frontmatter (control fields) plus a free Markdown body, so it stays
|
||||||
/// Deliberately decoupled from MarianumConnect: if that backend is unreachable
|
/// hand-writable in an outage. See [parse] for the format.
|
||||||
/// (the disaster case this exists for), a plain text file on any other host can
|
|
||||||
/// still surface a message. The file is a small YAML-ish frontmatter followed
|
|
||||||
/// by a free Markdown body, so it stays hand-writable in an emergency — no JSON
|
|
||||||
/// escaping of the (multi-line) content.
|
|
||||||
///
|
|
||||||
/// See [parse] for the exact format.
|
|
||||||
class EmergencyNotice {
|
class EmergencyNotice {
|
||||||
/// Whether the notice can be dismissed. `false` renders it full-screen and
|
/// `false` renders full-screen and blocks back/barrier taps.
|
||||||
/// blocks back/barrier taps.
|
|
||||||
final bool dismissible;
|
final bool dismissible;
|
||||||
|
|
||||||
/// Optional heading shown above the body.
|
|
||||||
final String? title;
|
final String? title;
|
||||||
|
|
||||||
/// Markdown body (the message itself).
|
|
||||||
final String body;
|
final String body;
|
||||||
|
|
||||||
const EmergencyNotice({
|
const EmergencyNotice({
|
||||||
@@ -24,38 +13,24 @@ class EmergencyNotice {
|
|||||||
required this.body,
|
required this.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Parses the raw file into a displayable notice, or returns `null` when
|
/// Parses the raw file, or returns `null` when there is nothing to show.
|
||||||
/// there is nothing to show. Never throws — any malformed input yields `null`
|
/// Never throws — malformed input yields `null` so a broken file can't break
|
||||||
/// so a broken file can never break the app.
|
/// the app.
|
||||||
///
|
///
|
||||||
/// Format:
|
|
||||||
/// ```
|
/// ```
|
||||||
/// ---
|
/// ---
|
||||||
/// active: true
|
/// active: true # required truthy, else null; # lines are comments
|
||||||
/// # dismissible: false <- comment lines (# ...) are ignored
|
/// dismissible: true # default true
|
||||||
/// dismissible: true
|
/// title: Störung # optional
|
||||||
/// title: Störung
|
|
||||||
/// ---
|
/// ---
|
||||||
/// # Markdown heading
|
/// Free **markdown** body (everything after the closing ---).
|
||||||
/// Free **markdown** body.
|
|
||||||
/// ```
|
/// ```
|
||||||
///
|
|
||||||
/// Rules:
|
|
||||||
/// - The frontmatter is everything between the first `---` line and the next
|
|
||||||
/// `---` line. Both delimiters are required.
|
|
||||||
/// - Frontmatter entries are `key: value`; a line whose trimmed form starts
|
|
||||||
/// with `#` is a comment. Unknown keys are ignored.
|
|
||||||
/// - `active` (default `false`) must be explicitly true, else `null`.
|
|
||||||
/// - `dismissible` defaults to `true`.
|
|
||||||
/// - `title` is optional. The rest after the closing `---` is the Markdown
|
|
||||||
/// body; an empty body yields `null`.
|
|
||||||
static EmergencyNotice? parse(String raw) {
|
static EmergencyNotice? parse(String raw) {
|
||||||
final lines = raw
|
final lines = raw
|
||||||
.replaceAll('\r\n', '\n')
|
.replaceAll('\r\n', '\n')
|
||||||
.replaceAll('\r', '\n')
|
.replaceAll('\r', '\n')
|
||||||
.split('\n');
|
.split('\n');
|
||||||
|
|
||||||
// Locate the opening `---` (skipping any leading blank lines).
|
|
||||||
var i = 0;
|
var i = 0;
|
||||||
while (i < lines.length && lines[i].trim().isEmpty) {
|
while (i < lines.length && lines[i].trim().isEmpty) {
|
||||||
i++;
|
i++;
|
||||||
@@ -63,7 +38,6 @@ class EmergencyNotice {
|
|||||||
if (i >= lines.length || lines[i].trim() != '---') return null;
|
if (i >= lines.length || lines[i].trim() != '---') return null;
|
||||||
final openIndex = i;
|
final openIndex = i;
|
||||||
|
|
||||||
// Locate the closing `---`.
|
|
||||||
var closeIndex = -1;
|
var closeIndex = -1;
|
||||||
for (var j = openIndex + 1; j < lines.length; j++) {
|
for (var j = openIndex + 1; j < lines.length; j++) {
|
||||||
if (lines[j].trim() == '---') {
|
if (lines[j].trim() == '---') {
|
||||||
|
|||||||
@@ -2,17 +2,30 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import 'emergency_notice.dart';
|
import 'emergency_notice.dart';
|
||||||
|
|
||||||
/// Loads the emergency notice from a foreign URL over a standalone [Dio]
|
/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no
|
||||||
/// instance — no MarianumConnect interceptors, base URL or auth. That keeps the
|
/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage.
|
||||||
/// fallback fully independent of the backend it is meant to survive.
|
/// Never throws: any failure yields `null` (nothing shown).
|
||||||
///
|
///
|
||||||
/// Fail-safe by contract: if the server is unreachable, times out, answers with
|
/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host;
|
||||||
/// a non-2xx status or delivers unparsable content, [fetch] returns `null` and
|
/// it lives only for the process, so a cold start always fetches fresh.
|
||||||
/// nothing is shown. It never throws.
|
|
||||||
class EmergencyNoticeClient {
|
class EmergencyNoticeClient {
|
||||||
const EmergencyNoticeClient();
|
EmergencyNoticeClient();
|
||||||
|
|
||||||
|
static const Duration cacheTtl = Duration(minutes: 1);
|
||||||
|
|
||||||
|
EmergencyNotice? _cached;
|
||||||
|
String? _cachedUrl;
|
||||||
|
DateTime? _cachedAt;
|
||||||
|
|
||||||
Future<EmergencyNotice?> fetch(String url) async {
|
Future<EmergencyNotice?> fetch(String url) async {
|
||||||
|
final cachedAt = _cachedAt;
|
||||||
|
if (cachedAt != null &&
|
||||||
|
_cachedUrl == url &&
|
||||||
|
DateTime.now().difference(cachedAt) < cacheTtl) {
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmergencyNotice? result;
|
||||||
try {
|
try {
|
||||||
final dio = Dio(
|
final dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
@@ -24,11 +37,15 @@ class EmergencyNoticeClient {
|
|||||||
);
|
);
|
||||||
final response = await dio.get<String>(url);
|
final response = await dio.get<String>(url);
|
||||||
final raw = response.data;
|
final raw = response.data;
|
||||||
if (raw == null || raw.isEmpty) return null;
|
result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw);
|
||||||
return EmergencyNotice.parse(raw);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Server down / timeout / bad status / malformed body: show nothing.
|
result = null;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache failures too, so a down server isn't retried on every resume.
|
||||||
|
_cached = result;
|
||||||
|
_cachedUrl = url;
|
||||||
|
_cachedAt = DateTime.now();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ import '../../api/emergency/emergency_notice_client.dart';
|
|||||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
|
||||||
/// Wraps the app and surfaces a backend-independent emergency notice on cold
|
/// Wraps the app and surfaces a backend-independent emergency notice on cold
|
||||||
/// start and on resume. Transparent otherwise: renders [child] unchanged and
|
/// start and resume. Renders [child] unchanged and only overlays a dialog when
|
||||||
/// only overlays a dialog when the foreign source has an active message.
|
/// the foreign source has an active message. Independent of MarianumConnect and
|
||||||
///
|
/// the login state by design — the fallback for when the backend is gone.
|
||||||
/// Independent of MarianumConnect and of the login state by design — this is
|
|
||||||
/// the disaster fallback for when the backend is gone. Any failure to load or
|
|
||||||
/// parse is swallowed by [EmergencyNoticeClient], so a missing/broken source
|
|
||||||
/// simply shows nothing.
|
|
||||||
class EmergencyNoticeGate extends StatefulWidget {
|
class EmergencyNoticeGate extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@@ -28,7 +24,7 @@ class EmergencyNoticeGate extends StatefulWidget {
|
|||||||
|
|
||||||
class _EmergencyNoticeGateState extends State<EmergencyNoticeGate>
|
class _EmergencyNoticeGateState extends State<EmergencyNoticeGate>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
final EmergencyNoticeClient _client = const EmergencyNoticeClient();
|
final EmergencyNoticeClient _client = EmergencyNoticeClient();
|
||||||
bool _showing = false;
|
bool _showing = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user