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 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 11:55:28 +02:00
co-authored by Claude Opus 5.5
parent b2413ef627
commit d5ff7982a0
4 changed files with 377 additions and 40 deletions
@@ -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<List<Event>> 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 = <Event>[];
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<String>('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;
+6 -40
View File
@@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/search/local_message_search.dart';
import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/mxc_image.dart';
// ─── State ──────────────────────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────────────────────
@@ -110,7 +111,11 @@ class _SearchModalState extends ConsumerState<SearchModal> {
List<Event> messageResults = []; List<Event> messageResults = [];
if (_filter != _Filter.people) { if (_filter != _Filter.people) {
try { try {
messageResults = await _localMessageSearch(client, q); messageResults = await localMessageSearch(
client,
q,
mediaOnly: _filter == _Filter.media,
);
} catch (e) { } catch (e) {
setState(() => _state = _state.copyWith( setState(() => _state = _state.copyWith(
loading: false, loading: false,
@@ -129,45 +134,6 @@ class _SearchModalState extends ConsumerState<SearchModal> {
} }
} }
// 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<List<Event>> _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 = <Event>[];
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<String>('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}) { void _openRoom(String roomId, {String? eventId}) {
widget.onClose(); widget.onClose();
ref.read(activeRoomIdProvider.notifier).state = roomId; ref.read(activeRoomIdProvider.notifier).state = roomId;
+109
View File
@@ -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<void> 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);
});
}
+200
View File
@@ -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<Client> 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<void> 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<List<Room>> loadRooms(Client client) async {
final rooms = await client.database.getRoomList(client);
client.rooms
..clear()
..addAll(rooms);
return rooms;
}
static List<MatrixEvent> _roomState(int r) {
final ts = DateTime.utc(2025, 12, 1).millisecondsSinceEpoch;
MatrixEvent state(
String type,
String stateKey,
Map<String, Object?> 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<String, Object?> 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),
);
}
}