121 lines
4.5 KiB
Dart
121 lines
4.5 KiB
Dart
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<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();
|
|
|
|
var platform = 'unknown';
|
|
String? deviceModel;
|
|
String? osVersion;
|
|
var raw = <String, dynamic>{};
|
|
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<void>(
|
|
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,
|
|
};
|