From 7c1f5c06dfc1cebe1eb597d07d5b1cc34487ff96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 15 Jul 2026 20:30:20 +0200 Subject: [PATCH] implemented a backend-independent emergency notice system --- docs/marMobile.example.override | 40 +++++ lib/api/emergency/emergency_notice.dart | 116 ++++++++++++++ .../emergency/emergency_notice_client.dart | 34 ++++ lib/main.dart | 9 +- lib/storage/dev_tools_settings.dart | 14 ++ lib/storage/dev_tools_settings.g.dart | 2 + .../pages/settings/data/default_settings.dart | 1 + .../settings/sections/dev_tools_section.dart | 113 ++++++++++++++ .../emergency/emergency_notice_gate.dart | 145 ++++++++++++++++++ pubspec.yaml | 1 + test/emergency_notice_parse_test.dart | 127 +++++++++++++++ 11 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 docs/marMobile.example.override create mode 100644 lib/api/emergency/emergency_notice.dart create mode 100644 lib/api/emergency/emergency_notice_client.dart create mode 100644 lib/widget/emergency/emergency_notice_gate.dart create mode 100644 test/emergency_notice_parse_test.dart diff --git a/docs/marMobile.example.override b/docs/marMobile.example.override new file mode 100644 index 0000000..5f49f29 --- /dev/null +++ b/docs/marMobile.example.override @@ -0,0 +1,40 @@ +--- +# +# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden! +# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich! +# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen. +# +# Notfall-Nachricht der MarianumMobile-App +# +# Diese Datei wird bei jedem App-Start geladen. +# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand). +# +# Steuerfelder: +# active: true schaltet die Anzeige ein (Default: false) +# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true) +# title: optionale Überschrift +# +# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen. +# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/ +# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"! +# Beispielkonfiguration: +# +# --- +# # Ein Kommentar +# active: true +# dismissible: false +# title: Wichtiger Hinweis +# --- +# Hinweistext in Markdown +# +# ============================================================================ + +active: false +dismissible: true +title: Hinweis +--- +# Serverstörung +Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung. +Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung. + +- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de) diff --git a/lib/api/emergency/emergency_notice.dart b/lib/api/emergency/emergency_notice.dart new file mode 100644 index 0000000..ad602f3 --- /dev/null +++ b/lib/api/emergency/emergency_notice.dart @@ -0,0 +1,116 @@ +/// A backend-independent emergency notice, loaded from a foreign server URL. +/// +/// Deliberately decoupled from MarianumConnect: if that backend is unreachable +/// (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 { + /// Whether the notice can be dismissed. `false` renders it full-screen and + /// blocks back/barrier taps. + final bool dismissible; + + /// Optional heading shown above the body. + final String? title; + + /// Markdown body (the message itself). + final String body; + + const EmergencyNotice({ + required this.dismissible, + required this.title, + required this.body, + }); + + /// Parses the raw file into a displayable notice, or returns `null` when + /// there is nothing to show. Never throws — any malformed input yields `null` + /// so a broken file can never break the app. + /// + /// Format: + /// ``` + /// --- + /// active: true + /// # dismissible: false <- comment lines (# ...) are ignored + /// dismissible: true + /// title: Störung + /// --- + /// # Markdown heading + /// 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) { + final lines = raw + .replaceAll('\r\n', '\n') + .replaceAll('\r', '\n') + .split('\n'); + + // Locate the opening `---` (skipping any leading blank lines). + var i = 0; + while (i < lines.length && lines[i].trim().isEmpty) { + i++; + } + if (i >= lines.length || lines[i].trim() != '---') return null; + final openIndex = i; + + // Locate the closing `---`. + var closeIndex = -1; + for (var j = openIndex + 1; j < lines.length; j++) { + if (lines[j].trim() == '---') { + closeIndex = j; + break; + } + } + if (closeIndex == -1) return null; + + final meta = {}; + for (var j = openIndex + 1; j < closeIndex; j++) { + final line = lines[j].trim(); + if (line.isEmpty || line.startsWith('#')) continue; + final sep = line.indexOf(':'); + if (sep <= 0) continue; + final key = line.substring(0, sep).trim().toLowerCase(); + final value = line.substring(sep + 1).trim(); + meta[key] = value; + } + + if (_parseBool(meta['active']) != true) return null; + + final body = lines.sublist(closeIndex + 1).join('\n').trim(); + if (body.isEmpty) return null; + + final title = meta['title']; + return EmergencyNotice( + dismissible: _parseBool(meta['dismissible']) ?? true, + title: (title == null || title.isEmpty) ? null : title, + body: body, + ); + } + + static bool? _parseBool(String? value) { + switch (value?.trim().toLowerCase()) { + case 'true': + case 'yes': + case '1': + case 'on': + return true; + case 'false': + case 'no': + case '0': + case 'off': + return false; + default: + return null; + } + } +} diff --git a/lib/api/emergency/emergency_notice_client.dart b/lib/api/emergency/emergency_notice_client.dart new file mode 100644 index 0000000..97f0ed3 --- /dev/null +++ b/lib/api/emergency/emergency_notice_client.dart @@ -0,0 +1,34 @@ +import 'package:dio/dio.dart'; + +import 'emergency_notice.dart'; + +/// Loads the emergency notice from a foreign URL over a standalone [Dio] +/// instance — no MarianumConnect interceptors, base URL or auth. That keeps the +/// fallback fully independent of the backend it is meant to survive. +/// +/// Fail-safe by contract: if the server is unreachable, times out, answers with +/// a non-2xx status or delivers unparsable content, [fetch] returns `null` and +/// nothing is shown. It never throws. +class EmergencyNoticeClient { + const EmergencyNoticeClient(); + + Future fetch(String url) async { + try { + final dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + sendTimeout: const Duration(seconds: 5), + responseType: ResponseType.plain, + ), + ); + final response = await dio.get(url); + final raw = response.data; + if (raw == null || raw.isEmpty) return null; + return EmergencyNotice.parse(raw); + } catch (_) { + // Server down / timeout / bad status / malformed body: show nothing. + return null; + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 3035f41..ff7daf2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -55,6 +55,7 @@ import 'widget/avatar_disk_cache.dart'; import 'widget/breaker/breaker.dart'; import 'widget/debug/cache_view.dart'; import 'widget/downloads/download_tray.dart'; +import 'widget/emergency/emergency_notice_gate.dart'; import 'widget_data/widget_sync.dart'; Future main() async { @@ -360,9 +361,10 @@ class _MainState extends State
{ // would otherwise cover it). child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), ), - home: LoaderOverlay( - child: Breaker( - breaker: BreakerArea.global, + home: EmergencyNoticeGate( + child: LoaderOverlay( + child: Breaker( + breaker: BreakerArea.global, child: BlocConsumer( listenWhen: (previous, current) => previous.status != current.status, @@ -468,6 +470,7 @@ class _MainState extends State
{ ); } }, + ), ), ), ), diff --git a/lib/storage/dev_tools_settings.dart b/lib/storage/dev_tools_settings.dart index d9b86ce..a50f540 100644 --- a/lib/storage/dev_tools_settings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -17,12 +17,18 @@ class DevToolsSettings { @JsonKey(defaultValue: '') String marianumConnectCustomUrl; + /// Optional override for the backend-independent emergency-notice source. + /// Empty falls back to [emergencyNoticeDefaultUrl]. + @JsonKey(defaultValue: '') + String emergencyNoticeUrl; + DevToolsSettings({ required this.showPerformanceOverlay, required this.checkerboardOffscreenLayers, required this.checkerboardRasterCacheImages, this.marianumConnectEndpoint = MarianumConnectEndpoint.live, this.marianumConnectCustomUrl = '', + this.emergencyNoticeUrl = '', }); // Resolves the effective base URL, falling back to live when the custom URL @@ -43,6 +49,14 @@ class DevToolsSettings { static const String liveUrl = 'https://connect.marianum-fulda.de'; static const String betaUrl = 'https://connect-beta.marianum-fulda.de'; + /// Compiled-in source for the backend-independent emergency notice. Must live + /// on infrastructure that is reachable even when MarianumConnect is down. + static const String emergencyNoticeDefaultUrl = 'https://www.marianum-fulda.de/~aushang/marMobile.override'; + + String? resolveEmergencyNoticeUrl() => + sanitizeCustomUrl(emergencyNoticeUrl) ?? + sanitizeCustomUrl(emergencyNoticeDefaultUrl); + /// `true` in builds where plaintext HTTP custom endpoints are still allowed /// (debug, profile). Release builds keep this `false` and the picker /// rejects `http://` entirely. diff --git a/lib/storage/dev_tools_settings.g.dart b/lib/storage/dev_tools_settings.g.dart index 5b98f13..75540e6 100644 --- a/lib/storage/dev_tools_settings.g.dart +++ b/lib/storage/dev_tools_settings.g.dart @@ -19,6 +19,7 @@ DevToolsSettings _$DevToolsSettingsFromJson( ) ?? MarianumConnectEndpoint.live, marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '', + emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '', ); Map _$DevToolsSettingsToJson(DevToolsSettings instance) => @@ -29,6 +30,7 @@ Map _$DevToolsSettingsToJson(DevToolsSettings instance) => 'marianumConnectEndpoint': _$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!, 'marianumConnectCustomUrl': instance.marianumConnectCustomUrl, + 'emergencyNoticeUrl': instance.emergencyNoticeUrl, }; const _$MarianumConnectEndpointEnumMap = { diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 82aa4e2..4a54706 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -75,6 +75,7 @@ class DefaultSettings { showPerformanceOverlay: false, marianumConnectEndpoint: MarianumConnectEndpoint.live, marianumConnectCustomUrl: '', + emergencyNoticeUrl: '', ), hapticSettings: HapticSettings(level: HapticLevel.full), ); diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index a81eeab..2d70847 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/settings.dart' as model; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; @@ -88,6 +89,27 @@ class _DevToolsSectionState extends State { onTap: () => MarianumConnectEndpointPicker.show(context, widget.settings), ), + BlocBuilder( + bloc: widget.settings, + builder: (_, _) { + final override = widget.settings + .val() + .devToolsSettings + .emergencyNoticeUrl + .trim(); + return ListTile( + leading: const CenteredLeading(Icon(Icons.emergency_outlined)), + title: const Text('Notfall-Nachricht (Quelle)'), + subtitle: Text( + override.isEmpty + ? 'Standardquelle (im Code hinterlegt)' + : override, + ), + trailing: const Icon(Icons.arrow_right), + onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings), + ); + }, + ), ListTile( leading: const CenteredLeading(Icon(Icons.image_outlined)), title: const Text('Thumb-storage'), @@ -170,3 +192,94 @@ class _DevToolsSectionState extends State { ], ); } + +/// Bottom-sheet editor for the backend-independent emergency-notice override +/// URL. Empty clears the override so the compiled-in default source is used. +class _EmergencyNoticeUrlEditor extends StatefulWidget { + final SettingsCubit settings; + const _EmergencyNoticeUrlEditor({required this.settings}); + + static void show(BuildContext context, SettingsCubit settings) { + showDetailsBottomSheet( + context, + header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')), + children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)], + ); + } + + @override + State<_EmergencyNoticeUrlEditor> createState() => + _EmergencyNoticeUrlEditorState(); +} + +class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> { + late final TextEditingController _controller; + String? _error; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: widget.settings.val().devToolsSettings.emergencyNoticeUrl, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _save() { + final raw = _controller.text.trim(); + // Empty is valid: it clears the override and falls back to the default. + if (raw.isNotEmpty && + DevToolsSettings.sanitizeCustomUrl(raw) == null) { + setState( + () => _error = DevToolsSettings.allowsHttpCustomEndpoint + ? 'Ungültige URL (http(s)://host[:port]/...)' + : 'Ungültige URL — nur HTTPS erlaubt', + ); + return; + } + widget.settings.val(write: true).devToolsSettings.emergencyNoticeUrl = raw; + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + 'Überschreibt die im Code hinterlegte Standardquelle. Leer ' + 'lassen, um die Standardquelle zu verwenden.', + style: TextStyle(fontSize: 12), + ), + ), + TextField( + controller: _controller, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: 'https://...', + errorText: _error, + ), + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: FilledButton( + onPressed: _save, + child: const Text('Übernehmen'), + ), + ), + ], + ), + ); +} diff --git a/lib/widget/emergency/emergency_notice_gate.dart b/lib/widget/emergency/emergency_notice_gate.dart new file mode 100644 index 0000000..44005c1 --- /dev/null +++ b/lib/widget/emergency/emergency_notice_gate.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../../api/emergency/emergency_notice.dart'; +import '../../api/emergency/emergency_notice_client.dart'; +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; + +/// Wraps the app and surfaces a backend-independent emergency notice on cold +/// start and on resume. Transparent otherwise: renders [child] unchanged and +/// only overlays a dialog when the foreign source has an active message. +/// +/// 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 { + final Widget child; + + const EmergencyNoticeGate({required this.child, super.key}); + + @override + State createState() => _EmergencyNoticeGateState(); +} + +class _EmergencyNoticeGateState extends State + with WidgetsBindingObserver { + final EmergencyNoticeClient _client = const EmergencyNoticeClient(); + bool _showing = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_check())); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) unawaited(_check()); + } + + Future _check() async { + if (_showing || !mounted) return; + final url = context + .read() + .val() + .devToolsSettings + .resolveEmergencyNoticeUrl(); + if (url == null) return; + + final notice = await _client.fetch(url); + if (notice == null || !mounted || _showing) return; + + _showing = true; + try { + await showDialog( + context: context, + barrierDismissible: notice.dismissible, + builder: (_) => _EmergencyNoticeDialog(notice: notice), + ); + } finally { + _showing = false; + } + } + + @override + Widget build(BuildContext context) => widget.child; +} + +class _EmergencyNoticeDialog extends StatelessWidget { + final EmergencyNotice notice; + + const _EmergencyNoticeDialog({required this.notice}); + + Future _openLink(String? href) async { + if (href == null) return; + if (await canLaunchUrlString(href)) { + await launchUrlString(href); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final body = MarkdownBody( + data: notice.body, + onTapLink: (_, href, _) => unawaited(_openLink(href)), + ); + + if (notice.dismissible) { + return AlertDialog( + title: notice.title == null ? null : Text(notice.title!), + content: SingleChildScrollView(child: body), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen'), + ), + ], + ); + } + + // Full-screen, non-dismissible: no close affordance, back is blocked. + return PopScope( + canPop: false, + child: Dialog.fullscreen( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (notice.title != null) ...[ + Text( + notice.title!, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ], + Expanded( + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: body, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index cf07b34..86a3c1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,6 +98,7 @@ dependencies: # user declined the permission and wants to enable it later. app_settings: ^7.0.0 flutter_layout_grid: ^2.0.8 + flutter_markdown_plus: ^1.0.12 dev_dependencies: flutter_test: diff --git a/test/emergency_notice_parse_test.dart b/test/emergency_notice_parse_test.dart new file mode 100644 index 0000000..886e3b9 --- /dev/null +++ b/test/emergency_notice_parse_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/emergency/emergency_notice.dart'; + +void main() { + group('EmergencyNotice.parse', () { + test('parses an active, dismissible notice', () { + final notice = EmergencyNotice.parse(''' +--- +active: true +dismissible: true +title: Störung +--- +# Ausfall +Der Server ist **down**. +'''); + + expect(notice, isNotNull); + expect(notice!.dismissible, isTrue); + expect(notice.title, 'Störung'); + expect(notice.body, '# Ausfall\nDer Server ist **down**.'); + }); + + test('parses a full-screen (dismissible: false) notice', () { + final notice = EmergencyNotice.parse(''' +--- +active: true +dismissible: false +--- +Wichtige Meldung +'''); + + expect(notice, isNotNull); + expect(notice!.dismissible, isFalse); + expect(notice.title, isNull); + }); + + test('defaults dismissible to true when absent', () { + final notice = EmergencyNotice.parse(''' +--- +active: true +--- +Body +'''); + + expect(notice!.dismissible, isTrue); + }); + + test('returns null when active is false', () { + expect( + EmergencyNotice.parse('---\nactive: false\n---\nBody'), + isNull, + ); + }); + + test('returns null when active is missing', () { + expect( + EmergencyNotice.parse('---\ntitle: Hallo\n---\nBody'), + isNull, + ); + }); + + test('ignores comment lines in the frontmatter', () { + final notice = EmergencyNotice.parse(''' +--- +# dismissible: false <- example config, disabled +active: true +dismissible: true +--- +Body +'''); + + expect(notice, isNotNull); + expect(notice!.dismissible, isTrue); + }); + + test('returns null without a frontmatter', () { + expect(EmergencyNotice.parse('Just some **markdown** text'), isNull); + }); + + test('returns null when the closing delimiter is missing', () { + expect(EmergencyNotice.parse('---\nactive: true\nBody'), isNull); + }); + + test('returns null on empty input', () { + expect(EmergencyNotice.parse(''), isNull); + expect(EmergencyNotice.parse(' \n '), isNull); + }); + + test('returns null when the body is empty', () { + expect(EmergencyNotice.parse('---\nactive: true\n---\n '), isNull); + }); + + test('preserves the markdown body verbatim', () { + final notice = EmergencyNotice.parse(''' +--- +active: true +--- +# Titel + +- Punkt 1 +- Punkt 2 + +Mehr [Info](https://example.org). +'''); + + expect( + notice!.body, + '# Titel\n\n- Punkt 1\n- Punkt 2\n\nMehr [Info](https://example.org).', + ); + }); + + test('handles CRLF line endings', () { + final notice = EmergencyNotice.parse( + '---\r\nactive: true\r\n---\r\nHallo\r\n', + ); + + expect(notice, isNotNull); + expect(notice!.body, 'Hallo'); + }); + + test('accepts alternative truthy values for active', () { + expect(EmergencyNotice.parse('---\nactive: yes\n---\nB'), isNotNull); + expect(EmergencyNotice.parse('---\nactive: 1\n---\nB'), isNotNull); + expect(EmergencyNotice.parse('---\nactive: on\n---\nB'), isNotNull); + }); + }); +}