push registration no longer depends on notification permission, reporting effective opt-in and os permission
This commit is contained in:
@@ -3,41 +3,71 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
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 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../../../push/push_registration.dart';
|
||||||
import '../../../../push/push_registration_store.dart';
|
import '../../../../push/push_registration_store.dart';
|
||||||
import '../../../../push/push_registration_type.dart';
|
import '../../../../push/push_registration_type.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import 'telemetry_device_id.dart';
|
import 'telemetry_device_id.dart';
|
||||||
|
|
||||||
|
typedef _PushState = ({
|
||||||
|
bool enabled,
|
||||||
|
AuthorizationStatus? permission,
|
||||||
|
String? pushDeviceIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
||||||
/// upserts the stable install id, platform, app version and device info. Sent
|
/// upserts the stable install id, platform, app version and device info. Sent
|
||||||
/// on app start, again once push registration completes that session (so a
|
/// on app start, on resume and once push registration completes, throttled to
|
||||||
/// fresh registration isn't under-reported until the next launch), and on
|
/// one per 15 minutes unless the push state changed in between.
|
||||||
/// resume after the app spent more than 15 minutes in the background.
|
|
||||||
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
||||||
/// mhsl.eu `server/userIndex/update` call.
|
/// mhsl.eu `server/userIndex/update` call.
|
||||||
class TelemetryHeartbeat extends MarianumConnectQuery {
|
class TelemetryHeartbeat extends MarianumConnectQuery {
|
||||||
TelemetryHeartbeat({super.dio});
|
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
|
/// 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
|
/// send never disrupts app start. A changed push state (toggle, OS
|
||||||
/// lifecycle handler, and re-emitted once push registration completes (see
|
/// permission — e.g. back from the system settings — or a fresh
|
||||||
/// `_MainState._syncPush`).
|
/// 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}) {
|
static void report({required bool notificationsEnabled}) {
|
||||||
unawaited(
|
unawaited(_report(notificationsEnabled).catchError((Object _) {}));
|
||||||
TelemetryHeartbeat()
|
|
||||||
.send(notificationsEnabled: 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 info = DeviceInfoPlugin();
|
||||||
final package = await PackageInfo.fromPlatform();
|
final package = await PackageInfo.fromPlatform();
|
||||||
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
||||||
final pushDeviceIdentifier = await const PushRegistrationStore()
|
|
||||||
.deviceIdentifier(PushRegistrationType.general);
|
|
||||||
|
|
||||||
var platform = 'unknown';
|
var platform = 'unknown';
|
||||||
String? deviceModel;
|
String? deviceModel;
|
||||||
@@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
|
|||||||
data: {
|
data: {
|
||||||
'deviceIdentifier': deviceIdentifier,
|
'deviceIdentifier': deviceIdentifier,
|
||||||
// `pushDeviceIdentifier` reflects a *completed* registration and is
|
// `pushDeviceIdentifier` reflects a *completed* registration and is
|
||||||
// absent until it lands; `pushEnabled` carries the user's intent
|
// absent until it lands; `pushEnabled` carries the user's intent (the
|
||||||
// (the notification toggle) so the backend can tell "user wants push"
|
// toggle). The server combines it with `osPermission` into the
|
||||||
// apart from "registration not finished yet".
|
// visible-vs-silent delivery decision.
|
||||||
'pushDeviceIdentifier': ?pushDeviceIdentifier,
|
'pushDeviceIdentifier': ?push.pushDeviceIdentifier,
|
||||||
'pushEnabled': notificationsEnabled,
|
'pushEnabled': push.enabled,
|
||||||
|
'osPermission': ?osPermissionWireValue(push.permission),
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'appVersion': package.version,
|
'appVersion': package.version,
|
||||||
'appBuild': int.tryParse(package.buildNumber),
|
'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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -48,11 +48,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
int _knownTotalTabs = 1;
|
int _knownTotalTabs = 1;
|
||||||
int _lastTabIndex = 0;
|
int _lastTabIndex = 0;
|
||||||
bool _userOnLastTab = false;
|
bool _userOnLastTab = false;
|
||||||
DateTime? _lastTelemetryAt;
|
|
||||||
|
|
||||||
static const Duration _chatListActiveInterval = Duration(seconds: 15);
|
static const Duration _chatListActiveInterval = Duration(seconds: 15);
|
||||||
static const Duration _chatListIdleInterval = Duration(seconds: 60);
|
static const Duration _chatListIdleInterval = Duration(seconds: 60);
|
||||||
static const Duration _telemetryInterval = Duration(minutes: 15);
|
|
||||||
|
|
||||||
void _onTabControllerChanged() {
|
void _onTabControllerChanged() {
|
||||||
final newIndex = Main.bottomNavigator.index;
|
final newIndex = Main.bottomNavigator.index;
|
||||||
@@ -81,15 +79,8 @@ class _AppState extends State<App> 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() {
|
void _reportTelemetry() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final now = DateTime.now();
|
|
||||||
final last = _lastTelemetryAt;
|
|
||||||
if (last != null && now.difference(last) < _telemetryInterval) return;
|
|
||||||
_lastTelemetryAt = now;
|
|
||||||
TelemetryHeartbeat.report(
|
TelemetryHeartbeat.report(
|
||||||
notificationsEnabled: context
|
notificationsEnabled: context
|
||||||
.read<SettingsCubit>()
|
.read<SettingsCubit>()
|
||||||
|
|||||||
@@ -355,9 +355,8 @@ class PushRegistration {
|
|||||||
status != AuthorizationStatus.deniedPermanently;
|
status != AuthorizationStatus.deniedPermanently;
|
||||||
|
|
||||||
/// Requests the OS notification permission (covers iOS + Android 13) and
|
/// Requests the OS notification permission (covers iOS + Android 13) and
|
||||||
/// returns whether registration should proceed. Errors from the plugin are
|
/// returns whether it is usable (not explicitly denied). Errors from the
|
||||||
/// treated as usable — better a possibly-idle registration than silently
|
/// plugin are treated as usable so a transient failure never nags the user.
|
||||||
/// losing push over a transient failure.
|
|
||||||
static Future<bool> requestOsPermission() async {
|
static Future<bool> requestOsPermission() async {
|
||||||
try {
|
try {
|
||||||
final settings = await FirebaseMessaging.instance.requestPermission();
|
final settings = await FirebaseMessaging.instance.requestPermission();
|
||||||
@@ -368,54 +367,47 @@ class PushRegistration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the user has explicitly denied the OS notification permission.
|
/// Current OS notification permission, or null when the plugin can't tell.
|
||||||
/// Read-only (no prompt) — used by the settings UI to surface the state.
|
/// Read-only — never triggers the OS prompt.
|
||||||
static Future<bool> isOsPermissionDenied() async {
|
static Future<AuthorizationStatus?> osPermissionStatus() async {
|
||||||
try {
|
try {
|
||||||
final settings = await FirebaseMessaging.instance
|
final settings = await FirebaseMessaging.instance
|
||||||
.getNotificationSettings();
|
.getNotificationSettings();
|
||||||
return !isPermissionUsable(settings.authorizationStatus);
|
return settings.authorizationStatus;
|
||||||
} on Object {
|
} on Object {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the OS notification permission is already granted
|
/// True when the OS notification permission is already granted
|
||||||
/// (`authorized`/`provisional`). Read-only — never triggers the OS prompt.
|
/// (`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<bool> isOsPermissionGranted() async {
|
static Future<bool> isOsPermissionGranted() async {
|
||||||
try {
|
final status = await osPermissionStatus();
|
||||||
final settings = await FirebaseMessaging.instance
|
return status != null && canDisplay(status);
|
||||||
.getNotificationSettings();
|
|
||||||
return settings.authorizationStatus == AuthorizationStatus.authorized ||
|
|
||||||
settings.authorizationStatus == AuthorizationStatus.provisional;
|
|
||||||
} on Object {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Registers this device whenever the backend advertises the push capability.
|
||||||
/// Deliberately independent of the in-app notification toggle: a user who
|
/// Deliberately independent of both the in-app notification toggle and the
|
||||||
/// turned notifications off stays registered so silent sync pushes keep
|
/// OS notification permission: silent sync pushes (deletes, chat/badge
|
||||||
/// flowing — the display is suppressed downstream via the mirrored flag (see
|
/// refresh, widget refresh) need no permission — data-only FCM on Android,
|
||||||
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
|
/// `content-available` on iOS — so every device stays in sync. Whether a
|
||||||
/// notification permission is *already* granted — it never triggers the OS
|
/// visible notification is wanted is reported separately via the telemetry
|
||||||
/// prompt itself. Requesting the permission is the job of the first Talk visit
|
/// heartbeat (toggle + OS permission), from which the server picks silent
|
||||||
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
|
/// pushes on iOS. Never triggers the OS prompt. Safe to call on every
|
||||||
/// cold-start path. Safe to call on every start — Nextcloud dedups an
|
/// start — Nextcloud dedups an unchanged registration — which also
|
||||||
/// unchanged registration — which also self-heals a device whose registration
|
/// self-heals a device whose registration was lost.
|
||||||
/// was lost.
|
|
||||||
/// Returns whether registration was actually *attempted* (all gates passed).
|
/// Returns whether registration was actually *attempted* (all gates passed).
|
||||||
/// Even a partial success persists the `general` device identifier, so the
|
/// Even a partial success persists the `general` device identifier, so the
|
||||||
/// caller re-emits telemetry on `true` to reflect the fresh registration in
|
/// caller re-emits telemetry on `true` to reflect the fresh registration in
|
||||||
/// the same session instead of lagging until the next launch.
|
/// the same session instead of lagging until the next launch.
|
||||||
static Future<bool> syncSubscription({required bool capable}) async {
|
static Future<bool> syncSubscription({required bool capable}) async {
|
||||||
if (!capable) return false;
|
if (!capable) return false;
|
||||||
if (!await isOsPermissionGranted()) {
|
|
||||||
log('Push: OS notification permission not granted, skipping registration');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final registration = PushRegistration();
|
final registration = PushRegistration();
|
||||||
// register() below refreshes an unchanged subscription anyway; the check
|
// register() below refreshes an unchanged subscription anyway; the check
|
||||||
// only surfaces the endpoint switch in the log for diagnosability.
|
// only surfaces the endpoint switch in the log for diagnosability.
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../model/account_data.dart';
|
import '../model/account_data.dart';
|
||||||
@@ -141,21 +140,11 @@ Future<PushStatusReport> collectPushStatus({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<PushCheck> _osPermission() async {
|
Future<PushCheck> _osPermission() async {
|
||||||
try {
|
final status = await PushRegistration.osPermissionStatus();
|
||||||
final settings = await FirebaseMessaging.instance.getNotificationSettings();
|
if (status == null) return PushCheck.unknown;
|
||||||
switch (settings.authorizationStatus) {
|
if (PushRegistration.canDisplay(status)) return PushCheck.ok;
|
||||||
case AuthorizationStatus.authorized:
|
if (!PushRegistration.isPermissionUsable(status)) return PushCheck.fail;
|
||||||
case AuthorizationStatus.provisional:
|
return PushCheck.unknown;
|
||||||
return PushCheck.ok;
|
|
||||||
case AuthorizationStatus.denied:
|
|
||||||
case AuthorizationStatus.deniedPermanently:
|
|
||||||
return PushCheck.fail;
|
|
||||||
case AuthorizationStatus.notDetermined:
|
|
||||||
return PushCheck.unknown;
|
|
||||||
}
|
|
||||||
} on Object {
|
|
||||||
return PushCheck.unknown;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line in the status checklist.
|
/// One line in the status checklist.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_registration.dart';
|
||||||
import '../../../../push/push_status.dart';
|
import '../../../../push/push_status.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
@@ -123,6 +124,8 @@ class _PushSettingsState extends State<_PushSettings>
|
|||||||
|
|
||||||
void _onToggle(bool enabled) {
|
void _onToggle(bool enabled) {
|
||||||
widget.settings.val(write: true).notificationSettings.enabled = 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
|
// Turning off does NOT unregister: the device stays subscribed so silent
|
||||||
// sync pushes keep arriving; the message handler and iOS NSE suppress only
|
// sync pushes keep arriving; the message handler and iOS NSE suppress only
|
||||||
// the visible notification (via the mirrored flag). Enabling (re-)registers
|
// the visible notification (via the mirrored flag). Enabling (re-)registers
|
||||||
@@ -132,11 +135,10 @@ class _PushSettingsState extends State<_PushSettings>
|
|||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
// Only register when the OS permission isn't explicitly denied —
|
final granted = await PushRegistration.requestOsPermission();
|
||||||
// otherwise NC + proxy would push into the void.
|
// Registered regardless: silent sync pushes need no permission.
|
||||||
if (await PushRegistration.requestOsPermission()) {
|
await PushRegistration().register();
|
||||||
await PushRegistration().register();
|
if (!granted) {
|
||||||
} else {
|
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter_test/flutter_test.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';
|
import 'package:marianum_mobile/push/push_registration.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -37,4 +38,36 @@ void main() {
|
|||||||
expect(AuthorizationStatus.values, hasLength(5));
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user