implemented a backend-independent emergency notice system

This commit is contained in:
2026-07-15 20:30:20 +02:00
parent e8c6ac1c65
commit 7c1f5c06df
11 changed files with 599 additions and 3 deletions
+40
View File
@@ -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)
+116
View File
@@ -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 = <String, String>{};
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;
}
}
}
@@ -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<EmergencyNotice?> 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<String>(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;
}
}
}
+4 -1
View File
@@ -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<void> main() async {
@@ -360,7 +361,8 @@ class _MainState extends State<Main> {
// would otherwise cover it).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
),
home: LoaderOverlay(
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocConsumer<AccountBloc, AccountState>(
@@ -471,6 +473,7 @@ class _MainState extends State<Main> {
),
),
),
),
);
},
),
+14
View File
@@ -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.
+2
View File
@@ -19,6 +19,7 @@ DevToolsSettings _$DevToolsSettingsFromJson(
) ??
MarianumConnectEndpoint.live,
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
);
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
@@ -29,6 +30,7 @@ Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
'marianumConnectEndpoint':
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
'emergencyNoticeUrl': instance.emergencyNoticeUrl,
};
const _$MarianumConnectEndpointEnumMap = {
@@ -75,6 +75,7 @@ class DefaultSettings {
showPerformanceOverlay: false,
marianumConnectEndpoint: MarianumConnectEndpoint.live,
marianumConnectCustomUrl: '',
emergencyNoticeUrl: '',
),
hapticSettings: HapticSettings(level: HapticLevel.full),
);
@@ -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<DevToolsSection> {
onTap: () =>
MarianumConnectEndpointPicker.show(context, widget.settings),
),
BlocBuilder<SettingsCubit, model.Settings>(
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<DevToolsSection> {
],
);
}
/// 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'),
),
),
],
),
);
}
@@ -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<EmergencyNoticeGate> createState() => _EmergencyNoticeGateState();
}
class _EmergencyNoticeGateState extends State<EmergencyNoticeGate>
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<void> _check() async {
if (_showing || !mounted) return;
final url = context
.read<SettingsCubit>()
.val()
.devToolsSettings
.resolveEmergencyNoticeUrl();
if (url == null) return;
final notice = await _client.fetch(url);
if (notice == null || !mounted || _showing) return;
_showing = true;
try {
await showDialog<void>(
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<void> _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,
),
),
],
),
),
),
),
);
}
}
+1
View File
@@ -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:
+127
View File
@@ -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);
});
});
}