feat: Push als Chat-Verlauf - mehrere Nachrichten, voller Text, Bildvorschau, echtes "Gelesen"

Bernds Beta-Feedback: pro Raum wurde jede Nachricht durch die naechste ersetzt
und auf 100 Zeichen gekuerzt, ohne Bild. Jetzt:
- NotificationHelper: MessagingStyle mit den letzten 8 Nachrichten pro Raum
  (Verlauf in SharedPreferences, nur solange die Benachrichtigung sichtbar ist),
  gleiche eventId ersetzt (Platzhalter -> Klartext), nur Neues klingelt.
- notification_content.dart: Text bis 1000 Zeichen, Antwort-Zitat entfernt,
  Medien-Labels, Vorschau-Einstellung auch bei gekillter App; Bild (E2EE
  entschluesselt) + Avatar per FileProvider an die System-UI.
- "Gelesen" setzt eine echte Lesebestaetigung (Haupt-Engine bzw. BgEngine).
- Laufende App holt genau das gepushte Event statt room.lastEvent.
- Debug-only DebugNotifyReceiver zum Testen per adb ohne Login.
Emulator (API 36): Verlauf, Ersetzen, Bild, Avatar, Gelesen geprueft;
9 neue Tests. Echtes Geraet (Bernd) steht aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 16:27:56 +02:00
co-authored by Claude Opus 5.5
parent 460477204b
commit ef1d881967
12 changed files with 1103 additions and 170 deletions
+66 -77
View File
@@ -12,6 +12,7 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart';
@@ -197,6 +198,21 @@ Future<void> _initAndroid(WidgetRef ref) async {
if (!share.isEmpty) {
ref.read(pendingShareProvider.notifier).state = share;
}
} else if (call.method == 'markReadFromNotification') {
// „Gelesen“ in der Benachrichtigung (ReplyReceiver, App lebt).
final roomId = (args is Map ? args['room_id'] : null) as String?;
final eventId = (args is Map ? args['event_id'] : null) as String?;
if (roomId != null && eventId != null) {
try {
final client = await ref.read(matrixClientProvider.future);
await client
.getRoomById(roomId)
?.setReadMarker(eventId, mRead: eventId);
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
} catch (e) {
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
}
}
} else if (call.method == 'decryptAndUpdateNotification') {
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
// We decrypt the event here (the Matrix client has the keys) and overwrite
@@ -458,57 +474,37 @@ Future<void> _decryptAndUpdateNotification(
final client = await ref
.read(matrixClientProvider.future)
.timeout(const Duration(seconds: 5));
var room = client.getRoomById(roomId);
if (room == null) {
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
if (eventId == null) return;
// Genau das gepushte Event holen (lokal oder vom Server, entschlüsselt) –
// nicht room.lastEvent, das kann inzwischen eine Reaktion o. Ä. sein.
final event = await client
.getEventByPushNotification(
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
storeInDatabase: false,
returnNullIfSeen: false,
)
.timeout(const Duration(seconds: 20));
if (event == null || event.senderId == client.userID) return;
final content = MessageNotificationContent.fromEvent(
event,
showPreview: ref.read(notifPreviewProvider),
);
if (content == null) {
debugPrint('[NOTIF] decryptAndUpdate: nichts Anzeigbares, Platzhalter bleibt');
return;
}
// The event might not have synced yet — wait for the next sync (max 12 s).
// We use lastEvent as a proxy: after sync it will be the event we want.
Event? event = room.lastEvent;
final bool alreadyHave =
eventId == null || event?.eventId == eventId;
if (!alreadyHave || event == null) {
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
room = client.getRoomById(roomId);
if (room == null) return;
event = room.lastEvent;
}
if (event == null) {
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
return;
}
if (event.type != EventTypes.Message) return;
if (event.senderId == client.userID) return;
final rawBody = event.body;
// If decryption failed the body starts with "** Unable to decrypt"
if (rawBody.isEmpty || rawBody.startsWith('**')) {
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
return;
}
final notifBody =
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
final title = room.isDirectChat
? displayName
: '$displayName · ${room.getLocalizedDisplayname()}';
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
// replaces the existing notification rather than stacking a new one.
await _kInstallChannel.invokeMethod('showNativeNotification', {
'title': title,
'body': notifBody,
'roomId': roomId,
'notifId': notifId ?? _stableRoomId(roomId),
});
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
// Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
// Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
await deliverMessageNotification(
event,
content,
notifId: notifId ?? _stableRoomId(roomId),
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
);
debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
} catch (e) {
debugPrint('[NOTIF] decryptAndUpdate error: $e');
}
@@ -516,27 +512,30 @@ Future<void> _decryptAndUpdateNotification(
// ─── Show notification ────────────────────────────────────────────────────────
Future<void> showMessageNotification({
required String roomId,
String? roomName,
required String senderName,
required String body,
}) async {
final title = roomName != null ? '$senderName · $roomName' : senderName;
Future<void> showMessageNotification(
Event event,
MessageNotificationContent content,
) async {
final roomId = content.roomId;
if (Platform.isAndroid && _androidInitialized) {
// Use native Kotlin notification so the reply PendingIntent targets
// ReplyReceiver directly — the app never opens when the user replies.
try {
await _kInstallChannel.invokeMethod('showNativeNotification', {
'title': title,
'body': body,
'roomId': roomId,
'notifId': _stableRoomId(roomId),
});
await deliverMessageNotification(
event,
content,
notifId: _stableRoomId(roomId),
send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
);
} catch (e) {
debugPrint('[NOTIF] showNativeNotification failed: $e');
}
} else if (Platform.isWindows) {
final title = content.title;
// Windows-Toasts zeigen ohnehin nur wenige Zeilen.
final body = content.body.length > 300
? '${content.body.substring(0, 300)}…'
: content.body;
// Close the previous notification for this room before showing a new one.
_windowsNotifs.remove(roomId)?.close();
@@ -624,23 +623,13 @@ void _watchEvents(Ref ref, Client client) {
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
}
// ── Build notification body ───────────────────────────────────────────
final rawBody = event.body;
final showPreview = ref.read(notifPreviewProvider);
final notifBody = showPreview
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
: 'Neue Nachricht';
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
showMessageNotification(
roomId: roomId,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
senderName: displayName,
body: notifBody,
// ── Build notification content ────────────────────────────────────────
final content = MessageNotificationContent.fromEvent(
event,
showPreview: ref.read(notifPreviewProvider),
);
if (content == null) return;
showMessageNotification(event, content);
});
}