merged develop
This commit is contained in:
@@ -60,6 +60,10 @@ class PushActions {
|
||||
// auth header, the Talk POST never happens and the RemoteInput spinner
|
||||
// runs forever.
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
// The action engine lives as long as the process: without a reload a
|
||||
// reply would be sent with the account that was signed in when the
|
||||
// engine first started.
|
||||
await SessionManager().reloadFromStorage();
|
||||
|
||||
_plog(
|
||||
'action=${response.actionId} payload=${response.payload} '
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypton/crypton.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pointycastle/export.dart' as pc;
|
||||
|
||||
import 'push_subject.dart';
|
||||
@@ -25,6 +25,14 @@ class PushDecryptor {
|
||||
|
||||
const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey});
|
||||
|
||||
/// [verify] + [decrypt] on a background isolate: pure-Dart RSA-2048 (with
|
||||
/// the OAEP → PKCS#1 fallback) takes tens of ms up to >100 ms on older
|
||||
/// phones, which would otherwise land on the UI thread for every push.
|
||||
Future<({bool verified, PushSubject? subject})> verifyAndDecryptInBackground(
|
||||
String subjectBase64,
|
||||
String signatureBase64,
|
||||
) => compute(_verifyAndDecrypt, (this, subjectBase64, signatureBase64));
|
||||
|
||||
/// Returns true when [signatureBase64] is a valid server signature over the
|
||||
/// encrypted subject. Returns true when no server key is configured (the
|
||||
/// proxy already verified the signature before forwarding).
|
||||
@@ -75,3 +83,13 @@ class PushDecryptor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
({bool verified, PushSubject? subject}) _verifyAndDecrypt(
|
||||
(PushDecryptor, String, String) args,
|
||||
) {
|
||||
final (decryptor, subject, signature) = args;
|
||||
if (!decryptor.verify(subject, signature)) {
|
||||
return (verified: false, subject: null);
|
||||
}
|
||||
return (verified: true, subject: decryptor.decrypt(subject));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
|
||||
import '../background/widget_background_task.dart';
|
||||
import '../notification/notification_service.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import 'chat_thread_store.dart';
|
||||
import 'nid_store.dart';
|
||||
import 'push_decryptor.dart';
|
||||
@@ -54,6 +55,8 @@ PushKind classifyPush(Map<String, dynamic> data) {
|
||||
/// "must be annotated"), so a plain function is the reliable form.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
|
||||
// This engine outlives sign-outs and logins in the app.
|
||||
await SessionManager().reloadFromStorage();
|
||||
await NotificationService().initializeNotifications();
|
||||
await PushRenderer.ensureChannels();
|
||||
await PushMessageHandler().handle(message);
|
||||
@@ -175,11 +178,15 @@ class PushMessageHandler {
|
||||
devicePrivateKey: privateKey,
|
||||
serverPublicKey: serverPublicKey,
|
||||
);
|
||||
if (!decryptor.verify(subjectBase64, signatureBase64)) {
|
||||
final result = await decryptor.verifyAndDecryptInBackground(
|
||||
subjectBase64,
|
||||
signatureBase64,
|
||||
);
|
||||
if (!result.verified) {
|
||||
log('Push: signature verification failed');
|
||||
return;
|
||||
}
|
||||
final subject = decryptor.decrypt(subjectBase64);
|
||||
final subject = result.subject;
|
||||
if (subject == null) {
|
||||
log('Push: could not decrypt subject');
|
||||
return;
|
||||
|
||||
@@ -118,6 +118,7 @@ class PushRegistration {
|
||||
Future<bool> register() async {
|
||||
if (DemoMode.active) return false;
|
||||
if (_nextcloudOrNull == null) return _direct.register();
|
||||
final epoch = SessionManager().sessionEpoch;
|
||||
final String? fcmToken;
|
||||
try {
|
||||
fcmToken = await FirebaseMessaging.instance.getToken();
|
||||
@@ -167,6 +168,7 @@ class PushRegistration {
|
||||
fcmToken: fcmToken,
|
||||
pems: pems,
|
||||
appVersion: appVersion,
|
||||
epoch: epoch,
|
||||
);
|
||||
allOk = allOk && ok;
|
||||
}
|
||||
@@ -178,6 +180,7 @@ class PushRegistration {
|
||||
required String fcmToken,
|
||||
required PushKeypairPems pems,
|
||||
required String? appVersion,
|
||||
required int epoch,
|
||||
}) async {
|
||||
try {
|
||||
final proxyServer = currentProxyServer;
|
||||
@@ -193,6 +196,11 @@ class PushRegistration {
|
||||
userAgent: isTalk ? _talkUserAgent : null,
|
||||
);
|
||||
|
||||
// Signed out while registering: persisting or announcing this
|
||||
// registration would keep delivering the previous account's pushes,
|
||||
// and logoutCleanup already ran so nothing would unregister it.
|
||||
if (!SessionManager().isCurrentSession(epoch)) return false;
|
||||
|
||||
await _store.save(
|
||||
type: type,
|
||||
deviceIdentifier: registration.deviceIdentifier,
|
||||
@@ -355,9 +363,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<bool> requestOsPermission() async {
|
||||
try {
|
||||
final settings = await FirebaseMessaging.instance.requestPermission();
|
||||
@@ -368,56 +375,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<bool> isOsPermissionDenied() async {
|
||||
/// Current OS notification permission, or null when the plugin can't tell.
|
||||
/// Read-only — never triggers the OS prompt.
|
||||
static Future<AuthorizationStatus?> 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<bool> 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<bool> 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.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../session/session_manager.dart';
|
||||
@@ -142,21 +141,11 @@ Future<PushStatusReport> collectPushStatus({
|
||||
}
|
||||
|
||||
Future<PushCheck> _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.
|
||||
|
||||
Reference in New Issue
Block a user