migrated custom timetable events to Marianum-Connect and refactored push notification handling

This commit is contained in:
2026-07-12 14:31:48 +02:00
parent c444ed54a5
commit 91a6216f66
28 changed files with 474 additions and 212 deletions
@@ -13,22 +13,29 @@ import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'telemetry_device_id.dart';
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — one
/// upsert per app start carrying the stable install id, platform, app version
/// and device info. Bearer-authenticated via the shared dio interceptor.
/// Replaces the legacy mhsl.eu `server/userIndex/update` call.
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent
/// once on app start and again once push registration completes that session
/// (so a fresh registration isn't under-reported until the next launch).
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat {
final Dio _dio;
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
/// send never disrupts app start. Used from the app shell's initState.
static void report() {
unawaited(TelemetryHeartbeat().send().catchError((Object _) {}));
/// send never disrupts app start. Used from the app shell's initState and
/// re-emitted once push registration completes (see `_MainState._syncPush`).
static void report({required bool notificationsEnabled}) {
unawaited(
TelemetryHeartbeat()
.send(notificationsEnabled: notificationsEnabled)
.catchError((Object _) {}),
);
}
Future<void> send() async {
Future<void> send({required bool notificationsEnabled}) async {
try {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
@@ -58,7 +65,12 @@ class TelemetryHeartbeat {
MarianumConnectEndpoint.resolve('me/telemetry'),
data: {
'deviceIdentifier': deviceIdentifier,
// `pushDeviceIdentifier` reflects a *completed* registration and is
// absent until it lands; `pushEnabled` carries the user's intent
// (the notification toggle) so the backend can tell "user wants push"
// apart from "registration not finished yet".
'pushDeviceIdentifier': ?pushDeviceIdentifier,
'pushEnabled': notificationsEnabled,
'platform': platform,
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
@@ -0,0 +1,68 @@
import 'dart:developer';
import 'package:localstore/localstore.dart';
import '../../../../model/account_data.dart';
import '../../../demo/demo_mode.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart';
import 'timetable_custom_events_add.dart';
/// One-time migration of the user's custom timetable events from the legacy
/// MHSL backend to Marianum-Connect. Runs at most once per install, guarded by
/// a persisted flag.
///
/// The MHSL identity is `sha512(username:password)` — computed here exactly as
/// the app always did, so the fetch matches the user's own events perfectly.
/// Each event is POSTed to Marianum-Connect and then deleted from MHSL, which
/// makes the whole run idempotent and resumable: a re-run after a mid-way
/// failure only sees the events that were not yet moved, so nothing is
/// duplicated. The flag is set only once MHSL reports no remaining events.
class CustomEventsMigration {
static const String _collection = 'MarianumMobile';
static const String _document = 'customEventsMigration';
static const String _doneKey = 'migratedToMc';
const CustomEventsMigration._();
static Future<void> runOnce() async {
if (DemoMode.active) return;
if (await _isDone()) return;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()),
).run();
for (final event in response.events) {
await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
} catch (e) {
// Leave the flag unset so the next launch retries; the delete-after-post
// above keeps a partial run duplicate-free.
log('Custom events migration failed, will retry on next launch: $e');
}
}
static Future<bool> _isDone() async {
final data = await Localstore.instance
.collection(_collection)
.doc(_document)
.get();
return data != null && data[_doneKey] == true;
}
static Future<void> _markDone() async {
await Localstore.instance.collection(_collection).doc(_document).set({
_doneKey: true,
});
}
}
@@ -0,0 +1,23 @@
import 'package:dio/dio.dart';
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
class TimetableCustomEventsAdd {
final Dio _dio;
TimetableCustomEventsAdd({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<void> run(CustomTimetableEvent event) async {
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('timetable/custom-events'),
data: event.toJson(),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,15 @@
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../request_cache.dart';
import 'timetable_custom_events_get.dart';
class TimetableCustomEventsCache
extends SimpleCache<GetCustomTimetableEventResponse> {
TimetableCustomEventsCache({super.onUpdate, super.onError, super.renew})
: super(
cacheTime: RequestCache.cacheMinute,
loader: () => TimetableCustomEventsGet().run(),
fromJson: GetCustomTimetableEventResponse.fromJson,
) {
start('customTimetableEvents');
}
}
@@ -0,0 +1,23 @@
import 'package:dio/dio.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
class TimetableCustomEventsGet {
final Dio _dio;
TimetableCustomEventsGet({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<GetCustomTimetableEventResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/custom-events'),
);
return GetCustomTimetableEventResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,22 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
class TimetableCustomEventsRemove {
final Dio _dio;
TimetableCustomEventsRemove({Dio? dio})
: _dio = dio ?? MarianumConnectApi.dio();
Future<void> run(String id) async {
try {
await _dio.delete<void>(
MarianumConnectEndpoint.resolve('timetable/custom-events/$id'),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,24 @@
import 'package:dio/dio.dart';
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
class TimetableCustomEventsUpdate {
final Dio _dio;
TimetableCustomEventsUpdate({Dio? dio})
: _dio = dio ?? MarianumConnectApi.dio();
Future<void> run(String id, CustomTimetableEvent event) async {
try {
await _dio.put<void>(
MarianumConnectEndpoint.resolve('timetable/custom-events/$id'),
data: event.toJson(),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}