merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
+4
View File
@@ -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} '
+19 -1
View File
@@ -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));
}
+9 -2
View File
@@ -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;
+32 -34
View File
@@ -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.
+5 -16
View File
@@ -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.