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:
co-authored by
Claude Opus 5.5
parent
460477204b
commit
ef1d881967
@@ -8,6 +8,7 @@ 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/soft_logout_guard.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -64,6 +65,11 @@ void notificationEngineMain() {
|
||||
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');
|
||||
@@ -88,26 +94,27 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (event == null) return false;
|
||||
// Bad-encrypted body still starts with "**" — keep the native placeholder.
|
||||
if (event.body.startsWith('**')) return false;
|
||||
|
||||
final room = client.getRoomById(roomId);
|
||||
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
|
||||
event.senderId.split(':').first.replaceFirst('@', '');
|
||||
final title = (room?.isDirectChat ?? true)
|
||||
? sender
|
||||
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
|
||||
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
|
||||
// MainActivity-based reply action (the flutter_local_notifications reply
|
||||
// path doesn't fire dependably when the app is killed).
|
||||
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
|
||||
'title': title,
|
||||
'body': _buildBody(event),
|
||||
'room_id': roomId,
|
||||
'notif_id': notifId ?? _stableId(roomId),
|
||||
});
|
||||
// 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');
|
||||
@@ -152,6 +159,27 @@ Future<bool> _bgSendReply(String? roomId, String? text) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// „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 room
|
||||
.setReadMarker(eventId, mRead: eventId)
|
||||
.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 {
|
||||
@@ -217,20 +245,6 @@ Future<Client?> _buildClient() async {
|
||||
}
|
||||
}
|
||||
|
||||
String _buildBody(Event event) {
|
||||
final msgType = event.messageType;
|
||||
final rawBody = event.body;
|
||||
return switch (msgType) {
|
||||
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody,
|
||||
'm.image' => '📷 Bild',
|
||||
'm.video' => '🎥 Video',
|
||||
'm.audio' => '🎵 Audio',
|
||||
'm.file' => '📄 Datei',
|
||||
'm.sticker' => '🏷️ Sticker',
|
||||
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user