diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart index 8a8b2a5..40a7a60 100644 --- a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart @@ -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 send({required bool notificationsEnabled}) => guard(() async { + 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(); - 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, +}; diff --git a/lib/app.dart b/lib/app.dart index e6bb8a4..a8d38f1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -48,11 +48,9 @@ class _AppState extends State with WidgetsBindingObserver { int _knownTotalTabs = 1; int _lastTabIndex = 0; bool _userOnLastTab = false; - DateTime? _lastTelemetryAt; static const Duration _chatListActiveInterval = Duration(seconds: 15); static const Duration _chatListIdleInterval = Duration(seconds: 60); - static const Duration _telemetryInterval = Duration(minutes: 15); void _onTabControllerChanged() { final newIndex = Main.bottomNavigator.index; @@ -81,15 +79,8 @@ class _AppState extends State with WidgetsBindingObserver { } } - // 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. void _reportTelemetry() { if (!mounted) return; - final now = DateTime.now(); - final last = _lastTelemetryAt; - if (last != null && now.difference(last) < _telemetryInterval) return; - _lastTelemetryAt = now; TelemetryHeartbeat.report( notificationsEnabled: context .read() diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 52dc540..e9cd974 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -355,9 +355,8 @@ class PushRegistration { status != AuthorizationStatus.deniedPermanently; /// Requests the OS notification permission (covers iOS + Android 13) and - /// returns whether registration should proceed. Errors from the plugin are - /// treated as usable — better a possibly-idle registration than silently - /// losing push over a transient failure. + /// returns whether it is usable (not explicitly denied). Errors from the + /// plugin are treated as usable so a transient failure never nags the user. static Future requestOsPermission() async { try { final settings = await FirebaseMessaging.instance.requestPermission(); @@ -368,54 +367,47 @@ class PushRegistration { } } - /// True when the user has explicitly denied the OS notification permission. - /// Read-only (no prompt) — used by the settings UI to surface the state. - static Future isOsPermissionDenied() async { + /// Current OS notification permission, or null when the plugin can't tell. + /// Read-only — never triggers the OS prompt. + static Future osPermissionStatus() async { try { final settings = await FirebaseMessaging.instance .getNotificationSettings(); - return !isPermissionUsable(settings.authorizationStatus); + return settings.authorizationStatus; } on Object { - return false; + return null; } } /// True when the OS notification permission is already granted /// (`authorized`/`provisional`). Read-only — never triggers the OS prompt. - /// Used by the cold-start/self-heal path so it registers only for devices - /// that already opted in, leaving the actual prompt to the first Talk visit. static Future isOsPermissionGranted() async { - try { - final settings = await FirebaseMessaging.instance - .getNotificationSettings(); - return settings.authorizationStatus == AuthorizationStatus.authorized || - settings.authorizationStatus == AuthorizationStatus.provisional; - } on Object { - return false; - } + final status = await osPermissionStatus(); + return status != null && canDisplay(status); } + /// Whether the OS will actually show a visible notification in [status]. + /// Stricter than [isPermissionUsable]: `notDetermined` shows nothing yet. + static bool canDisplay(AuthorizationStatus status) => + status == AuthorizationStatus.authorized || + status == AuthorizationStatus.provisional; + /// Registers this device whenever the backend advertises the push capability. - /// Deliberately independent of the in-app notification toggle: a user who - /// turned notifications off stays registered so silent sync pushes keep - /// flowing — the display is suppressed downstream via the mirrored flag (see - /// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS - /// notification permission is *already* granted — it never triggers the OS - /// prompt itself. Requesting the permission is the job of the first Talk visit - /// (see `maybePromptTalkNotifications`), which keeps the prompt out of the - /// cold-start path. Safe to call on every start — Nextcloud dedups an - /// unchanged registration — which also self-heals a device whose registration - /// was lost. + /// Deliberately independent of both the in-app notification toggle and the + /// OS notification permission: silent sync pushes (deletes, chat/badge + /// refresh, widget refresh) need no permission — data-only FCM on Android, + /// `content-available` on iOS — so every device stays in sync. Whether a + /// visible notification is wanted is reported separately via the telemetry + /// heartbeat (toggle + OS permission), from which the server picks silent + /// pushes on iOS. Never triggers the OS prompt. Safe to call on every + /// start — Nextcloud dedups an unchanged registration — which also + /// self-heals a device whose registration was lost. /// Returns whether registration was actually *attempted* (all gates passed). /// Even a partial success persists the `general` device identifier, so the /// caller re-emits telemetry on `true` to reflect the fresh registration in /// the same session instead of lagging until the next launch. static Future syncSubscription({required bool capable}) async { if (!capable) return false; - if (!await isOsPermissionGranted()) { - log('Push: OS notification permission not granted, skipping registration'); - return false; - } final registration = PushRegistration(); // register() below refreshes an unchanged subscription anyway; the check // only surfaces the endpoint switch in the log for diagnosability. diff --git a/lib/push/push_status.dart b/lib/push/push_status.dart index a0664cb..6e5f73e 100644 --- a/lib/push/push_status.dart +++ b/lib/push/push_status.dart @@ -1,4 +1,3 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import '../model/account_data.dart'; @@ -141,21 +140,11 @@ Future collectPushStatus({ } Future _osPermission() async { - try { - final settings = await FirebaseMessaging.instance.getNotificationSettings(); - switch (settings.authorizationStatus) { - case AuthorizationStatus.authorized: - case AuthorizationStatus.provisional: - return PushCheck.ok; - case AuthorizationStatus.denied: - case AuthorizationStatus.deniedPermanently: - return PushCheck.fail; - case AuthorizationStatus.notDetermined: - return PushCheck.unknown; - } - } on Object { - return PushCheck.unknown; - } + final status = await PushRegistration.osPermissionStatus(); + if (status == null) return PushCheck.unknown; + if (PushRegistration.canDisplay(status)) return PushCheck.ok; + if (!PushRegistration.isPermissionUsable(status)) return PushCheck.fail; + return PushCheck.unknown; } /// One line in the status checklist. diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 0a1a79b..ea9ecec 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import '../../../../push/push_registration.dart'; import '../../../../push/push_status.dart'; import '../../../../routing/app_routes.dart'; @@ -123,6 +124,8 @@ class _PushSettingsState extends State<_PushSettings> void _onToggle(bool enabled) { widget.settings.val(write: true).notificationSettings.enabled = enabled; + // A permission granted below arrives via the resume heartbeat. + TelemetryHeartbeat.report(notificationsEnabled: enabled); // Turning off does NOT unregister: the device stays subscribed so silent // sync pushes keep arriving; the message handler and iOS NSE suppress only // the visible notification (via the mirrored flag). Enabling (re-)registers @@ -132,11 +135,10 @@ class _PushSettingsState extends State<_PushSettings> final messenger = ScaffoldMessenger.of(context); unawaited(() async { try { - // Only register when the OS permission isn't explicitly denied — - // otherwise NC + proxy would push into the void. - if (await PushRegistration.requestOsPermission()) { - await PushRegistration().register(); - } else { + final granted = await PushRegistration.requestOsPermission(); + // Registered regardless: silent sync pushes need no permission. + await PushRegistration().register(); + if (!granted) { messenger.showSnackBar( const SnackBar( content: Text( diff --git a/test/push/push_permission_test.dart b/test/push/push_permission_test.dart index 8ec6fc7..d029751 100644 --- a/test/push/push_permission_test.dart +++ b/test/push/push_permission_test.dart @@ -1,5 +1,6 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'package:marianum_mobile/push/push_registration.dart'; void main() { @@ -37,4 +38,36 @@ void main() { expect(AuthorizationStatus.values, hasLength(5)); }); }); + + group('PushRegistration.canDisplay', () { + test('only authorized and provisional show notifications', () { + expect( + AuthorizationStatus.values.where(PushRegistration.canDisplay), + unorderedEquals([ + AuthorizationStatus.authorized, + AuthorizationStatus.provisional, + ]), + ); + }); + }); + + group('osPermissionWireValue', () { + test('maps every status', () { + expect(osPermissionWireValue(AuthorizationStatus.authorized), 'granted'); + expect( + osPermissionWireValue(AuthorizationStatus.provisional), + 'provisional', + ); + expect(osPermissionWireValue(AuthorizationStatus.denied), 'denied'); + expect( + osPermissionWireValue(AuthorizationStatus.deniedPermanently), + 'denied', + ); + expect( + osPermissionWireValue(AuthorizationStatus.notDetermined), + 'notDetermined', + ); + expect(osPermissionWireValue(null), isNull); + }); + }); }