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]>
294 lines
10 KiB
Dart
294 lines
10 KiB
Dart
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,
|
||
),
|
||
);
|
||
}
|