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 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('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('filename'); final body = event.content.tryGet('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 /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 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 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 _notifDir() async { final dir = Directory( p.join((await getTemporaryDirectory()).path, 'downloads', 'notif'), ); await dir.create(recursive: true); return dir; } Future _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 _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 deliverMessageNotification( Event event, MessageNotificationContent content, { required int notifId, required Future Function(Map 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, ), ); }