Files
pyramid/lib/core/background_push.dart
T
Bernd SteckmeisterandClaude Opus 5.5 1d0ccfde8b fix: Lesebestätigung nur im Vordergrund, ohne Dauerfeuer, privat statt gar nicht
- Offener Chat meldete bei jedem Neuaufbau erneut "gelesen" – auch mit
  App im Hintergrund; die gerade gezeigte Benachrichtigung verschwand dann
  sofort wieder. Jetzt nur bei Fokus, je Event nur einmal.
- Zurück in der App: "Neue Nachrichten"-Trenner hinter dem zuletzt
  gelesenen Stand, danach gelesen melden.
- "Lesebestätigungen senden" aus: nur noch private Bestätigung
  (m.read.private) statt gar keiner – Ungelesen-Zähler stimmen wieder.
  Gilt auch für "Gelesen" aus der Benachrichtigung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:32:36 +02:00

320 lines
13 KiB
Dart

import 'dart:io';
import 'dart:ui';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/read_markers.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Port name used to locate the main isolate from background isolates.
/// Registered by notification_service.dart when the app starts.
const kMainIsolateName = 'pyramid_main';
int _stableId(String roomId) {
var h = 0;
for (final c in roomId.codeUnits) {
h = ((h * 31) + c) & 0x7FFFFFFF;
}
return h == 0 ? 1 : h;
}
// ── FCM background handler ────────────────────────────────────────────────────
// Runs in its own Dart isolate (or fresh process) when the app is killed or
// backgrounded. Firebase calls this after Firebase.initializeApp() is done.
@pragma('vm:entry-point')
Future<void> handleBackgroundMessage(RemoteMessage message) async {
// PushService.kt handles all FCM-triggered notifications natively.
// This callback is registered for API completeness but never fires because
// FlutterFirebaseMessagingService is removed from the manifest.
}
// ── Background engine entrypoint (killed-app E2EE work) ───────────────────────
// PushService.kt / ReplyReceiver bootstrap a headless FlutterEngine running this
// entrypoint when the app is KILLED (no main engine). It exposes a MethodChannel
// so native can ask us to (a) decrypt a push event and update its notification,
// or (b) send an encrypted reply. Building a Matrix client here is safe ONLY
// because native gates this on the main app being dead — never two clients on
// the same olm DB at once.
const _kBgEngineChannel = 'chat.pyramid.pyramid/bg_engine';
@pragma('vm:entry-point')
void notificationEngineMain() {
WidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel(_kBgEngineChannel);
channel.setMethodCallHandler((call) async {
try {
final args = (call.arguments as Map?)?.cast<String, dynamic>() ?? {};
switch (call.method) {
case 'decryptAndShow':
return await _bgDecryptAndShow(
args['room_id'] as String?,
args['event_id'] as String?,
(args['notif_id'] as num?)?.toInt(),
);
case 'sendReply':
return await _bgSendReply(
args['room_id'] as String?,
args['text'] as String?,
);
case 'markRead':
return await _bgMarkRead(
args['room_id'] as String?,
args['event_id'] as String?,
);
}
} catch (e) {
debugPrint('[NOTIF-BGENGINE] handler error: $e');
}
return false;
});
// Signal native that the channel handler is installed and tasks can be sent.
channel.invokeMethod('bgEngineReady');
}
Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) async {
if (roomId == null || eventId == null) return false;
final client = await _buildClient();
if (client == null) return false;
try {
final event = await client
.getEventByPushNotification(
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
storeInDatabase: false,
returnNullIfSeen: false,
)
.timeout(const Duration(seconds: 20));
if (event == null) return false;
final prefs = await SharedPreferences.getInstance();
final content = MessageNotificationContent.fromEvent(
event,
showPreview: prefs.getBool('notif_preview') ?? true,
);
// Nicht entschlüsselt / nichts Anzeigbares → nativer Platzhalter bleibt.
if (content == null) return false;
// Hand the decrypted content back to native so it re-shows the SAME
// notification via NotificationHelper — that keeps the reliable
// ReplyReceiver-based reply action (the flutter_local_notifications reply
// path doesn't fire dependably when the app is killed). Erst der Text,
// dann lautlos Avatar/Bild (deliverMessageNotification).
await deliverMessageNotification(
event,
content,
notifId: notifId ?? _stableId(roomId),
send: (a) => const MethodChannel(_kBgEngineChannel)
.invokeMethod('showDecrypted', a),
);
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
return false;
} finally {
// Let the async key-backup upload settle before closing the DB — otherwise
// it logs a harmless "database_closed" error mid-flight.
await Future.delayed(const Duration(seconds: 2));
await client.dispose().catchError((_) {});
}
}
Future<bool> _bgSendReply(String? roomId, String? text) async {
if (roomId == null || text == null || text.trim().isEmpty) return false;
final client = await _buildClient();
if (client == null) return false;
try {
// A one-shot sync ensures the room and recipient device keys are present so
// the message can be encrypted. Safe here: main app is dead (native-gated).
await client.oneShotSync().timeout(const Duration(seconds: 20)).catchError((_) => null);
final room = client.getRoomById(roomId);
if (room == null) {
// Fall back to the prefs queue so the main app sends it on next launch.
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
return false;
}
await room.sendTextEvent(text.trim());
debugPrint('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] sendReply error: $e');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
return false;
} finally {
// Allow the encrypted send + any key upload to flush before closing the DB.
await Future.delayed(const Duration(seconds: 2));
await client.dispose().catchError((_) {});
}
}
/// „Gelesen“ bei beendeter App: Lesebestätigung bis [eventId] setzen (reiner
/// HTTP-Aufruf, keine Verschlüsselung nötig).
Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
if (roomId == null || eventId == null) return false;
final client = await _buildClient();
if (client == null) return false;
try {
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
await markRoomReadUpTo(
room,
eventId,
publicReceipt: await loadReadReceiptsPublic(),
).timeout(const Duration(seconds: 15));
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] markRead error: $e');
return false;
} finally {
await client.dispose().catchError((_) {});
}
}
/// Stores the raw callback handle of [notificationEngineMain] so native code can
/// bootstrap the background engine. Call once at app startup.
Future<void> registerBgEngineHandle() async {
if (!Platform.isAndroid) return;
try {
final handle = PluginUtilities.getCallbackHandle(notificationEngineMain);
if (handle == null) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('bg_engine_handle', handle.toRawHandle());
} catch (e) {
debugPrint('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
}
}
// ── Build a short-lived Matrix client for background work ────────────────────
// Uses NativeImplementationsDummy so Vodozemac runs synchronously in this
// isolate — NativeImplementationsIsolate(compute) would try to spawn a
// sub-isolate, which fails inside a Firebase background isolate.
Future<Client?> _buildClient() async {
try {
// vodozemac may already be initialised in this isolate (e.g. a decrypt task
// ran before this reply task reused the same engine). Calling init() twice
// throws — swallow it so it doesn't fail the whole client build.
try {
await vod.init();
} catch (_) {}
// Gemeinsame Storage-Fassade: erkennt Klartext vs. SQLCipher am
// Datei-Header, migriert aber NIE selbst (docs/SQLCIPHER_MIGRATION.md).
// null = gerade kein sicherer Zugriff (Migration läuft / DB oder
// Schlüssel fehlt) → Notification behält den nativen Platzhalter.
final db =
await AppDatabase.openForPush().timeout(const Duration(seconds: 10));
if (db == null) return null;
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
final client = Client(
'Pyramid',
database: sdkDb,
nativeImplementations: NativeImplementationsDummy(),
logLevel: Level.error,
// Kritisch: getEventByPushNotification() ruft als Erstes
// ensureNotSoftLoggedOut() auf. Ohne eigenen Handler würde ein
// fehlgeschlagener Token-Refresh AUS DEM HINTERGRUND-ISOLATE heraus
// logout()+clear() auf der geteilten pyramid.sqlite auslösen und die
// Session der gesamten App zerstören. Siehe soft_logout_guard.dart.
onSoftLogout: guardedSoftLogoutRefresh,
);
await client
.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: false,
)
.timeout(const Duration(seconds: 15));
return client;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] _buildClient failed: $e');
return null;
}
}
// ── Notification action handler ───────────────────────────────────────────────
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
// Must return quickly — Android keeps the notification's "sending" spinner
// visible until this function returns.
@pragma('vm:entry-point')
Future<void> handleBackgroundNotificationResponse(
NotificationResponse details) async {
WidgetsFlutterBinding.ensureInitialized();
debugPrint('[NOTIF-BG] handleBackgroundNotificationResponse fired');
debugPrint('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
// ── Fast path: main isolate is running ───────────────────────────────────
// If the Pyramid UI process is alive (app is backgrounded but not killed),
// forward the action directly via IsolateNameServer. This avoids starting
// any new engine and works silently without bringing the app to foreground.
final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName);
if (sendPort != null) {
debugPrint('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer');
sendPort.send(<String, dynamic>{
'actionId': details.actionId,
'payload': details.payload,
'input': details.input,
'id': details.id,
});
return;
}
// ── Slow path: app was killed ─────────────────────────────────────────────
// Main isolate is gone. Dismiss the notification and, for reply actions,
// persist the text in SharedPreferences so _sendPendingReply picks it up
// the next time the app opens (via getInitialReply cold-start flow).
debugPrint('[NOTIF-BG] main isolate not running → SharedPreferences fallback');
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@drawable/ic_notification')),
onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse,
);
final roomId = details.payload;
if (roomId != null) {
await plugin.cancel(_stableId(roomId));
debugPrint('[NOTIF-BG] cancelled notification for room $roomId');
} else if (details.id != null) {
await plugin.cancel(details.id!);
debugPrint('[NOTIF-BG] cancelled notification id ${details.id}');
}
if (details.actionId != 'reply') {
debugPrint('[NOTIF-BG] not a reply action, done');
return;
}
final text = details.input?.trim() ?? '';
if (text.isEmpty || roomId == null) {
debugPrint('[NOTIF-BG] text empty or roomId null, aborting');
return;
}
// App killed — send the encrypted reply directly from this background isolate.
// _bgSendReply falls back to the prefs queue if it can't send (so the main app
// delivers it on next launch).
final sent = await _bgSendReply(roomId, text);
debugPrint('[NOTIF-BG] background reply sent=$sent room=$roomId');
}