feat(app): redesigned interface, polls, drafts, forwarding and mentions
A new look across the app and a set of everyday messaging features. Interface - Home screen with Chats, Contacts and Settings tabs, a large title, search across chats and message text (diacritics-insensitive), filters and pinned chats. - Conversation view with a translucent header and composer, grouped bubbles, author avatars and colours in groups, swipe to reply, an "N new messages" marker, a jump-to-latest button and in-chat search. - Contact and group profiles: verification, shared photos, groups in common, disappearing messages, mute, block; members, admin badges, adding people, rename and leave for groups. - Full-screen photo viewer that pages through the whole conversation. - A stable avatar colour per person and a clearly visible read state. Messaging - Polls in chats and groups, single or multiple choice, with votes that can be changed or withdrawn; older clients see them as text. - Drafts survive leaving a chat and show in the chat list. - Forwarding of messages, photos, files and voice messages. - Notes to self: a local-only, encrypted chat that never reaches the relay. - @mentions in groups that notify even when the group is muted. - Typing indicators sent as ephemeral messages, opt-in and mutual like read receipts. - Delivery and read times per message and per group member. - Profile photos, cropped, resized and stripped of metadata, delivered encrypted to contacts. - Voice message playback at 1.5x and 2x. - Call buttons in 1:1 chats and call log entries. Fixes - Muted groups no longer notify: muting was checked against the author's chat instead of the group. - A blocked contact could unblock itself by changing its delivery address. - Read receipts no longer regress to delivered when a late delivery receipt arrives. - A message marked as failed corrects itself once it is delivered. - Double ticks in groups now mean delivered to every member.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sepot/core/services/avatar_image.dart';
|
||||
import 'package:sepot/models/avatar_ref.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
|
||||
/// Odkaz na fotku přichází od protistrany v profilu — nesmí projít nic,
|
||||
/// co by šlo zneužít (cizí cesta k blobu, klíč špatné délky).
|
||||
void main() {
|
||||
final ref = AvatarRef(
|
||||
blobId: 'a' * 32,
|
||||
key: Uint8List(32),
|
||||
nonce: Uint8List(12),
|
||||
digest: Uint8List(32),
|
||||
);
|
||||
|
||||
test('drát tam a zpět', () {
|
||||
final back = AvatarRef.fromWire(ref.toWire())!;
|
||||
expect(back.blobId, ref.blobId);
|
||||
expect(back.key, ref.key);
|
||||
});
|
||||
|
||||
test('podvržený odkaz = null', () {
|
||||
expect(AvatarRef.fromWire(null), isNull);
|
||||
expect(AvatarRef.fromWire({...ref.toWire(), 'b': '../../etc/passwd'}),
|
||||
isNull);
|
||||
expect(AvatarRef.fromWire({...ref.toWire(), 'k': 'AAAA'}), isNull);
|
||||
expect(AvatarRef.fromWire({...ref.toWire(), 'd': 'nebase64!'}), isNull);
|
||||
});
|
||||
|
||||
test('kontakt si fotku pamatuje přes uložení', () {
|
||||
final contact = Contact(
|
||||
displayName: 'Alice',
|
||||
publicIdentity: PublicIdentity(
|
||||
displayName: 'Alice',
|
||||
identityKey: Uint8List.fromList(List.filled(33, 5)),
|
||||
createdAt: DateTime(2026, 9, 1),
|
||||
),
|
||||
addedAt: DateTime(2026, 9, 1),
|
||||
avatar: ref,
|
||||
);
|
||||
final back = Contact.fromJson(contact.toJson());
|
||||
expect(back.avatar?.blobId, ref.blobId);
|
||||
});
|
||||
|
||||
test('fotka se ořízne na čtverec, zmenší a ztratí EXIF', () async {
|
||||
final photo = img.Image(width: 800, height: 400)
|
||||
..exif.imageIfd['Make'] = 'TestPhone';
|
||||
final jpeg = Uint8List.fromList(img.encodeJpg(photo));
|
||||
expect(img.decodeJpgExif(jpeg)?.imageIfd['Make'], isNotNull);
|
||||
|
||||
final out = await prepareAvatarJpeg(jpeg);
|
||||
final decoded = img.decodeJpg(out!)!;
|
||||
expect(decoded.width, avatarSide);
|
||||
expect(decoded.height, avatarSide);
|
||||
expect(img.decodeJpgExif(out)?.imageIfd['Make'], isNull);
|
||||
});
|
||||
|
||||
test('nečitelný obrázek = null', () async {
|
||||
expect(await prepareAvatarJpeg(Uint8List.fromList([1, 2, 3])), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/core/storage/crypto_db.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
|
||||
/// Správci skupin end-to-end přes MessagingService: skutečné XEdDSA
|
||||
/// podpisy, skutečné úložiště, relay nahrazený přímým předáním obsahu mezi
|
||||
/// „telefony" (debugSendTap). Pokrývá tvorbu a příjem `gop`, legacy stín
|
||||
/// pro v1 klienty, kompatibilní režim a resync po mezeře / rozchodu.
|
||||
///
|
||||
/// Potřebuje nativní libsignal i sqlcipher (jako groups_security_test).
|
||||
void main() {
|
||||
final rng = Random.secure();
|
||||
final tmpDirs = <Directory>[];
|
||||
final dbs = <CryptoDb>[];
|
||||
|
||||
/// Odeslaný obsah čekající na doručení (kdo, komu, co).
|
||||
final outbox = <({String from, String to, Map<String, dynamic> content})>[];
|
||||
|
||||
setUpAll(() async => SepotCrypto.init());
|
||||
|
||||
tearDown(() {
|
||||
outbox.clear();
|
||||
for (final db in dbs) {
|
||||
db.close();
|
||||
}
|
||||
for (final d in tmpDirs) {
|
||||
d.deleteSync(recursive: true);
|
||||
}
|
||||
dbs.clear();
|
||||
tmpDirs.clear();
|
||||
});
|
||||
|
||||
|
||||
Future<_Phone> phone(String name) async {
|
||||
final tmp = Directory.systemTemp.createTempSync('sepot_admini');
|
||||
tmpDirs.add(tmp);
|
||||
final dek = Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
|
||||
final db = CryptoDb.open(path: '${tmp.path}/app.db', dek: dek);
|
||||
dbs.add(db);
|
||||
final storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
|
||||
..debugAttachDb(db);
|
||||
final service = MessagingService(storage: storage);
|
||||
final identity = await LocalIdentity.create(name);
|
||||
service.debugSetIdentity(identity);
|
||||
final p = _Phone(name, identity, storage, service);
|
||||
service.debugSendTap = (to, content) => outbox.add((
|
||||
from: p.fp,
|
||||
to: to,
|
||||
// Přes JSON jako na drátu — žádné sdílené objekty mezi telefony.
|
||||
content: jsonDecode(jsonEncode(content)) as Map<String, dynamic>,
|
||||
));
|
||||
return p;
|
||||
}
|
||||
|
||||
/// Každý zná každého jako kontakt s routingem (jako po výměně QR).
|
||||
Future<void> introduce(List<_Phone> phones) async {
|
||||
for (final p in phones) {
|
||||
for (final q in phones) {
|
||||
if (p == q) continue;
|
||||
await p.storage.saveContact(Contact.fromPublicIdentity(
|
||||
q.identity.publicIdentity.withRouting(
|
||||
accountId: 'a' * 32,
|
||||
deliveryAddress: Uint8List(32),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Doručí frontu (FIFO) příjemcům ze seznamu; ostatní zahodí.
|
||||
Future<void> deliver(List<_Phone> phones,
|
||||
{bool Function(Map<String, dynamic>)? where}) async {
|
||||
final byFp = {for (final p in phones) p.fp: p};
|
||||
final batch = [...outbox];
|
||||
outbox.clear();
|
||||
for (final m in batch) {
|
||||
if (where != null && !where(m.content)) {
|
||||
outbox.add(m);
|
||||
continue;
|
||||
}
|
||||
await byFp[m.to]?.service.debugHandleGroupControl(m.from, m.content);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> sentTo(_Phone p, String type) => [
|
||||
for (final m in outbox)
|
||||
if (m.to == p.fp && m.content['t'] == type) m.content,
|
||||
];
|
||||
|
||||
test('založení: v2 člen zná správce hned, v1 dostane obyčejný ginv',
|
||||
() async {
|
||||
final a = await phone('Alice');
|
||||
final b = await phone('Bob');
|
||||
await introduce([a, b]);
|
||||
final contactB = (await a.storage.loadContacts()).single;
|
||||
final g = await a.service.createGroup('Chata', [contactB]);
|
||||
|
||||
final ginv = sentTo(b, 'ginv').single;
|
||||
// Tvar, který zpracuje v1 klient: v:1, gid, gname, roster se mnou i s ním.
|
||||
expect(ginv['v'], 1);
|
||||
expect(ginv['gname'], 'Chata');
|
||||
expect((ginv['roster'] as List).map((e) => e['f']),
|
||||
containsAll([a.fp, b.fp]));
|
||||
expect(ginv['gop'], isA<Map>(), reason: 'vnořený podepsaný stav pro v2');
|
||||
expect(ginv['gv'], 2);
|
||||
|
||||
await deliver([a, b]);
|
||||
final gb = (await b.storage.loadGroup(g.id))!;
|
||||
expect(gb.admins, [a.fp]);
|
||||
expect(gb.epoch, 0);
|
||||
expect(gb.provisional, isFalse, reason: 'stav je podepsaný');
|
||||
});
|
||||
|
||||
group('skupina A (správce), B, C', () {
|
||||
late _Phone a, b, c;
|
||||
late String gid;
|
||||
|
||||
setUp(() async {
|
||||
outbox.clear();
|
||||
a = await phone('Alice');
|
||||
b = await phone('Bob');
|
||||
c = await phone('Cyril');
|
||||
await introduce([a, b, c]);
|
||||
final contacts = await a.storage.loadContacts();
|
||||
gid = (await a.service.createGroup('Chata', contacts)).id;
|
||||
await deliver([a, b, c]);
|
||||
});
|
||||
|
||||
Future<Group> load(_Phone p) async => (await p.storage.loadGroup(gid))!;
|
||||
|
||||
test('odebrání: B aplikuje, C se dozví, že už není členem', () async {
|
||||
await a.service.removeGroupMember(await load(a), c.fp);
|
||||
expect(sentTo(c, 'ginv'), hasLength(1),
|
||||
reason: 'odebraný dostane výsledný stav taky');
|
||||
await deliver([a, b, c]);
|
||||
|
||||
expect((await load(b)).memberIds, isNot(contains(c.fp)));
|
||||
expect((await load(b)).epoch, 1);
|
||||
expect((await load(c)).left, isTrue);
|
||||
});
|
||||
|
||||
test('povýšení a přejmenování novým správcem projde u ostatních',
|
||||
() async {
|
||||
await a.service.setGroupAdmin(await load(a), b.fp, true);
|
||||
await deliver([a, b, c]);
|
||||
expect((await load(c)).admins, containsAll([a.fp, b.fp]));
|
||||
|
||||
await b.service.renameGroup(await load(b), 'Chata 2026');
|
||||
await deliver([a, b, c]);
|
||||
expect((await load(a)).name, 'Chata 2026');
|
||||
expect((await load(c)).name, 'Chata 2026');
|
||||
expect((await load(c)).epoch, 2);
|
||||
});
|
||||
|
||||
test('NE-SPRÁVCE: B podepsaný gop se nepostaví a podvržený se odmítne',
|
||||
() async {
|
||||
await expectLater(b.service.renameGroup(await load(b), 'Únos'),
|
||||
throwsA(isA<NotGroupAdminError>()));
|
||||
|
||||
// B si vyrobí operaci s podpisem sebe sama (upravený klient).
|
||||
await a.service.setGroupAdmin(await load(a), b.fp, true);
|
||||
final gop = Map<String, dynamic>.from(
|
||||
sentTo(c, 'ginv').single['gop'] as Map);
|
||||
outbox.clear();
|
||||
// Stejné pole, ale autor B, který v C správce není → notAdmin.
|
||||
await c.service.debugHandleGroupControl(b.fp, {
|
||||
'v': 1,
|
||||
't': 'gop',
|
||||
...gop,
|
||||
'by': b.fp,
|
||||
});
|
||||
final gc = await load(c);
|
||||
expect(gc.admins, [a.fp]);
|
||||
expect(gc.rejectedOpAt, isNotNull);
|
||||
});
|
||||
|
||||
test('SPOOF: operaci správce A přeposlanou od B C odmítne', () async {
|
||||
await a.service.renameGroup(await load(a), 'Nové jméno');
|
||||
final gmeta = sentTo(c, 'gmeta').single;
|
||||
outbox.clear();
|
||||
await c.service.debugHandleGroupControl(b.fp, gmeta);
|
||||
final gc = await load(c);
|
||||
expect(gc.epoch, 0);
|
||||
expect(gc.rejectedOpAt, isNotNull);
|
||||
});
|
||||
|
||||
test('ŠPATNÝ PODPIS: pozměněné jméno se stejným podpisem neprojde',
|
||||
() async {
|
||||
await a.service.renameGroup(await load(a), 'Poctivé');
|
||||
final gmeta = sentTo(c, 'gmeta').single;
|
||||
outbox.clear();
|
||||
(gmeta['gop'] as Map)['gname'] = 'Podvržené';
|
||||
await c.service.debugHandleGroupControl(a.fp, gmeta);
|
||||
final gc = await load(c);
|
||||
expect(gc.epoch, 0);
|
||||
expect(gc.rejectedOpAt, isNotNull);
|
||||
expect(gc.name, isNot('Podvržené'));
|
||||
});
|
||||
|
||||
test('MEZERA: epocha 2 před 1 se podrží, vyžádá stav a po 1 aplikuje',
|
||||
() async {
|
||||
await a.service.renameGroup(await load(a), 'Jedna');
|
||||
final e1 = sentTo(c, 'gmeta').single;
|
||||
await a.service.renameGroup(await load(a), 'Dvě');
|
||||
final e2 = sentTo(c, 'gmeta').last;
|
||||
outbox.clear();
|
||||
|
||||
await c.service.debugHandleGroupControl(a.fp, e2);
|
||||
var gc = await load(c);
|
||||
expect(gc.epoch, 0);
|
||||
expect(gc.needsResync, isTrue);
|
||||
expect(gc.pendingOps, hasLength(1));
|
||||
expect(sentTo(a, 'gsync'), hasLength(1),
|
||||
reason: 'žádost o stav jde správci');
|
||||
|
||||
await c.service.debugHandleGroupControl(a.fp, e1);
|
||||
gc = await load(c);
|
||||
expect(gc.epoch, 2);
|
||||
expect(gc.name, 'Dvě');
|
||||
expect(gc.needsResync, isFalse);
|
||||
expect(gc.pendingOps, isEmpty);
|
||||
});
|
||||
|
||||
test('ZASTARALÁ: opakované doručení téže operace nic nezmění', () async {
|
||||
await a.service.renameGroup(await load(a), 'Jednou');
|
||||
final e1 = sentTo(c, 'gmeta').single;
|
||||
await deliver([a, b, c]);
|
||||
final before = jsonEncode((await load(c)).toJson());
|
||||
await c.service.debugHandleGroupControl(a.fp, e1);
|
||||
expect(jsonEncode((await load(c)).toJson()), before);
|
||||
});
|
||||
|
||||
test('FORK přes prev: resync stavem od správce a konec bez smyčky',
|
||||
() async {
|
||||
// C má kvůli rozdílné historii jiný stav na stejné epoše.
|
||||
final gc0 = await load(c)..name = 'Rozešlá historie';
|
||||
await c.storage.saveGroup(gc0);
|
||||
|
||||
await a.service.renameGroup(await load(a), 'Pravda');
|
||||
await deliver([c]);
|
||||
var gc = await load(c);
|
||||
expect(gc.needsResync, isTrue);
|
||||
expect(gc.name, 'Rozešlá historie');
|
||||
|
||||
// gsync → A odpoví podepsaným stavem → C ho přijme (epocha 1 > 0).
|
||||
await deliver([a, c]);
|
||||
await deliver([a, c]);
|
||||
gc = await load(c);
|
||||
expect(gc.name, 'Pravda');
|
||||
expect(gc.epoch, 1);
|
||||
expect(gc.needsResync, isFalse);
|
||||
expect(outbox, isEmpty, reason: 'odpověď už nic dalšího nevyvolá');
|
||||
});
|
||||
|
||||
test('poslední správce se nemůže degradovat; při odchodu předá roli',
|
||||
() async {
|
||||
await expectLater(a.service.setGroupAdmin(await load(a), a.fp, false),
|
||||
throwsA(isA<LastGroupAdminError>()));
|
||||
|
||||
await a.service.leaveGroup(await load(a));
|
||||
await deliver([a, b, c]);
|
||||
final gb = await load(b);
|
||||
final naslednik = ([b.fp, c.fp]..sort()).first;
|
||||
expect(gb.memberIds, isNot(contains(a.fp)));
|
||||
expect(gb.admins, [naslednik]);
|
||||
expect((await load(c)).admins, [naslednik]);
|
||||
expect((await load(a)).left, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('kompatibilní režim v1 ↔ v2', () {
|
||||
late _Phone a, b, c, d;
|
||||
late String gid;
|
||||
|
||||
setUp(() async {
|
||||
outbox.clear();
|
||||
a = await phone('Alice');
|
||||
b = await phone('Bob');
|
||||
c = await phone('Cyril');
|
||||
d = await phone('Dana');
|
||||
await introduce([a, b, c, d]);
|
||||
final contacts = (await a.storage.loadContacts())
|
||||
.where((x) => x.id != d.fp)
|
||||
.toList();
|
||||
gid = (await a.service.createGroup('Chata', contacts)).id;
|
||||
await deliver([a, b, c]);
|
||||
});
|
||||
|
||||
/// Legacy `ginv` od B (bez `gop`), který přidává Danu — tak přidává v1.
|
||||
Map<String, dynamic> legacyAddFromB() {
|
||||
Map<String, dynamic> e(_Phone p) => {
|
||||
'f': p.fp,
|
||||
'n': p.name,
|
||||
'ik': base64Encode(p.identity.publicIdentity.identityKey),
|
||||
'aid': 'a' * 32,
|
||||
'da': base64Encode(Uint8List(32)),
|
||||
};
|
||||
return {
|
||||
'v': 1,
|
||||
't': 'ginv',
|
||||
'gid': gid,
|
||||
'gname': 'Chata',
|
||||
'roster': [e(a), e(b), e(c), e(d)],
|
||||
};
|
||||
}
|
||||
|
||||
test('v1 člen ve skupině → ginv od ne-správce projde (jen přidání)',
|
||||
() async {
|
||||
// C inzeruje v2 (poslal skupinovou zprávu s gv), B je v1 — nic.
|
||||
final gc = (await c.storage.loadGroup(gid))!..v2Members.add(a.fp);
|
||||
await c.storage.saveGroup(gc);
|
||||
|
||||
await c.service.debugHandleGroupControl(b.fp, legacyAddFromB());
|
||||
final after = (await c.storage.loadGroup(gid))!;
|
||||
expect(after.memberIds, contains(d.fp));
|
||||
expect(after.admins, [a.fp], reason: 'role se legacy cestou nemění');
|
||||
});
|
||||
|
||||
test('všichni inzerují v2 → ginv od ne-správce neprojde', () async {
|
||||
final gc = (await c.storage.loadGroup(gid))!
|
||||
..v2Members.addAll([a.fp, b.fp]);
|
||||
await c.storage.saveGroup(gc);
|
||||
|
||||
await c.service.debugHandleGroupControl(b.fp, legacyAddFromB());
|
||||
final after = (await c.storage.loadGroup(gid))!;
|
||||
expect(after.memberIds, isNot(contains(d.fp)));
|
||||
expect(after.rejectedOpAt, isNotNull);
|
||||
});
|
||||
|
||||
test('ve vynucené skupině jde operace samostatně jako t:gop', () async {
|
||||
final ga = (await a.storage.loadGroup(gid))!
|
||||
..v2Members.addAll([b.fp, c.fp]);
|
||||
await a.storage.saveGroup(ga);
|
||||
await a.service.renameGroup(ga, 'Jen v2');
|
||||
expect(sentTo(c, 'gop'), hasLength(1));
|
||||
expect(sentTo(c, 'gmeta'), isEmpty);
|
||||
await deliver([a, b, c]);
|
||||
expect((await c.storage.loadGroup(gid))!.name, 'Jen v2');
|
||||
});
|
||||
|
||||
test('ne-správce v kompatibilním režimu přidá legacy cestou, ve '
|
||||
'vynucené skupině nesmí', () async {
|
||||
final contactD =
|
||||
(await b.storage.loadContacts()).firstWhere((x) => x.id == d.fp);
|
||||
var gb = (await b.storage.loadGroup(gid))!;
|
||||
await b.service.addGroupMembers(gb, [contactD]);
|
||||
final inv = sentTo(c, 'ginv').single;
|
||||
expect(inv.containsKey('gop'), isFalse);
|
||||
|
||||
outbox.clear();
|
||||
gb = (await b.storage.loadGroup(gid))!
|
||||
..memberIds.remove(d.fp)
|
||||
..v2Members.addAll([a.fp, c.fp, d.fp]);
|
||||
await b.storage.saveGroup(gb);
|
||||
await expectLater(b.service.addGroupMembers(gb, [contactD]),
|
||||
throwsA(isA<NotGroupAdminError>()));
|
||||
});
|
||||
|
||||
test('capability se učí z gv ve skupinové zprávě a nejde odebrat',
|
||||
() async {
|
||||
final ga = (await a.storage.loadGroup(gid))!;
|
||||
expect(ga.v2Members, isEmpty);
|
||||
// Legacy gmeta od B s gv:2 (v2 klient) — naučí; bez gv nic neodebere.
|
||||
await a.service.debugHandleGroupControl(
|
||||
b.fp, {'v': 1, 't': 'gmeta', 'gid': gid, 'gname': 'X', 'gv': 2});
|
||||
await a.service.debugHandleGroupControl(
|
||||
b.fp, {'v': 1, 't': 'gmeta', 'gid': gid, 'gname': 'Y'});
|
||||
expect((await a.storage.loadGroup(gid))!.v2Members, {b.fp});
|
||||
});
|
||||
});
|
||||
|
||||
test('migrace uložené v1 skupiny proběhne při načtení bez sítě', () async {
|
||||
final a = await phone('Alice');
|
||||
dbs.last.groupPut(
|
||||
'stara',
|
||||
jsonEncode({
|
||||
'id': 'stara',
|
||||
'name': 'Z verze 1.1',
|
||||
'memberIds': [a.fp, 'fpB'],
|
||||
'createdBy': a.fp,
|
||||
'createdAt': '2026-07-10T10:00:00.000',
|
||||
'left': false,
|
||||
}),
|
||||
);
|
||||
final g = (await a.storage.loadGroup('stara'))!;
|
||||
expect(g.admins, [a.fp]);
|
||||
expect(g.epoch, 0);
|
||||
expect(outbox, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
class _Phone {
|
||||
_Phone(this.name, this.identity, this.storage, this.service);
|
||||
|
||||
final String name;
|
||||
final LocalIdentity identity;
|
||||
final SecureStorageService storage;
|
||||
final MessagingService service;
|
||||
|
||||
String get fp => identity.fingerprint.display;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Zmínky přicházejí od cizích klientů — vadný tvar nesmí rozbít zprávu
|
||||
/// a počet je shora omezený.
|
||||
void main() {
|
||||
test('platné zmínky projdou', () {
|
||||
expect(mentionsFromWire(['aa11', 'bb22']), ['aa11', 'bb22']);
|
||||
});
|
||||
|
||||
test('ne-seznam a ne-řetězce se zahodí', () {
|
||||
expect(mentionsFromWire('aa11'), isEmpty);
|
||||
expect(mentionsFromWire(null), isEmpty);
|
||||
expect(mentionsFromWire(['aa11', 42, null, {'x': 1}]), ['aa11']);
|
||||
});
|
||||
|
||||
test('počet je omezený stropem členů skupiny', () {
|
||||
final many = List.generate(Group.maxMembers + 50, (i) => 'fp$i');
|
||||
expect(mentionsFromWire(many), hasLength(Group.maxMembers));
|
||||
});
|
||||
|
||||
test('zmínky přežijí uložení a načtení zprávy', () {
|
||||
final m = Message.text(
|
||||
senderId: 'me',
|
||||
recipientId: 'gid',
|
||||
content: 'ahoj @Alice',
|
||||
mentions: const ['alice-fp'],
|
||||
);
|
||||
expect(Message.fromJson(m.toJson()).mentions, ['alice-fp']);
|
||||
});
|
||||
|
||||
test('zpráva bez zmínek je neukládá', () {
|
||||
final m = Message.text(senderId: 'me', recipientId: 'x', content: 'a');
|
||||
expect(m.toJson().containsKey('mentions'), isFalse);
|
||||
expect(Message.fromJson(m.toJson()).mentions, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Doručenky a potvrzení přečtení nesou čas od příjemce; stav mé zprávy
|
||||
/// z nich roste, ale nikdy neklesá.
|
||||
void main() {
|
||||
final odeslano = DateTime(2026, 9, 28, 14, 0);
|
||||
|
||||
Message moje({MessageStatus status = MessageStatus.sent}) => Message(
|
||||
id: 'm1',
|
||||
senderId: 'ja',
|
||||
recipientId: 'bob',
|
||||
type: MessageType.text,
|
||||
content: 'ahoj',
|
||||
timestamp: odeslano,
|
||||
status: status,
|
||||
);
|
||||
|
||||
Message receipt(Message m, String from, int minut,
|
||||
{bool read = false, int recipients = 1}) =>
|
||||
MessagingService.applyReceipt(
|
||||
m,
|
||||
from: from,
|
||||
at: odeslano.add(Duration(minutes: minut)),
|
||||
read: read,
|
||||
recipients: recipients,
|
||||
);
|
||||
|
||||
test('doručenka zapíše čas a povýší na doručeno', () {
|
||||
final m = receipt(moje(), 'bob', 1);
|
||||
expect(m.status, MessageStatus.delivered);
|
||||
expect(m.deliveredTo['bob'], odeslano.add(const Duration(minutes: 1)));
|
||||
expect(m.firstReadAt, isNull);
|
||||
});
|
||||
|
||||
test('přečtení zároveň znamená doručení', () {
|
||||
final m = receipt(moje(), 'bob', 5, read: true);
|
||||
expect(m.status, MessageStatus.read);
|
||||
expect(m.firstDeliveredAt, m.firstReadAt);
|
||||
});
|
||||
|
||||
test('pozdní doručenka po přečtení stav nesníží', () {
|
||||
// Doručenka poslaná znovu po výpadku spojení dorazí až po „přečteno".
|
||||
var m = receipt(moje(), 'bob', 5, read: true);
|
||||
m = receipt(m, 'bob', 9);
|
||||
expect(m.status, MessageStatus.read);
|
||||
});
|
||||
|
||||
test('opakované potvrzení nepřepíše první čas', () {
|
||||
var m = receipt(moje(), 'bob', 2);
|
||||
m = receipt(m, 'bob', 30);
|
||||
expect(m.deliveredTo['bob'], odeslano.add(const Duration(minutes: 2)));
|
||||
});
|
||||
|
||||
test('doručenka zachrání zprávu označenou jako neodeslaná', () {
|
||||
// Timeout před ACK relaye → failed, přitom zpráva dorazila.
|
||||
final m = receipt(moje(status: MessageStatus.failed), 'bob', 1);
|
||||
expect(m.status, MessageStatus.delivered);
|
||||
});
|
||||
|
||||
test('opožděné hodiny příjemce nepřečtou zprávu před odesláním', () {
|
||||
final m = receipt(moje(), 'bob', -10, read: true);
|
||||
expect(m.firstReadAt, odeslano);
|
||||
});
|
||||
|
||||
test('skupina: ✓✓ a přečteno až za všechny ostatní členy', () {
|
||||
var m = receipt(moje(), 'bob', 1, recipients: 2);
|
||||
expect(m.status, MessageStatus.sent);
|
||||
m = receipt(m, 'cyril', 2, recipients: 2);
|
||||
expect(m.status, MessageStatus.delivered);
|
||||
m = receipt(m, 'bob', 3, read: true, recipients: 2);
|
||||
expect(m.status, MessageStatus.delivered);
|
||||
m = receipt(m, 'cyril', 4, read: true, recipients: 2);
|
||||
expect(m.status, MessageStatus.read);
|
||||
expect(m.firstReadAt, odeslano.add(const Duration(minutes: 3)));
|
||||
});
|
||||
|
||||
test('časy přežijí uložení a načtení', () {
|
||||
final m = receipt(receipt(moje(), 'bob', 1), 'bob', 4, read: true);
|
||||
final zpet = Message.fromJson(m.toJson());
|
||||
expect(zpet.deliveredTo, m.deliveredTo);
|
||||
expect(zpet.readBy, m.readBy);
|
||||
});
|
||||
|
||||
test('starý záznam bez časů i vadné položky se načtou', () {
|
||||
final json = moje().toJson()
|
||||
..remove('deliveredTo')
|
||||
..['readBy'] = {'bob': 'včera', 'cyril': 1790000000000};
|
||||
final m = Message.fromJson(json);
|
||||
expect(m.deliveredTo, isEmpty);
|
||||
expect(m.readBy.keys, ['cyril']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/models/poll.dart';
|
||||
|
||||
/// Anketa přichází od protistrany — vadná data nesmí nic rozbít a hlas
|
||||
/// nesmí jít mimo nabídnuté možnosti.
|
||||
void main() {
|
||||
const poll = Poll(
|
||||
question: 'Kdy vyrazíme?',
|
||||
options: ['Pátek', 'Sobota', 'Neděle'],
|
||||
);
|
||||
|
||||
test('drát tam a zpět zachová otázku i možnosti', () {
|
||||
final back = Poll.fromWire(poll.toWire())!;
|
||||
expect(back.question, poll.question);
|
||||
expect(back.options, poll.options);
|
||||
expect(back.multi, isFalse);
|
||||
});
|
||||
|
||||
test('vadná anketa z drátu = null (ukáže se záložní text)', () {
|
||||
expect(Poll.fromWire(null), isNull);
|
||||
expect(Poll.fromWire({'q': 'Jen jedna?', 'o': ['a']}), isNull);
|
||||
expect(Poll.fromWire({'q': '', 'o': ['a', 'b']}), isNull);
|
||||
expect(Poll.fromWire({'q': 'x', 'o': ['a', 42]}), isNull);
|
||||
expect(
|
||||
Poll.fromWire({'q': 'x', 'o': List.filled(Poll.maxOptions + 1, 'a')}),
|
||||
isNull);
|
||||
expect(
|
||||
Poll.fromWire({'q': 'x' * (Poll.maxQuestionLength + 1), 'o': ['a', 'b']}),
|
||||
isNull);
|
||||
});
|
||||
|
||||
test('hlas mimo nabídku se zahodí', () {
|
||||
final p = poll.withVote('bob', [7, -1]);
|
||||
expect(p.votes, isEmpty);
|
||||
});
|
||||
|
||||
test('jedna volba: platí jen první index', () {
|
||||
final p = poll.withVote('bob', [2, 0]);
|
||||
expect(p.votes['bob'], [0]);
|
||||
});
|
||||
|
||||
test('více voleb: duplicity se sloučí', () {
|
||||
const multi = Poll(question: 'Co vezmeme?', options: ['a', 'b', 'c'], multi: true);
|
||||
final p = multi.withVote('bob', [2, 0, 2]);
|
||||
expect(p.votes['bob'], [0, 2]);
|
||||
expect(p.countFor(2), 1);
|
||||
});
|
||||
|
||||
test('nový hlas přepíše starý, prázdný ho zruší', () {
|
||||
var p = poll.withVote('bob', [0]).withVote('cyril', [0]);
|
||||
expect(p.countFor(0), 2);
|
||||
p = p.withVote('bob', [1]);
|
||||
expect(p.countFor(0), 1);
|
||||
expect(p.countFor(1), 1);
|
||||
p = p.withVote('bob', const []);
|
||||
expect(p.voterCount, 1);
|
||||
});
|
||||
|
||||
test('hlasy přežijí uložení zprávy', () {
|
||||
final msg = Message.text(
|
||||
senderId: 'ja',
|
||||
recipientId: 'chata',
|
||||
content: 'Anketa: Kdy vyrazíme?',
|
||||
poll: poll.withVote('bob', [1]),
|
||||
);
|
||||
final back = Message.fromJson(msg.toJson());
|
||||
expect(back.poll?.question, poll.question);
|
||||
expect(back.poll?.votes['bob'], [1]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/services/typing_tracker.dart';
|
||||
|
||||
/// „píše…“ nemá zprávu „dopsal jsem“ — indikátor musí sám vyhasnout,
|
||||
/// jinak by po ztraceném spojení svítil navždy.
|
||||
void main() {
|
||||
late DateTime now;
|
||||
late TypingTracker tracker;
|
||||
|
||||
setUp(() {
|
||||
now = DateTime(2026, 9, 29, 12);
|
||||
tracker = TypingTracker(clock: () => now);
|
||||
});
|
||||
|
||||
tearDown(() => tracker.dispose());
|
||||
|
||||
test('pisatel se ukáže v dané konverzaci', () {
|
||||
tracker.mark('chat-a', 'alice');
|
||||
expect(tracker.current, {
|
||||
'chat-a': {'alice'},
|
||||
});
|
||||
});
|
||||
|
||||
test('bez dalšího „píšu“ indikátor vyprší', () {
|
||||
tracker.mark('chat-a', 'alice');
|
||||
now = now.add(TypingTracker.showFor + const Duration(milliseconds: 1));
|
||||
expect(tracker.current, isEmpty);
|
||||
tracker.prune();
|
||||
expect(tracker.current, isEmpty);
|
||||
});
|
||||
|
||||
test('opakované „píšu“ prodlužuje', () {
|
||||
tracker.mark('chat-a', 'alice');
|
||||
now = now.add(const Duration(seconds: 4));
|
||||
tracker.mark('chat-a', 'alice');
|
||||
now = now.add(const Duration(seconds: 4));
|
||||
expect(tracker.current['chat-a'], {'alice'});
|
||||
});
|
||||
|
||||
test('příchozí zpráva indikátor hned zhasne', () {
|
||||
tracker
|
||||
..mark('skupina', 'alice')
|
||||
..mark('skupina', 'tomas')
|
||||
..clear('skupina', 'alice');
|
||||
expect(tracker.current, {
|
||||
'skupina': {'tomas'},
|
||||
});
|
||||
});
|
||||
|
||||
test('změny se hlásí posluchačům', () async {
|
||||
final events = <Map<String, Set<String>>>[];
|
||||
final sub = tracker.changes.listen(events.add);
|
||||
tracker
|
||||
..mark('chat-a', 'alice')
|
||||
..clear('chat-a', 'alice');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await sub.cancel();
|
||||
expect(events, [
|
||||
{
|
||||
'chat-a': {'alice'},
|
||||
},
|
||||
<String, Set<String>>{},
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/features/profile/group_profile_screen.dart';
|
||||
import 'package:sepot/features/profile/profile_widgets.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
|
||||
/// Profil skupiny podle role: ne-správce nesmí vidět správcovské akce
|
||||
/// (odebrat, udělat správcem), jen vysvětlení; správce je dostane v nabídce
|
||||
/// člena.
|
||||
class _FakeIdentityNotifier extends IdentityNotifier {
|
||||
_FakeIdentityNotifier(this._identity);
|
||||
final LocalIdentity _identity;
|
||||
|
||||
@override
|
||||
Future<LocalIdentity?> build() async => _identity;
|
||||
}
|
||||
|
||||
class _NoContacts extends ContactsNotifier {
|
||||
@override
|
||||
Future<List<Contact>> build() async => const [];
|
||||
}
|
||||
|
||||
final _ja = LocalIdentity(
|
||||
displayName: 'Já',
|
||||
identityPrivateKey: Uint8List(32),
|
||||
identityPublicKey: Uint8List(33),
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
const _spravce = 'SPRAVCE0001';
|
||||
const _clen = 'CLENKA0002';
|
||||
|
||||
Group _group({required bool jaSpravce, required bool vsichniV2}) => Group(
|
||||
id: 'g1',
|
||||
name: 'Chata v srpnu',
|
||||
memberIds: [_ja.id, _spravce, _clen],
|
||||
createdBy: _spravce,
|
||||
admins: [_spravce, if (jaSpravce) _ja.id],
|
||||
v2Members: vsichniV2 ? {_spravce, _clen} : {_spravce},
|
||||
);
|
||||
|
||||
Widget _app(Group group) => ProviderScope(
|
||||
overrides: [
|
||||
identityProvider.overrideWith(() => _FakeIdentityNotifier(_ja)),
|
||||
contactsProvider.overrideWith(_NoContacts.new),
|
||||
groupProvider.overrideWith((ref, id) async => group),
|
||||
conversationProvider.overrideWith((ref, id) async => null),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('cs'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: GroupProfileScreen(groupId: group.id),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _pump(WidgetTester tester, Group group) async {
|
||||
tester.view.physicalSize = const Size(1080, 3200);
|
||||
tester.view.devicePixelRatio = 2.0;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(_app(group));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
ProfileAction _addAction(WidgetTester tester) => tester.widget<ProfileAction>(
|
||||
find.widgetWithText(ProfileAction, 'Přidat'));
|
||||
|
||||
void main() {
|
||||
testWidgets('ne-správce ve vynucené skupině: akce skryté, vysvětlení',
|
||||
(tester) async {
|
||||
await _pump(tester, _group(jaSpravce: false, vsichniV2: true));
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
expect(find.text('Správce'), findsOneWidget,
|
||||
reason: 'odznak u skutečného správce');
|
||||
expect(find.text('Jméno, členy a správce skupiny mění jen správci.'),
|
||||
findsOneWidget);
|
||||
expect(_addAction(tester).onTap, isNull,
|
||||
reason: 'přidávat lidi smí jen správce');
|
||||
expect(find.byIcon(Icons.edit_outlined), findsNothing,
|
||||
reason: 'přejmenování jen správcem');
|
||||
|
||||
await tester.tap(find.textContaining('SEP-CLENKA'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Odebrat ze skupiny'), findsNothing);
|
||||
expect(find.text('Udělat správcem'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('ne-správce s v1 členem ve skupině smí přidávat, ne spravovat',
|
||||
(tester) async {
|
||||
await _pump(tester, _group(jaSpravce: false, vsichniV2: false));
|
||||
expect(_addAction(tester).onTap, isNotNull);
|
||||
expect(find.textContaining('Přidávat lidi zatím může každý'),
|
||||
findsOneWidget);
|
||||
expect(find.byIcon(Icons.edit_outlined), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('správce dostane nabídku člena s odebráním a rolí',
|
||||
(tester) async {
|
||||
await _pump(tester, _group(jaSpravce: true, vsichniV2: true));
|
||||
expect(find.text('Správce'), findsNWidgets(2));
|
||||
expect(_addAction(tester).onTap, isNotNull);
|
||||
expect(find.byIcon(Icons.edit_outlined), findsOneWidget);
|
||||
expect(find.textContaining('mění jen správci'), findsNothing);
|
||||
|
||||
await tester.tap(find.textContaining('SEP-CLENKA'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Odebrat ze skupiny'), findsOneWidget);
|
||||
expect(find.text('Udělat správcem'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user