From d5ff7982a08ff7dfee2824fefc853a4823a7e2bd Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Wed, 7 Oct 2026 11:55:28 +0200 Subject: [PATCH] refactor: lokale Nachrichtensuche als eigenes Modul (lib/features/search) + 5 Vertragstests Verhaltensgleich aus search_modal.dart herausgeloest (gleiche Deckel 1500/60/500, gleiche Schleife; Medien-Filter jetzt Parameter). Tests laufen ueber die echte SDK-DB-Schicht mit synthetischen Daten (test/support/synthetic_matrix_data.dart). Co-Authored-By: Claude Opus 5.5 --- lib/features/search/local_message_search.dart | 62 ++++++ lib/widgets/search_modal.dart | 46 +--- test/local_message_search_test.dart | 109 ++++++++++ test/support/synthetic_matrix_data.dart | 200 ++++++++++++++++++ 4 files changed, 377 insertions(+), 40 deletions(-) create mode 100644 lib/features/search/local_message_search.dart create mode 100644 test/local_message_search_test.dart create mode 100644 test/support/synthetic_matrix_data.dart diff --git a/lib/features/search/local_message_search.dart b/lib/features/search/local_message_search.dart new file mode 100644 index 0000000..288ce20 --- /dev/null +++ b/lib/features/search/local_message_search.dart @@ -0,0 +1,62 @@ +import 'package:matrix/matrix.dart'; + +/// Lokale Nachrichtensuche über die entschlüsselten Events in der Client-DB. +/// +/// Die server-seitige `/search`-API sieht bei E2EE nur Chiffretext und liefert +/// für verschlüsselte Räume nichts – deshalb wird (wie bei Element) die lokale +/// Datenbank durchsucht. Aus `search_modal.dart` herausgelöst, damit Tests und +/// `benchmark/` genau diesen Codepfad prüfen/messen können. +/// +/// Bei [mediaOnly] werden nur Medien-Nachrichten geliefert, deren +/// Dateiname/Beschriftung passt. Ergebnis: neueste zuerst, höchstens +/// [searchTotalCap] Treffer; pro Raum werden höchstens +/// [searchPerRoomScanCap] Events gescannt. +Future> localMessageSearch( + Client client, + String query, { + bool mediaOnly = false, +}) async { + final db = client.database; + final lower = query.toLowerCase(); + const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'}; + + final results = []; + for (final room in client.rooms.where((r) => !r.isSpace)) { + var start = 0; + var scanned = 0; + try { + while (scanned < searchPerRoomScanCap) { + final events = await db.getEventList( + room, + start: start, + limit: searchScanChunk, + ); + if (events.isEmpty) break; + for (final e in events) { + if (e.type != EventTypes.Message || e.redacted) continue; + final msgtype = e.content.tryGet('msgtype') ?? ''; + if (mediaOnly && !mediaTypes.contains(msgtype)) continue; + if (!e.body.toLowerCase().contains(lower)) continue; + results.add(e); + } + start += searchScanChunk; + scanned += events.length; + final roomExhausted = events.length < searchScanChunk; + if (roomExhausted || results.length >= searchTotalCap) break; + } + } catch (_) {} + if (results.length >= searchTotalCap) break; + } + + results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs)); + return results.take(searchTotalCap).toList(); +} + +/// Höchstens so viele Events werden pro Raum gescannt. +const searchPerRoomScanCap = 1500; + +/// Höchstens so viele Treffer liefert die Suche insgesamt. +const searchTotalCap = 60; + +/// So viele Events werden pro DB-Abfrage geladen. +const searchScanChunk = 500; diff --git a/lib/widgets/search_modal.dart b/lib/widgets/search_modal.dart index ef7c648..ee237b9 100644 --- a/lib/widgets/search_modal.dart +++ b/lib/widgets/search_modal.dart @@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/theme.dart'; +import 'package:pyramid/features/search/local_message_search.dart'; import 'package:pyramid/widgets/mxc_image.dart'; // ─── State ──────────────────────────────────────────────────────────────────── @@ -110,7 +111,11 @@ class _SearchModalState extends ConsumerState { List messageResults = []; if (_filter != _Filter.people) { try { - messageResults = await _localMessageSearch(client, q); + messageResults = await localMessageSearch( + client, + q, + mediaOnly: _filter == _Filter.media, + ); } catch (e) { setState(() => _state = _state.copyWith( loading: false, @@ -129,45 +134,6 @@ class _SearchModalState extends ConsumerState { } } - // Scans the local (decrypted) event database across all rooms for messages - // whose body matches the query. For the media filter, only media messages - // whose filename/caption matches are returned. - Future> _localMessageSearch(Client client, String q) async { - final db = client.database; - final lower = q.toLowerCase(); - final media = _filter == _Filter.media; - const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'}; - const perRoomScanCap = 1500; - const totalCap = 60; - const chunk = 500; - - final results = []; - for (final room in client.rooms.where((r) => !r.isSpace)) { - var start = 0; - var scanned = 0; - try { - while (scanned < perRoomScanCap) { - final events = await db.getEventList(room, start: start, limit: chunk); - if (events.isEmpty) break; - for (final e in events) { - if (e.type != EventTypes.Message || e.redacted) continue; - final msgtype = e.content.tryGet('msgtype') ?? ''; - if (media && !mediaTypes.contains(msgtype)) continue; - if (!e.body.toLowerCase().contains(lower)) continue; - results.add(e); - } - start += chunk; - scanned += events.length; - if (events.length < chunk || results.length >= totalCap) break; - } - } catch (_) {} - if (results.length >= totalCap) break; - } - - results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs)); - return results.take(totalCap).toList(); - } - void _openRoom(String roomId, {String? eventId}) { widget.onClose(); ref.read(activeRoomIdProvider.notifier).state = roomId; diff --git a/test/local_message_search_test.dart b/test/local_message_search_test.dart new file mode 100644 index 0000000..f206900 --- /dev/null +++ b/test/local_message_search_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/features/search/local_message_search.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'support/synthetic_matrix_data.dart'; + +/// Vertragstests für die lokale Nachrichtensuche (Suchdialog, Strg+K). +/// Laufen auf der echten SDK-Datenbankschicht mit normalem sqlite – die +/// Suche selbst ist von der Verschlüsselung unabhängig (die misst +/// `benchmark/db_benchmark_test.dart`). +void main() { + late Client client; + + Future setUpClient({ + required int rooms, + required int eventsPerRoom, + int? needleEvery, + }) async { + final factory = createDatabaseFactoryFfi( + ffiInit: sqfliteFfiInit, + noIsolate: true, + ); + final db = await factory.openDatabase(inMemoryDatabasePath); + client = await SyntheticMatrixData.openClient( + db, + name: 'PyramidSearchTest', + ); + await SyntheticMatrixData.populate( + client, + rooms: rooms, + eventsPerRoom: eventsPerRoom, + needleEvery: needleEvery, + ); + await SyntheticMatrixData.loadRooms(client); + } + + tearDown(() async { + await client.dispose(closeDatabase: true); + }); + + test( + 'findet genau die Nachrichten mit dem Suchwort, neueste zuerst', + () async { + // 3 Räume × 40 Nachrichten, jede 7. Text-Nachricht trägt das Suchwort. + // Jede 10. Nachricht ist ein Bild (ohne Suchwort im Dateinamen). + await setUpClient(rooms: 3, eventsPerRoom: 40, needleEvery: 7); + final expectedPerRoom = [ + for (var i = 0; i < 40; i += 7) i, + ].where((i) => i % 10 != 9).length; + + final results = await localMessageSearch(client, 'nadelwort'); + + expect(results, hasLength(3 * expectedPerRoom)); + expect(results.every((e) => e.body.contains('nadelwort')), isTrue); + for (var i = 1; i < results.length; i++) { + expect( + results[i - 1].originServerTs.isAfter(results[i].originServerTs) || + results[i - 1].originServerTs == results[i].originServerTs, + isTrue, + reason: 'Ergebnisse müssen absteigend nach Zeit sortiert sein', + ); + } + }, + ); + + test('Groß-/Kleinschreibung egal, auch bei Umlauten', () async { + await setUpClient(rooms: 1, eventsPerRoom: 30, needleEvery: 3); + for (final (lowerQuery, upperQuery) in [ + ('nadelwort', 'NADELWORT'), + ('übermorgen', 'ÜBERMORGEN'), + ]) { + final lower = await localMessageSearch(client, lowerQuery); + final upper = await localMessageSearch(client, upperQuery); + expect(lower, isNotEmpty, reason: '"$lowerQuery" kommt in den Daten vor'); + expect(upper.map((e) => e.eventId), lower.map((e) => e.eventId)); + } + }); + + test( + 'Medien-Filter liefert nur Medien-Nachrichten (Dateiname passt)', + () async { + await setUpClient(rooms: 2, eventsPerRoom: 30); + // "g" steckt in "IMG_…jpg" UND in vielen Textwörtern ("morgen", …). + final all = await localMessageSearch(client, 'g'); + final media = await localMessageSearch(client, 'g', mediaOnly: true); + expect( + all.any((e) => e.content['msgtype'] == 'm.text'), + isTrue, + reason: 'ohne Filter müssen auch Text-Treffer kommen', + ); + // Jede 10. Nachricht ist ein Bild: i = 9, 19, 29 → 3 pro Raum. + expect(media, hasLength(6)); + expect(media.every((e) => e.content['msgtype'] == 'm.image'), isTrue); + }, + ); + + test('Trefferzahl ist auf searchTotalCap gedeckelt', () async { + // Jede Nachricht trägt das Suchwort → weit mehr Treffer als der Deckel. + await setUpClient(rooms: 2, eventsPerRoom: 80, needleEvery: 1); + final results = await localMessageSearch(client, 'nadelwort'); + expect(results, hasLength(searchTotalCap)); + }); + + test('kein Treffer → leere Liste, kein Fehler', () async { + await setUpClient(rooms: 2, eventsPerRoom: 20); + expect(await localMessageSearch(client, 'gibtsnicht'), isEmpty); + }); +} diff --git a/test/support/synthetic_matrix_data.dart b/test/support/synthetic_matrix_data.dart new file mode 100644 index 0000000..3d96795 --- /dev/null +++ b/test/support/synthetic_matrix_data.dart @@ -0,0 +1,200 @@ +import 'dart:math'; + +import 'package:matrix/matrix.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Synthetische Matrix-Daten für Tests und `benchmark/` – komplett offline: +/// kein Homeserver, keine echten Konten, keine Secrets. +/// +/// Die Daten gehen über die ECHTE SDK-Datenbankschicht (`MatrixSdkDatabase`, +/// `storeRoomUpdate`/`storeEventUpdate` in Sync-artigen Transaktionen) – also +/// denselben Weg wie beim echten Sync. Dadurch messen/prüfen Tests und +/// Benchmarks die Speicherstruktur, die die App wirklich auf der Platte hat. +class SyntheticMatrixData { + SyntheticMatrixData._(); + + static const homeserverDomain = 'bench.invalid'; + static const _senders = 5; + + /// Wörter für die Nachrichtentexte (bewusst mit Umlauten – die Suche + /// arbeitet mit `toLowerCase`). + static const _words = [ + 'hallo', + 'morgen', + 'treffen', + 'kaffee', + 'bahnhof', + 'urlaub', + 'foto', + 'danke', + 'später', + 'gleich', + 'abend', + 'essen', + 'kino', + 'wetter', + 'regen', + 'sonne', + 'arbeit', + 'pause', + 'auto', + 'zug', + 'buch', + 'musik', + 'spiel', + 'garten', + 'hund', + 'katze', + 'einkaufen', + 'termin', + 'grüße', + 'übermorgen', + ]; + + static String roomId(int r) => '!room$r:$homeserverDomain'; + static String eventId(int r, int i) => '\$r${r}e$i'; + static String sender(int i) => '@user${i % _senders}:$homeserverDomain'; + + /// Öffnet die SDK-Datenbank auf [db] und baut einen Offline-Client darauf. + static Future openClient( + Database db, { + String name = 'PyramidBench', + }) async { + final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db); + return Client(name, database: sdkDb); + } + + /// Schreibt [rooms] Räume mit je [eventsPerRoom] Nachrichten in die DB. + /// + /// Wie beim echten Sync landen die Events in Transaktionen à [batchSize] + /// (≈ eine Sync-Antwort) und in zeitlicher Reihenfolge (älteste zuerst). + /// Jede 10. Nachricht ist ein Bild (`m.image`). Ist [needleEvery] gesetzt, + /// enthält jede n-te Nachricht zusätzlich das Wort [needle] – so kennen + /// Tests die exakte Trefferzahl der Suche. + static Future populate( + Client client, { + required int rooms, + required int eventsPerRoom, + int batchSize = 50, + String needle = 'nadelwort', + int? needleEvery, + int seed = 42, + }) async { + final db = client.database; + final rnd = Random(seed); + final baseTs = DateTime.utc(2026, 1, 1).millisecondsSinceEpoch; + + for (var r = 0; r < rooms; r++) { + final id = roomId(r); + await db.transaction(() async { + await db.storeRoomUpdate( + id, + JoinedRoomUpdate( + summary: RoomSummary.fromJson({ + 'm.joined_member_count': _senders, + 'm.invited_member_count': 0, + }), + ), + null, + client, + ); + for (final state in _roomState(r)) { + await db.storeEventUpdate(id, state, EventUpdateType.state, client); + } + }); + + for (var start = 0; start < eventsPerRoom; start += batchSize) { + final end = min(start + batchSize, eventsPerRoom); + await db.transaction(() async { + for (var i = start; i < end; i++) { + final hasNeedle = needleEvery != null && i % needleEvery == 0; + await db.storeEventUpdate( + id, + _message(r, i, baseTs, rnd, hasNeedle ? needle : null), + EventUpdateType.timeline, + client, + ); + } + }); + } + } + } + + /// Lädt die Raumliste aus der DB in `client.rooms` – das, was beim + /// App-Start `client.init()` macht, ohne Netzwerk/Login. + static Future> loadRooms(Client client) async { + final rooms = await client.database.getRoomList(client); + client.rooms + ..clear() + ..addAll(rooms); + return rooms; + } + + static List _roomState(int r) { + final ts = DateTime.utc(2025, 12, 1).millisecondsSinceEpoch; + MatrixEvent state( + String type, + String stateKey, + Map content, + int n, + ) => MatrixEvent( + type: type, + eventId: '\$state${r}_$n', + senderId: sender(0), + originServerTs: DateTime.fromMillisecondsSinceEpoch(ts + n), + stateKey: stateKey, + content: content, + roomId: roomId(r), + ); + return [ + state(EventTypes.RoomCreate, '', {'creator': sender(0)}, 0), + state(EventTypes.RoomName, '', {'name': 'Raum $r'}, 1), + for (var u = 0; u < _senders; u++) + state(EventTypes.RoomMember, sender(u), { + 'membership': 'join', + 'displayname': 'Nutzer $u', + }, 2 + u), + ]; + } + + static MatrixEvent _message( + int r, + int i, + int baseTs, + Random rnd, + String? needle, + ) { + final isImage = i % 10 == 9; + final Map content; + if (isImage) { + content = { + 'msgtype': 'm.image', + 'body': 'IMG_${r}_$i.jpg', + 'url': 'mxc://$homeserverDomain/img$r$i', + 'info': { + 'w': 1920, + 'h': 1080, + 'mimetype': 'image/jpeg', + 'size': 250000, + }, + }; + } else { + final words = List.generate( + 4 + rnd.nextInt(20), + (_) => _words[rnd.nextInt(_words.length)], + ); + if (needle != null) words.insert(rnd.nextInt(words.length), needle); + content = {'msgtype': 'm.text', 'body': words.join(' ')}; + } + return MatrixEvent( + type: EventTypes.Message, + eventId: eventId(r, i), + senderId: sender(i), + originServerTs: DateTime.fromMillisecondsSinceEpoch( + baseTs + r * 1000 + i * 60000, + ), + content: content, + roomId: roomId(r), + ); + } +}