merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
@@ -3,41 +3,71 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../push/push_registration_type.dart';
import '../../marianumconnect_query.dart';
import 'telemetry_device_id.dart';
typedef _PushState = ({
bool enabled,
AuthorizationStatus? permission,
String? pushDeviceIdentifier,
});
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent
/// on app start, again once push registration completes that session (so a
/// fresh registration isn't under-reported until the next launch), and on
/// resume after the app spent more than 15 minutes in the background.
/// on app start, on resume and once push registration completes, throttled to
/// one per 15 minutes unless the push state changed in between.
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat extends MarianumConnectQuery {
TelemetryHeartbeat({super.dio});
static const Duration _interval = Duration(minutes: 15);
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
// reliably while the app is suspended, so the window would still be open on
// the resume it is supposed to let through.
static DateTime? _lastSentAt;
static _PushState? _lastPushState;
/// 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 and
/// lifecycle handler, and re-emitted once push registration completes (see
/// `_MainState._syncPush`).
/// send never disrupts app start. A changed push state (toggle, OS
/// permission — e.g. back from the system settings — or a fresh
/// registration) bypasses the throttle: the server picks visible vs. silent
/// delivery from it, so it must not wait for the next window.
static void report({required bool notificationsEnabled}) {
unawaited(
TelemetryHeartbeat()
.send(notificationsEnabled: notificationsEnabled)
.catchError((Object _) {}),
);
unawaited(_report(notificationsEnabled).catchError((Object _) {}));
}
Future<void> send({required bool notificationsEnabled}) => guard(() async {
static Future<void> _report(bool enabled) async {
final pushState = (
enabled: enabled,
permission: await PushRegistration.osPermissionStatus(),
pushDeviceIdentifier: await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general),
);
final now = DateTime.now();
final last = _lastSentAt;
if (last != null &&
now.difference(last) < _interval &&
pushState == _lastPushState) {
return;
}
// Claimed before sending so overlapping triggers don't both go out.
_lastSentAt = now;
_lastPushState = pushState;
await TelemetryHeartbeat()._send(pushState);
}
Future<void> _send(_PushState push) => guard(() async {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
var platform = 'unknown';
String? deviceModel;
@@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
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,
// absent until it lands; `pushEnabled` carries the user's intent (the
// toggle). The server combines it with `osPermission` into the
// visible-vs-silent delivery decision.
'pushDeviceIdentifier': ?push.pushDeviceIdentifier,
'pushEnabled': push.enabled,
'osPermission': ?osPermissionWireValue(push.permission),
'platform': platform,
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
@@ -77,3 +108,13 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
);
});
}
/// Wire value of the OS notification permission for the heartbeat.
String? osPermissionWireValue(AuthorizationStatus? status) => switch (status) {
AuthorizationStatus.authorized => 'granted',
AuthorizationStatus.provisional => 'provisional',
AuthorizationStatus.denied ||
AuthorizationStatus.deniedPermanently => 'denied',
AuthorizationStatus.notDetermined => 'notDetermined',
null => null,
};
@@ -1,6 +1,6 @@
import 'dart:developer';
import 'package:localstore/localstore.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../session/session.dart';
import '../../../../session/session_manager.dart';
@@ -21,9 +21,7 @@ import 'timetable_custom_events_add.dart';
/// 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';
static const String _doneKey = 'customEventsMigratedToMc';
const CustomEventsMigration._();
@@ -34,18 +32,23 @@ class CustomEventsMigration {
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return;
final epoch = SessionManager().sessionEpoch;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(session.legacyUserSecret),
).run();
for (final event in response.events) {
// The POST authenticates with whoever is signed in now; after a
// sign-out the previous account's events would land in the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
if (!SessionManager().isCurrentSession(epoch)) return;
await _markDone();
log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
@@ -57,17 +60,11 @@ class CustomEventsMigration {
}
}
static Future<bool> _isDone() async {
final data = await Localstore.instance
.collection(_collection)
.doc(_document)
.get();
return data != null && data[_doneKey] == true;
}
// SharedPreferences, like the old cache document, is cleared on sign-out,
// so the next account runs its own migration.
static Future<bool> _isDone() async =>
(await SharedPreferences.getInstance()).getBool(_doneKey) ?? false;
static Future<void> _markDone() async {
await Localstore.instance.collection(_collection).doc(_document).set({
_doneKey: true,
});
}
static Future<void> _markDone() async =>
(await SharedPreferences.getInstance()).setBool(_doneKey, true);
}
@@ -10,12 +10,17 @@ class McTimetableTeacher {
final String displayName;
final String? originalShortName;
final String? originalDisplayName;
// Webuntis element ids; null from servers that predate them.
final int? id;
final int? originalId;
McTimetableTeacher({
required this.shortName,
required this.displayName,
this.originalShortName,
this.originalDisplayName,
this.id,
this.originalId,
});
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
@@ -45,6 +50,8 @@ class McTimetableEntry {
final String? substitutionText;
final String? lessonText;
final String? infoText;
// Index-aligned with [classNames]; null from servers that predate it.
final List<int>? classIds;
McTimetableEntry({
required this.id,
@@ -60,6 +67,7 @@ class McTimetableEntry {
required this.substitutionText,
required this.lessonText,
required this.infoText,
this.classIds,
});
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
@@ -68,10 +76,17 @@ class McTimetableEntry {
/// Combines the calendar date with the hour/minute portion of [startTime]
/// (which carries a 1970 placeholder date) into a real DateTime.
DateTime get startDateTime =>
DateTime get startDateTime => _startDateTime;
DateTime get endDateTime => _endDateTime;
// Cached (private, so json_serializable ignores them): sorting and lesson
// merging read these per comparison, and every local DateTime construction
// does a timezone lookup.
late final DateTime _startDateTime =
DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute);
DateTime get endDateTime =>
late final DateTime _endDateTime =
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
@@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
displayName: json['displayName'] as String,
originalShortName: json['originalShortName'] as String?,
originalDisplayName: json['originalDisplayName'] as String?,
id: (json['id'] as num?)?.toInt(),
originalId: (json['originalId'] as num?)?.toInt(),
);
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
@@ -20,6 +22,8 @@ Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
'displayName': instance.displayName,
'originalShortName': instance.originalShortName,
'originalDisplayName': instance.originalDisplayName,
'id': instance.id,
'originalId': instance.originalId,
};
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
@@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
substitutionText: json['substitutionText'] as String?,
lessonText: json['lessonText'] as String?,
infoText: json['infoText'] as String?,
classIds: (json['classIds'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
);
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
@@ -60,6 +67,7 @@ Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
'substitutionText': instance.substitutionText,
'lessonText': instance.lessonText,
'infoText': instance.infoText,
'classIds': instance.classIds,
};
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(