import 'dart:async'; 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, 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. 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(_report(notificationsEnabled).catchError((Object _) {})); } static Future _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 _send(_PushState push) => guard(() async { final info = DeviceInfoPlugin(); final package = await PackageInfo.fromPlatform(); final deviceIdentifier = await TelemetryDeviceId.resolve(); var platform = 'unknown'; String? deviceModel; String? osVersion; var raw = {}; if (Platform.isAndroid) { platform = 'android'; final androidInfo = await info.androidInfo; deviceModel = androidInfo.model; osVersion = androidInfo.version.release; raw = androidInfo.data; } else if (Platform.isIOS) { platform = 'ios'; final appleInfo = await info.iosInfo; deviceModel = appleInfo.utsname.machine; osVersion = appleInfo.systemVersion; raw = appleInfo.data; } await dio.post( endpoint('me/telemetry'), data: { 'deviceIdentifier': deviceIdentifier, // `pushDeviceIdentifier` reflects a *completed* registration and is // 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), 'deviceModel': deviceModel, 'osVersion': osVersion, 'deviceInfo': jsonEncode(raw), }, ); }); } /// 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, };