Files
pyramid/lib/core/notification_content.dart
Bernd SteckmeisterandClaude Opus 5.5 ef1d881967 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]>
2026-10-07 16:27:56 +02:00

294 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/media_cache.dart';
/// Was eine Nachrichten-Benachrichtigung zeigt – egal, ob sie aus der
/// laufenden App (`notification_service.dart`) oder dem Hintergrund-Isolate
/// bei beendeter App (`background_push.dart`) kommt. Beide Wege übergeben
/// [toNativeArgs] an `NotificationHelper.kt`, das daraus pro Raum einen
/// Chat-Verlauf (Android MessagingStyle) baut.
class MessageNotificationContent {
const MessageNotificationContent({
required this.roomId,
required this.eventId,
required this.senderId,
required this.senderName,
required this.roomName,
required this.body,
required this.timestamp,
this.isImage = false,
});
final String roomId;
final String eventId;
final String senderId;
final String senderName;
/// `null` bei Direktnachrichten – dann zeigt Android nur den Absender.
final String? roomName;
final String body;
final DateTime timestamp;
/// Bild-Nachricht: Vorschaubild wird nachgeladen ([cacheNotificationImage]).
final bool isImage;
bool get isGroup => roomName != null;
/// Einzeiliger Titel für Plattformen ohne Chat-Verlauf (Windows).
String get title => roomName == null ? senderName : '$senderName · $roomName';
/// Liest den Inhalt aus einem (entschlüsselten) Event. `null`, wenn es
/// nichts Anzeigbares ist (nicht entschlüsselt, gelöscht, kein
/// Nachrichten-Event) – dann bleibt ein evtl. Platzhalter stehen.
/// Ist [showPreview] aus (Einstellung „Vorschau“), steht nur
/// [notificationPlaceholderBody] da und es wird kein Bild geladen.
static MessageNotificationContent? fromEvent(
Event event, {
bool showPreview = true,
}) {
final body = notificationBody(event);
if (body == null) return null;
final room = event.room;
final displayName = room
.unsafeGetUserFromMemoryOrFallback(event.senderId)
.displayName
?.trim();
return MessageNotificationContent(
roomId: room.id,
eventId: event.eventId,
senderId: event.senderId,
senderName: (displayName == null || displayName.isEmpty)
? _localpart(event.senderId)
: displayName,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
body: showPreview ? body : notificationPlaceholderBody,
timestamp: event.originServerTs,
isImage: showPreview && event.messageType == MessageTypes.Image,
);
}
/// Argumente für den nativen Kanal (`showNativeNotification` bzw.
/// `showDecrypted`). `title`/`body` bleiben für ältere Native-Pfade drin.
Map<String, Object?> toNativeArgs({
required int notifId,
String? imagePath,
String? avatarPath,
}) => {
'title': title,
'body': body,
'roomId': roomId,
'notifId': notifId,
'eventId': eventId,
'senderId': senderId,
'senderName': senderName,
'roomName': roomName,
'isGroup': isGroup,
'timestamp': timestamp.millisecondsSinceEpoch,
'imagePath': imagePath,
'avatarPath': avatarPath,
};
}
/// Text, den Android anzeigt, solange der echte Inhalt fehlt.
const notificationPlaceholderBody = 'Neue Nachricht';
/// Längere Texte werden gekürzt (aufgeklappt zeigt Android ohnehin nicht mehr).
const notificationBodyMaxChars = 1000;
/// Anzeigetext einer Nachricht für die Benachrichtigung, oder `null`, wenn
/// das Event nichts Anzeigbares enthält.
String? notificationBody(Event event) {
if (event.redacted) return null;
if (event.type == EventTypes.Sticker) return '🏷️ Sticker';
if (event.type == _pollStartType) return '📊 Umfrage';
if (event.type != EventTypes.Message) return null;
final content = event.content;
final caption = _caption(event);
final String? text = switch (event.messageType) {
MessageTypes.Text || MessageTypes.Notice => _plainText(event),
MessageTypes.Emote => '* ${_plainText(event)}',
MessageTypes.Image => caption == null ? '📷 Foto' : '📷 $caption',
MessageTypes.Video => caption == null ? '🎥 Video' : '🎥 $caption',
MessageTypes.Audio =>
content.containsKey(_voiceKey) ? '🎤 Sprachnachricht' : '🎵 Audio',
MessageTypes.File =>
'📎 ${content.tryGet<String>('filename') ?? event.body}',
MessageTypes.Location => '📍 Standort',
MessageTypes.Sticker => '🏷️ Sticker',
MessageTypes.BadEncrypted => null,
_ => _plainText(event),
};
final trimmed = text?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed.length > notificationBodyMaxChars
? '${trimmed.substring(0, notificationBodyMaxChars)}…'
: trimmed;
}
const _pollStartType = 'org.matrix.msc3381.poll.start';
const _voiceKey = 'org.matrix.msc3245.voice';
/// Klartext ohne Antwort-Zitat („> <@x> …“) und ohne HTML.
String _plainText(Event event) => event.calcUnlocalizedBody(
hideReply: true,
hideEdit: true,
plaintextBody: true,
);
/// MSC2530: Steht neben `filename` ein abweichender `body`, ist das die
/// Bildunterschrift.
String? _caption(Event event) {
final filename = event.content.tryGet<String>('filename');
final body = event.content.tryGet<String>('body')?.trim();
if (filename == null || body == null || body.isEmpty || body == filename) {
return null;
}
return body;
}
String _localpart(String userId) =>
userId.split(':').first.replaceFirst('@', '');
// ── Bilder für die Benachrichtigung ─────────────────────────────────────────
// Android zeigt Bilder in Benachrichtigungen nur über eine content://-URI.
// Die Dateien liegen deshalb unter <cache>/downloads/notif/ – genau dort gibt
// der FileProvider der App frei (res/xml: cache-path "downloads/").
const _maxImageBytes = 8 * 1024 * 1024;
const _maxAvatarBytes = 2 * 1024 * 1024;
const _keepFilesFor = Duration(days: 2);
/// Lädt das Vorschaubild einer Bild-Nachricht (bei E2EE entschlüsselt) in
/// den Cache und liefert den Dateipfad – oder `null`, wenn es nicht klappt.
Future<String?> cacheNotificationImage(Event event) async {
if (event.messageType != MessageTypes.Image) return null;
try {
final file = await event
.downloadAndDecryptAttachment(getThumbnail: event.hasThumbnail)
.timeout(const Duration(seconds: 20));
return await _writeNotifFile(
'img_${_hash(event.eventId)}',
file.bytes,
maxBytes: _maxImageBytes,
);
} catch (_) {
return null;
}
}
/// Avatar des Absenders als Datei (zuerst aus dem Bild-Cache der App).
Future<String?> cacheNotificationAvatar(Event event) async {
try {
final client = event.room.client;
final mxc = event.room
.unsafeGetUserFromMemoryOrFallback(event.senderId)
.avatarUrl;
if (mxc == null) return null;
final name = 'avatar_${_hash(mxc.toString())}';
final existing = File(p.join((await _notifDir()).path, name));
if (await existing.exists()) return existing.path;
final cacheKey = 'img:$mxc';
var bytes = await MediaCache.instance.getPersistent(cacheKey);
if (bytes == null) {
final headers = {'authorization': 'Bearer ${client.accessToken}'};
try {
final thumb = await mxc.getThumbnailUri(
client,
width: 128,
height: 128,
method: ThumbnailMethod.crop,
);
final res = await client.httpClient
.get(thumb, headers: headers)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) bytes = res.bodyBytes;
} catch (_) {}
if (bytes == null) {
// Continuwuity kann keine Thumbnails → Original (Avatare sind klein).
final full = await mxc.getDownloadUri(client);
final res = await client.httpClient
.get(full, headers: headers)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) bytes = res.bodyBytes;
}
if (bytes != null) {
await MediaCache.instance.putPersistent(cacheKey, bytes);
}
}
if (bytes == null) return null;
return await _writeNotifFile(name, bytes, maxBytes: _maxAvatarBytes);
} catch (_) {
return null;
}
}
Future<Directory> _notifDir() async {
final dir = Directory(
p.join((await getTemporaryDirectory()).path, 'downloads', 'notif'),
);
await dir.create(recursive: true);
return dir;
}
Future<String?> _writeNotifFile(
String name,
Uint8List bytes, {
required int maxBytes,
}) async {
if (bytes.isEmpty || bytes.length > maxBytes) return null;
final dir = await _notifDir();
await _pruneOldFiles(dir);
final file = File(p.join(dir.path, name));
await file.writeAsBytes(bytes, flush: true);
return file.path;
}
Future<void> _pruneOldFiles(Directory dir) async {
try {
final cutoff = DateTime.now().subtract(_keepFilesFor);
await for (final entity in dir.list()) {
if (entity is File && (await entity.lastModified()).isBefore(cutoff)) {
await entity.delete();
}
}
} catch (_) {}
}
/// Dateiname aus Event-ID/MXC (enthält `$`, `:` und `/`).
String _hash(String input) =>
sha1.convert(utf8.encode(input)).toString().substring(0, 20);
/// Zeigt eine Nachricht auf Android in zwei Schritten: erst sofort den Text
/// (damit nichts wartet), dann – nur falls es etwas gibt – lautlos dasselbe
/// mit Avatar und Bild. Native erkennt die Aktualisierung an der eventId und
/// ersetzt die Nachricht im Verlauf, statt sie doppelt anzuhängen.
Future<void> deliverMessageNotification(
Event event,
MessageNotificationContent content, {
required int notifId,
required Future<void> Function(Map<String, Object?> args) send,
}) async {
await send(content.toNativeArgs(notifId: notifId));
final avatarPath = await cacheNotificationAvatar(event);
final imagePath = content.isImage
? await cacheNotificationImage(event)
: null;
if (avatarPath == null && imagePath == null) return;
await send(
content.toNativeArgs(
notifId: notifId,
avatarPath: avatarPath,
imagePath: imagePath,
),
);
}