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
+211
View File
@@ -0,0 +1,211 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'support/synthetic_matrix_data.dart';
/// Vertragstests für den Inhalt der Push-Benachrichtigungen (Bernds
/// Beta-Feedback 2026-10-07: „beim Aufklappen viel weniger Text“, „keine
/// Bildvorschau“). Native Darstellung (Verlauf, Bild) prüft das Gerät.
void main() {
late Client client;
late Room room;
setUp(() async {
final factory = createDatabaseFactoryFfi(
ffiInit: sqfliteFfiInit,
noIsolate: true,
);
final db = await factory.openDatabase(inMemoryDatabasePath);
client = await SyntheticMatrixData.openClient(db, name: 'PyramidNotifTest');
room = Room(id: '!raum:bench.invalid', client: client);
room.setState(
Event(
type: EventTypes.RoomName,
content: {'name': 'Familie'},
eventId: r'$name',
senderId: '@uta:bench.invalid',
stateKey: '',
originServerTs: DateTime(2026),
room: room,
),
);
room.setState(
Event(
type: EventTypes.RoomMember,
content: {'membership': 'join', 'displayname': 'Uta'},
eventId: r'$member',
senderId: '@uta:bench.invalid',
stateKey: '@uta:bench.invalid',
originServerTs: DateTime(2026),
room: room,
),
);
});
tearDown(() async {
await client.dispose(closeDatabase: true);
});
Event message(
Map<String, Object?> content, {
String type = EventTypes.Message,
String sender = '@uta:bench.invalid',
}) => Event(
type: type,
content: content,
eventId: r'$ev1',
senderId: sender,
originServerTs: DateTime(2026, 10, 7, 12, 30),
room: room,
);
test('Text wird NICHT mehr auf 100 Zeichen gekürzt', () {
final text = 'Wort ' * 60; // 300 Zeichen
final body = notificationBody(message({'msgtype': 'm.text', 'body': text}));
expect(body, text.trim());
});
test('sehr lange Texte werden bei 1000 Zeichen gekappt', () {
final body = notificationBody(
message({'msgtype': 'm.text', 'body': 'x' * 5000}),
)!;
expect(body.length, notificationBodyMaxChars + 1);
expect(body.endsWith('…'), isTrue);
});
test('Antwort-Zitat (Reply-Fallback) wird entfernt', () {
final body = notificationBody(
message({
'msgtype': 'm.text',
'body': '> <@bernd:bench.invalid> Kommst du heute?\n\nJa, um acht!',
}),
);
expect(body, 'Ja, um acht!');
});
test('Medien bekommen verständliche Bezeichnungen', () {
expect(
notificationBody(message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'})),
'📷 Foto',
);
expect(
notificationBody(
message({
'msgtype': 'm.image',
'body': 'Unser Garten',
'filename': 'IMG_2.jpg',
}),
),
'📷 Unser Garten',
reason: 'MSC2530: abweichender body neben filename = Bildunterschrift',
);
expect(
notificationBody(message({'msgtype': 'm.video', 'body': 'v.mp4'})),
'🎥 Video',
);
expect(
notificationBody(
message({
'msgtype': 'm.audio',
'body': 'Sprachnachricht.ogg',
'org.matrix.msc3245.voice': <String, Object?>{},
}),
),
'🎤 Sprachnachricht',
);
expect(
notificationBody(message({'msgtype': 'm.audio', 'body': 'lied.mp3'})),
'🎵 Audio',
);
expect(
notificationBody(
message({
'msgtype': 'm.file',
'body': 'rechnung.pdf',
'filename': 'rechnung.pdf',
}),
),
'📎 rechnung.pdf',
);
expect(
notificationBody(message({'body': 'x'}, type: EventTypes.Sticker)),
'🏷️ Sticker',
);
});
test('nicht entschlüsselte Nachricht → null (Platzhalter bleibt)', () {
expect(
notificationBody(
message({
'algorithm': 'm.megolm.v1.aes-sha2',
'ciphertext': 'abc',
}, type: EventTypes.Encrypted),
),
isNull,
);
expect(
notificationBody(
message({
'msgtype': MessageTypes.BadEncrypted,
'body': '** Unable to decrypt: …',
}),
),
isNull,
);
});
test('Markdown-Fett am Anfang ist KEIN Entschlüsselungsfehler', () {
// Früher galt jeder Text mit "**" am Anfang als nicht entschlüsselt.
expect(
notificationBody(
message({'msgtype': 'm.text', 'body': '**Wichtig** heute'}),
),
'**Wichtig** heute',
);
});
test(
'Inhalt: Absender-Anzeigename, Raumname, Bild-Flag, Native-Argumente',
() {
final content = MessageNotificationContent.fromEvent(
message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'}),
)!;
expect(content.senderName, 'Uta');
expect(content.roomName, 'Familie');
expect(content.isGroup, isTrue);
expect(content.isImage, isTrue);
expect(content.title, 'Uta · Familie');
final args = content.toNativeArgs(notifId: 42, imagePath: '/x/img');
expect(args['notifId'], 42);
expect(args['eventId'], r'$ev1');
expect(args['senderId'], '@uta:bench.invalid');
expect(args['imagePath'], '/x/img');
expect(
args['timestamp'],
DateTime(2026, 10, 7, 12, 30).millisecondsSinceEpoch,
);
},
);
test('unbekannter Absender → Localpart statt leerem Namen', () {
final content = MessageNotificationContent.fromEvent(
message({
'msgtype': 'm.text',
'body': 'Hi',
}, sender: '@jochen:bench.invalid'),
)!;
expect(content.senderName, 'jochen');
});
test('Vorschau aus → kein Inhalt, kein Bild', () {
final content = MessageNotificationContent.fromEvent(
message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'}),
showPreview: false,
)!;
expect(content.body, notificationPlaceholderBody);
expect(content.isImage, isFalse);
});
}