Files
sepot/app/lib/core/services/messaging_service.dart
T
Julius Joska 9985b79aa6 fix(app): ping the relay, retry stuck messages, drop duplicates
A message sent into a dead socket ended up as failed — attachments even
stayed on "sending" forever — and only the user could send it again by
hand. Mobile NAT drops idle TCP silently, so neither side knew.

- pingInterval on the WebSocket, so the client notices a dead link itself
- outbox: after every reconnect, stuck messages are re-sent (batch of 30,
  up to a day old); permanent errors are not retried
- sendAttachment gained the try/catch it was missing, otherwise the
  attachment never reached the outbox
- de-duplicate by message id: a retry re-sends the SAME message, which
  would otherwise show up twice on the other side
2026-08-13 12:23:24 +02:00

1706 lines
61 KiB
Dart

/// Orchestrace zpráv — sváže SepotCrypto (libsignal), RelayClient
/// a lokální storage do jednoho toku:
///
/// odeslání: text → content JSON → sealedEncrypt → obálka → relay
/// příjem: DELIVER → sealedDecrypt → mapování na kontakt přes
/// fingerprint sender identity klíče → uložení → ACK
///
/// ACK se posílá až PO uložení zprávy (relay pak obálku hard-maže).
///
/// Content formát v1 = JSON uvnitř E2E ciphertextu (relay ho NIKDY nevidí):
/// text: `{ v:1, t:'txt', id:<uuid>, txt:<text>, p:{...} }`
/// příloha: `{ v:1, t:'att', id:<uuid>, txt:<popisek?>, att:{...}, p:{...} }`
/// kde att = AttachmentMeta.toWire() (blob_id + klíč + nonce + digest
/// + mime/rozměry + inline náhled) — blob sám jde MIMO obálku přes
/// POST/GET /v1/attachments, relay vidí jen
/// šifrované bajty bez vazby na zprávu.
/// p: `{ n:<jméno>, aid:<hex accountId>, da:<b64 delivery address> }`
/// Profil `p` je self-declared KROMĚ vazby na identitu — ta je daná
/// kryptograficky (senderIdentityKey ze sealed sender decrypt).
/// TODO: content na protobuf, až se ustálí pole (mizící zprávy).
library;
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:libsignal/libsignal.dart' show IdentityKeyPair, ProtocolAddress;
import 'package:mime/mime.dart' show lookupMimeType;
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/crypto/attachment_cipher.dart';
import 'package:sepot/core/errors.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/network/relay_client.dart';
import 'package:sepot/core/network/relay_transport.dart';
import 'package:sepot/core/services/image_processor.dart';
import 'package:sepot/core/storage/attachment_store.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/persistent_stores.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/attachment.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/identity.dart';
import 'package:sepot/models/message.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb;
import 'package:uuid/uuid.dart';
class MessagingService {
MessagingService({
required this.storage,
this.relayUrl = AppConstants.relayUrl,
AttachmentStore? attachments,
}) : _attachments = attachments ?? AttachmentStore();
final SecureStorageService storage;
final String relayUrl;
final AttachmentStore _attachments;
/// UI notifikace o změně dat (nové zprávy, nový kontakt).
void Function()? onUpdate;
/// Přijatá zpráva (po uložení + ACK) — provider přes ni ukazuje
/// systémovou notifikaci, když appka není v popředí.
void Function(Contact contact, Message message)? onMessageReceived;
CryptoDb? _db;
SepotCrypto? _crypto;
RelayClient? _relay;
LocalIdentity? _identity;
Uint8List? _senderCert;
Uint8List? _trustRoot;
DateTime? _certFetchedAt;
/// Pinovaný trust root relaye se při obnově certu neshodoval — signál
/// MITM / podvrženého relaye zachycený i na pozadí (kde by výjimka jinak
/// zmizela). UI to může přečíst a varovat i mimo cestu odeslání.
bool _trustRootMismatch = false;
bool get trustRootMismatch => _trustRootMismatch;
StreamSubscription<dynamic>? _deliverSub;
StreamSubscription<RelayConnectionState>? _stateSub;
Timer? _registerRetryTimer;
Timer? _purgeTimer;
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
/// dvakrát.
bool _outboxRunning = false;
/// Transport relaye: false = přímé WSS, true = přes Tor (SOCKS5/Orbot na
/// `.onion`). Čte se z nastavení při startu, přepíná se za běhu
/// ([setTorEnabled]) přestavením [RelayClient].
bool _useTor = false;
int _socksPort = 9050;
/// Poslední push registrace — přežije přestavení relaye (nový RelayClient
/// začíná bez ní), aby po přepnutí Tor/přímo push dál fungoval.
pb.RegisterPush? _lastPush;
bool get isStarted => _crypto != null;
bool get isTorEnabled => _useTor;
RelayConnectionState get connectionState =>
_relay?.state ?? RelayConnectionState.disconnected;
Stream<RelayConnectionState>? get connectionStream => _relay?.stateStream;
String? get accountId {
final aid = _db?.kvGet('account_id');
return aid == null ? null : utf8.decode(aid);
}
Uint8List? get myDeliveryAddress => _db?.kvGet('delivery_address');
/// Nastartuje krypto vrstvu a spojení. Volat po odemčení aplikace
/// (existující identita) i po dokončení onboardingu.
///
/// DB je sdílená se [SecureStorageService] — storage musí být odemčený
/// jinak StateError. Selhání sítě NEshodí start — registrace/
/// připojení se opakují na pozadí; krypto a lokální data fungují okamžitě.
Future<void> start(LocalIdentity identity) async {
if (isStarted) return;
_identity = identity;
final db = storage.db;
_db = db;
// registration ID: 1..16380, jednou vygenerovat a držet
final regIdRaw = db.kvGet('registration_id');
final int regId;
if (regIdRaw == null) {
regId = 1 +
(RelayClient.randomBytes(2).buffer.asByteData().getUint16(0) %
16380);
db.kvPut('registration_id', utf8.encode(regId.toString()));
} else {
regId = int.parse(utf8.decode(regIdRaw));
}
// Transport podle uloženého nastavení (odemčený storage — čte se z DB).
_useTor = (await storage.getSetting<bool>('tor_enabled')) ?? false;
_socksPort = (await storage.getSetting<int>('tor_socks_port')) ?? 9050;
final existingAccount = accountId;
final crypto = SepotCrypto.fromPrivateKey(
privateKey: identity.identityPrivateKey,
accountId: existingAccount ?? 'unbound',
registrationId: regId,
storesFactory: (IdentityKeyPair keyPair) => SepotStores(
session: DbSessionStore(db),
identity: DbIdentityKeyStore(
db,
identityKeyPair: keyPair,
registrationId: regId,
),
preKey: DbPreKeyStore(db),
signedPreKey: DbSignedPreKeyStore(db),
kyberPreKey: DbKyberPreKeyStore(db),
),
);
_crypto = crypto;
final relay = _buildRelay();
_relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) unawaited(_flushOutbox());
});
if (existingAccount != null) {
relay.restoreSession(
accountId: existingAccount,
deviceUuid: utf8.decode(db.kvGet('device_uuid')!),
);
relay.start();
// Na pozadí: TrustRootChangedError by jinak byla neošetřená async
// výjimka — zachytíme ji do flagu, síťové chyby řeší _refreshSenderCert.
unawaited(_refreshSenderCert().catchError((Object e) {
if (e is TrustRootChangedError) _trustRootMismatch = true;
}));
} else {
unawaited(_registerWithRetry());
}
// Mizící zprávy: úklid hned po startu a pak průběžně
unawaited(_purgeExpired());
_purgeTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _purgeExpired(),
);
}
/// Sestaví [RelayClient] podle aktuálního transportu. Tor jede na
/// `.onion` (Tor obálka šifruje), přímý režim na výchozí `relayUrl`.
RelayClient _buildRelay() {
final url = _useTor ? AppConstants.relayOnion : relayUrl;
final transport = _useTor
? TorTransport(socksPort: _socksPort)
: DirectTransport();
return RelayClient(baseUrl: url, crypto: _crypto!, transport: transport);
}
/// Zapne/vypne routování přes Tor. Uloží volbu a — pokud služba běží —
/// přestaví spojení na `.onion`/přímé bez nutnosti restartu appky.
/// Krypto stav, identita ani pinovaný trust root se nemění.
Future<void> setTorEnabled(bool enabled, {int socksPort = 9050}) async {
await storage.setSetting('tor_enabled', enabled);
await storage.setSetting('tor_socks_port', socksPort);
if (!isStarted) {
_useTor = enabled;
_socksPort = socksPort;
return;
}
if (_useTor == enabled && _socksPort == socksPort) return;
_useTor = enabled;
_socksPort = socksPort;
await _rebuildRelay();
}
/// Zahodí starý [RelayClient] (i s jeho transportem) a nastartuje nový.
/// Sender certifikát se zneplatní (vyzvedne se přes nový transport);
/// push registrace se znovu odešle. Trust root zůstává pinovaný.
Future<void> _rebuildRelay() async {
await _deliverSub?.cancel();
await _stateSub?.cancel();
await _relay?.dispose();
_senderCert = null;
_certFetchedAt = null;
final relay = _buildRelay();
_relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) unawaited(_flushOutbox());
});
final existingAccount = accountId;
if (existingAccount != null) {
relay.restoreSession(
accountId: existingAccount,
deviceUuid: utf8.decode(_db!.kvGet('device_uuid')!),
);
relay.start();
// Na pozadí: TrustRootChangedError by jinak byla neošetřená async
// výjimka — zachytíme ji do flagu, síťové chyby řeší _refreshSenderCert.
unawaited(_refreshSenderCert().catchError((Object e) {
if (e is TrustRootChangedError) _trustRootMismatch = true;
}));
if (_lastPush != null) relay.registerPush(_lastPush!);
} else {
unawaited(_registerWithRetry());
}
}
Future<void> _purgeExpired() async {
try {
if (await storage.purgeExpiredMessages()) {
onUpdate?.call();
}
} catch (_) {
// best-effort — sweep nesmí shodit messaging
}
}
Future<void> _registerWithRetry() async {
try {
await _register();
} catch (_) {
_registerRetryTimer?.cancel();
_registerRetryTimer =
Timer(const Duration(seconds: 30), _registerWithRetry);
return;
}
_relay!.start();
await _refreshSenderCert();
}
Future<void> _register() async {
final db = _db!;
final crypto = _crypto!;
final nowMs = DateTime.now().millisecondsSinceEpoch;
final bundle = await crypto.generatePreKeyBundle(
preKeyId: 1,
signedPreKeyId: 1,
kyberPreKeyId: 1,
timestampMs: nowMs,
);
// bundle nese OTP id 1; dalších 99 do zásoby
final otps = await crypto.generateOneTimePreKeys(startId: 2, count: 99);
final deliveryAddress = RelayClient.randomBytes(32);
final reg = await _relay!.register(
bundle: bundle,
oneTimePreKeys: [
SepotOneTimePreKey(id: bundle.preKeyId!, publicKey: bundle.preKeyPublic!),
...otps,
],
deliveryAddress: deliveryAddress,
);
db.transaction(() {
db.kvPut('account_id', utf8.encode(reg.accountId));
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
db.kvPut('delivery_address', deliveryAddress);
});
onUpdate?.call();
}
Future<void> _refreshSenderCert() async {
try {
final (cert, trustRoot) = await _relay!.fetchSenderCertificate();
final pinned = _db!.kvGet('trust_root');
if (pinned == null) {
_db!.kvPut('trust_root', trustRoot);
} else if (!_listEquals(pinned, trustRoot)) {
// Jiný trust root = jiný relay / MITM — tvrdě odmítnout
throw const TrustRootChangedError();
}
_senderCert = cert;
_trustRoot = Uint8List.fromList(pinned ?? trustRoot);
_certFetchedAt = DateTime.now();
} on TrustRootChangedError {
// Bezpečnostní signál (MITM / podvržený relay), ne přechodná chyba —
// propustíme dál. Cert se nepřijal, takže první odeslání skončí touž
// výjimkou i ve viditelné cestě a UI ji ukáže. Pozadové (unawaited)
// volání ji zachytí do `trustRootMismatch`.
rethrow;
} on Object {
// Síť/offline/timeout: fetchSenderCertificate může hodit i
// SocketException/ClientException (http bez timeoutu, běžný offline
// start), nejen RelayException. Spolkneme — zkusí se při odeslání.
}
}
Future<Uint8List> _senderCertificate() async {
final stale = _certFetchedAt == null ||
DateTime.now().difference(_certFetchedAt!) >
const Duration(hours: 12);
if (_senderCert == null || stale) {
await _refreshSenderCert();
}
final cert = _senderCert;
if (cert == null) {
throw const RelayUnavailableError();
}
return cert;
}
// ── Odeslání ────────────────────────────────────────────────────────
Future<Message> sendText(Contact contact, String text,
{String? replyToId}) async {
final identity = _identity ?? (throw StateError('Service not started'));
final ttl = await _conversationTtl(contact.id);
// Optimisticky ulož jako „odesílá se" (⏱ hned v UI), pak přepni podle
// reálného potvrzení relaye.
final message = Message.text(
senderId: identity.id,
recipientId: contact.id,
content: text,
replyToId: replyToId,
disappearAfterSeconds: ttl,
status: MessageStatus.sending,
);
await storage.addMessage(contact.id, message);
onUpdate?.call();
try {
final delivered = await _sendContent(contact, {
'v': 1,
't': 'txt',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'txt': text,
're': ?replyToId,
'ttl': ?ttl,
'p': _profile(),
});
final sent = message.copyWith(
isDelivered: delivered,
status: delivered ? MessageStatus.delivered : MessageStatus.sent,
);
await storage.updateMessage(contact.id, sent);
onUpdate?.call();
return sent;
} catch (e) {
await storage.updateMessage(
contact.id, message.copyWith(status: MessageStatus.failed));
onUpdate?.call();
rethrow;
}
}
/// Odešle přílohu: obrázky se nejdřív re-enkódují (EXIF/GPS pryč,
/// max 2048 px) a dostanou inline náhled; dokumenty jdou beze změny.
/// Blob se šifruje per-file klíčem, nahraje na relay a lokálně se drží
/// STEJNÝ šifrovaný blob (odesílatel i příjemce renderují touž cestou).
Future<Message> sendAttachment(
Contact contact, {
required Uint8List bytes,
required String fileName,
String? mime,
String? caption,
int? durationMs,
}) async {
final identity = _identity ?? (throw StateError('Service not started'));
final meta = await _prepareAttachment(
bytes: bytes,
fileName: fileName,
mime: mime,
durationMs: durationMs,
);
final ttl = await _conversationTtl(contact.id);
final message = Message.attachment(
senderId: identity.id,
recipientId: contact.id,
attachment: meta,
caption: caption,
disappearAfterSeconds: ttl,
status: MessageStatus.sending,
);
await storage.addMessage(contact.id, message);
onUpdate?.call();
// Stejné ošetření jako u textu: bez něj zůstala příloha po výpadku
// spojení viset na „odesílá se" navždy. Označit jako failed ji dostane
// do fronty odchozích ([_flushOutbox]) — blob je na relayi nahraný,
// dosílá se jen meta.
try {
final delivered = await _sendContent(contact, {
'v': 1,
't': 'att',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
if (caption != null && caption.isNotEmpty) 'txt': caption,
'ttl': ?ttl,
'att': meta.toWire(),
'p': _profile(),
});
final sent = message.copyWith(
isDelivered: delivered,
status: delivered ? MessageStatus.delivered : MessageStatus.sent,
);
await storage.updateMessage(contact.id, sent);
onUpdate?.call();
return sent;
} catch (_) {
await storage.updateMessage(
contact.id, message.copyWith(status: MessageStatus.failed));
onUpdate?.call();
rethrow;
}
}
/// Zpracuje a nahraje přílohu: obrázky re-enkóduje (EXIF/GPS pryč,
/// max 2048 px) a přidá inline náhled; blob zašifruje per-file klíčem,
/// nahraje na relay a lokálně uloží. Meta pak jede E2E kanálem —
/// u skupin STEJNÁ meta všem členům (blob se nahrává jen jednou).
Future<AttachmentMeta> _prepareAttachment({
required Uint8List bytes,
required String fileName,
String? mime,
int? durationMs,
}) async {
final relay = _relay!;
final guessedMime = mime ??
lookupMimeType(fileName, headerBytes: bytes) ??
'application/octet-stream';
var payload = bytes;
var finalMime = guessedMime;
String? name = fileName;
int? width;
int? height;
String? thumbB64;
if (guessedMime.startsWith('image/')) {
final processed =
await ImageProcessor.process(bytes, mimeHint: guessedMime);
if (processed != null) {
payload = processed.bytes;
finalMime = processed.mime;
width = processed.width;
height = processed.height;
thumbB64 = base64Encode(processed.thumbnailJpeg);
// Původní název fotky ven nejde (bývá v něm datum/čas focení)
name = null;
}
}
if (payload.length > AppConstants.maxAttachmentBytes) {
throw const AttachmentTooLargeError(AppConstants.maxAttachmentBytes);
}
final encrypted = await AttachmentCipher.encrypt(payload);
final blobId = await relay.uploadAttachment(encrypted.blob);
await _attachments.save(blobId, encrypted.blob);
return AttachmentMeta(
blobId: blobId,
keyB64: base64Encode(encrypted.key),
nonceB64: base64Encode(encrypted.nonce),
digestB64: base64Encode(encrypted.digest),
size: encrypted.plaintextSize,
mime: finalMime,
name: name,
width: width,
height: height,
thumbB64: thumbB64,
durationMs: durationMs,
state: AttachmentTransferState.ready,
);
}
/// TTL mizících zpráv nastavené na konverzaci (null = vypnuto).
Future<int?> _conversationTtl(String contactId) async {
final conv = await storage.loadConversation(contactId);
return conv?.disappearAfterSeconds;
}
Map<String, dynamic> _profile() {
final identity = _identity!;
final da = myDeliveryAddress;
return {
'n': identity.displayName,
'aid': ?accountId,
// Do doběhnutí vlastní registrace na relay ještě routing nemáme —
// pole vynecháme (protistrana ho doplní z pozdějšího profilu), NIKDY
// `da!` (null-check pád = „Null check operator used on a null value").
'da': ?(da == null ? null : base64Encode(da)),
};
}
// ── Reakce / editace / mazání (control zprávy) ──────────────────────
//
// Nevytvářejí novou bublinu — mění existující zprávu podle jejího id
// (`ref`). Poctivá hranice jako u mizících zpráv: je to dohoda klientů,
// protistrana mohla obsah už vidět.
/// Můj fingerprint (klíč do mapy reakcí).
String get _myFingerprint => _identity!.fingerprint.display;
/// Přidá/odebere moji reakci na zprávu. `emoji == null` = odebrat.
Future<void> sendReaction(
Contact contact, String targetId, String? emoji) async {
await _mutateLocalMessage(contact.id, targetId, (m) {
final r = Map<String, String>.from(m.reactions);
if (emoji == null) {
r.remove(_myFingerprint);
} else {
r[_myFingerprint] = emoji;
}
return m.copyWith(reactions: r);
});
await _sendControl(contact, {
't': 'rx',
'ref': targetId,
'e': ?emoji,
});
}
/// Upraví text mojí odeslané zprávy.
Future<void> editMessage(
Contact contact, String targetId, String newText) async {
await _mutateLocalMessage(contact.id, targetId,
(m) => m.copyWith(content: newText, editedAt: DateTime.now()));
await _sendControl(contact, {'t': 'edit', 'ref': targetId, 'txt': newText});
}
/// Pošle odesílateli potvrzení přečtení pro dané zprávy — jen když mám
/// read receipts ZAPNUTÉ (default vypnuto). Best-effort.
Future<void> sendReadReceipts(
Contact contact, List<String> messageIds) async {
if (messageIds.isEmpty) return;
final enabled =
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
if (!enabled) return;
await _sendControl(contact, {'t': 'rr', 'ids': messageIds});
}
/// Smaže zprávu pro všechny (tombstone u obou stran).
Future<void> deleteMessage(Contact contact, String targetId) async {
await _mutateLocalMessage(contact.id, targetId,
(m) => m.copyWith(isDeleted: true, content: '', reactions: const {}));
await _sendControl(contact, {'t': 'del', 'ref': targetId});
}
/// Odešle control zprávu (best-effort; lokální změna už proběhla).
Future<void> _sendControl(
Contact contact, Map<String, dynamic> extra) async {
try {
await _sendContent(contact, {
'v': 1,
'id': const Uuid().v4(),
...extra,
'p': _profile(),
});
} catch (_) {
// síť selhala — lokální stav zůstává, protistrana se dozví později
// jen když znovu odešleme (v1: bez retry fronty pro control zprávy)
}
}
/// Najde zprávu podle id v konverzaci a aplikuje transformaci.
Future<void> _mutateLocalMessage(String contactId, String messageId,
Message Function(Message) transform) async {
final conv = await storage.loadConversation(contactId);
if (conv == null) return;
final i = conv.messages.indexWhere((m) => m.id == messageId);
if (i < 0) return;
await storage.updateMessage(contactId, transform(conv.messages[i]));
onUpdate?.call();
}
// ── Skupiny (párový fanout) ─────────────────────────────────────────
//
// Skupina nemá vlastní krypto vrstvu — každá zpráva jede existujícím
// párovým Double Ratchet kanálem každému členovi zvlášť (`gid` v E2E
// obsahu ji směruje do skupinové konverzace). Autor = sealed-sender
// fingerprint, nikdy self-declared pole.
/// Založí skupinu a rozešle pozvánku (`ginv` s úplným rosterem).
/// Všichni členové musí mít routing (aid+da) — bez něj jim fanout
/// nemá kam doručit.
Future<Group> createGroup(String name, List<Contact> members) async {
if (_identity == null) throw StateError('Service not started');
if (members.isEmpty) throw const GroupNeedsMemberError();
if (members.length + 1 > Group.maxMembers) {
throw GroupTooLargeError(Group.maxMembers);
}
for (final m in members) {
final p = m.publicIdentity;
if (p.accountId == null || p.deliveryAddress == null) {
throw MemberNoDeliveryAddressError(m.displayName);
}
}
final group = Group(
id: const Uuid().v4(),
name: name.trim(),
memberIds: [_myFingerprint, ...members.map((m) => m.id)],
createdBy: _myFingerprint,
);
await storage.saveGroup(group);
// Konverzace vznikne prvním addMessage; založ ji hned, ať je v chat
// listu vidět i prázdná skupina.
await storage.ensureConversation(group.id);
onUpdate?.call();
await _fanoutInvite(group);
return group;
}
/// Přidá členy: rozšíří roster a rozešle nový úplný stav VŠEM členům
/// (noví z něj poprvé uvidí skupinu).
Future<void> addGroupMembers(Group group, List<Contact> added) async {
final ids = {...group.memberIds};
for (final m in added) {
final p = m.publicIdentity;
if (p.accountId == null || p.deliveryAddress == null) {
throw MemberNoDeliveryAddressError(m.displayName);
}
ids.add(m.id);
}
if (ids.length > Group.maxMembers) {
throw GroupTooLargeError(Group.maxMembers);
}
group.memberIds = ids.toList();
await storage.saveGroup(group);
onUpdate?.call();
await _fanoutInvite(group);
}
/// Přejmenuje skupinu (lokálně hned, členům `gmeta` best-effort).
Future<void> renameGroup(Group group, String newName) async {
group.name = newName.trim();
await storage.saveGroup(group);
onUpdate?.call();
await _fanoutToGroup(group, {
'v': 1,
't': 'gmeta',
'id': const Uuid().v4(),
'gid': group.id,
'gname': group.name,
'p': _profile(),
});
}
/// Opustí skupinu: oznámí členům `gleave`, lokálně označí `left`
/// (historie zůstává, vstup i příjem jsou mrtvé).
Future<void> leaveGroup(Group group) async {
await _fanoutToGroup(group, {
'v': 1,
't': 'gleave',
'id': const Uuid().v4(),
'gid': group.id,
'p': _profile(),
});
group.left = true;
group.memberIds =
group.memberIds.where((f) => f != _myFingerprint).toList();
await storage.saveGroup(group);
onUpdate?.call();
}
/// Odešle text do skupiny. Sémantika stavu: aspoň jeden
/// člen přijal → `sent`; všichni selhali → `failed`; `delivered` až po
/// první doručence.
Future<Message> sendGroupText(Group group, String text,
{String? replyToId}) async {
final identity = _identity ?? (throw StateError('Service not started'));
final ttl = await _conversationTtl(group.id);
final message = Message.text(
senderId: identity.id,
recipientId: group.id,
content: text,
replyToId: replyToId,
disappearAfterSeconds: ttl,
status: MessageStatus.sending,
);
await storage.addMessage(group.id, message);
onUpdate?.call();
try {
final anyOk = await _fanoutToGroup(group, {
'v': 1,
't': 'txt',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'gid': group.id,
'txt': text,
're': ?replyToId,
'ttl': ?ttl,
'p': _profile(),
});
final sent = message.copyWith(
status: anyOk ? MessageStatus.sent : MessageStatus.failed);
await storage.updateMessage(group.id, sent);
onUpdate?.call();
return sent;
} catch (e) {
// Výjimka mimo per-member cestu (např. build obsahu) by jinak nechala
// zprávu navždy ve stavu „odesílá se" (⏱). Označit jako selhalou.
await storage.updateMessage(
group.id, message.copyWith(status: MessageStatus.failed));
onUpdate?.call();
rethrow;
}
}
/// Odešle přílohu do skupiny — blob se šifruje a nahrává JEDNOU,
/// všem členům jede stejná meta (klíč+nonce+digest) E2E kanály.
Future<Message> sendGroupAttachment(
Group group, {
required Uint8List bytes,
required String fileName,
String? mime,
String? caption,
int? durationMs,
}) async {
final identity = _identity ?? (throw StateError('Service not started'));
final meta = await _prepareAttachment(
bytes: bytes,
fileName: fileName,
mime: mime,
durationMs: durationMs,
);
final ttl = await _conversationTtl(group.id);
final message = Message.attachment(
senderId: identity.id,
recipientId: group.id,
attachment: meta,
caption: caption,
disappearAfterSeconds: ttl,
status: MessageStatus.sending,
);
await storage.addMessage(group.id, message);
onUpdate?.call();
try {
final anyOk = await _fanoutToGroup(group, {
'v': 1,
't': 'att',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'gid': group.id,
if (caption != null && caption.isNotEmpty) 'txt': caption,
'ttl': ?ttl,
'att': meta.toWire(),
'p': _profile(),
});
final sent = message.copyWith(
status: anyOk ? MessageStatus.sent : MessageStatus.failed);
await storage.updateMessage(group.id, sent);
onUpdate?.call();
return sent;
} catch (e) {
await storage.updateMessage(
group.id, message.copyWith(status: MessageStatus.failed));
onUpdate?.call();
rethrow;
}
}
/// Reakce ve skupině (emoji == null = odebrat).
Future<void> sendGroupReaction(
Group group, String targetId, String? emoji) async {
await _mutateLocalMessage(group.id, targetId, (m) {
final r = Map<String, String>.from(m.reactions);
if (emoji == null) {
r.remove(_myFingerprint);
} else {
r[_myFingerprint] = emoji;
}
return m.copyWith(reactions: r);
});
await _fanoutToGroup(group, {
'v': 1,
't': 'rx',
'id': const Uuid().v4(),
'gid': group.id,
'ref': targetId,
'e': ?emoji,
'p': _profile(),
});
}
/// Editace mojí zprávy ve skupině.
Future<void> editGroupMessage(
Group group, String targetId, String newText) async {
await _mutateLocalMessage(group.id, targetId,
(m) => m.copyWith(content: newText, editedAt: DateTime.now()));
await _fanoutToGroup(group, {
'v': 1,
't': 'edit',
'id': const Uuid().v4(),
'gid': group.id,
'ref': targetId,
'txt': newText,
'p': _profile(),
});
}
/// Smazání mojí zprávy pro všechny členy.
Future<void> deleteGroupMessage(Group group, String targetId) async {
await _mutateLocalMessage(group.id, targetId,
(m) => m.copyWith(isDeleted: true, content: '', reactions: const {}));
await _fanoutToGroup(group, {
'v': 1,
't': 'del',
'id': const Uuid().v4(),
'gid': group.id,
'ref': targetId,
'p': _profile(),
});
}
/// Rozešle `ginv` s úplným rosterem všem členům kromě mě.
Future<void> _fanoutInvite(Group group) async {
final roster = await _buildRoster(group);
await _fanoutToGroup(group, {
'v': 1,
't': 'ginv',
'id': const Uuid().v4(),
'gid': group.id,
'gname': group.name,
'roster': roster,
'p': _profile(),
});
}
/// Testovací vstup do skupinové příjmové cesty bez relaye a krypta:
/// dosadí identitu příjemce a zavolá handlery, jejichž bezpečnostní
/// pravidla (anti-spoof rosteru, membership guard) chceme ověřit
/// izolovaně. `senderFp` v produkci VŽDY pochází ze sealed senderu.
@visibleForTesting
void debugSetIdentity(LocalIdentity identity) => _identity = identity;
@visibleForTesting
Future<void> debugHandleGroupInvite(
String senderFp, Map<String, dynamic> content) =>
_handleGroupInvite(senderFp, content);
@visibleForTesting
Future<Group?> debugGroupForIncoming(String gid, String senderFp) =>
_groupForIncoming(gid, senderFp);
/// Roster = [{f, n, ik, aid, da}] pro všechny členy včetně mě.
/// Člen bez routingu se vynechá (příjemci mu psát nemohou, ale
/// zprávy od něj membership guardem projdou).
Future<List<Map<String, dynamic>>> _buildRoster(Group group) async {
final contacts = await storage.loadContacts();
final byFp = {for (final c in contacts) c.id: c};
final roster = <Map<String, dynamic>>[];
final me = shareableIdentity!;
roster.add({
'f': _myFingerprint,
'n': me.displayName,
'ik': base64Encode(me.identityKey),
'aid': me.accountId,
'da': base64Encode(me.deliveryAddress!),
});
for (final fp in group.memberIds) {
if (fp == _myFingerprint) continue;
final c = byFp[fp];
final p = c?.publicIdentity;
if (p == null || p.accountId == null || p.deliveryAddress == null) {
continue;
}
roster.add({
'f': fp,
'n': c!.displayName,
'ik': base64Encode(p.identityKey),
'aid': p.accountId,
'da': base64Encode(p.deliveryAddress!),
});
}
return roster;
}
/// Fanout obsahu všem členům kromě mě. Vrací true, když aspoň jedno
/// odeslání prošlo (per-member retry v1 není — stejná hranice jako
/// u 1:1 control zpráv).
Future<bool> _fanoutToGroup(
Group group, Map<String, dynamic> content) async {
final contacts = await storage.loadContacts();
final byFp = {for (final c in contacts) c.id: c};
var anyOk = false;
for (final fp in group.memberIds) {
if (fp == _myFingerprint) continue;
final c = byFp[fp];
if (c == null || c.blocked) continue;
try {
await _sendContent(c, content);
anyOk = true;
} catch (_) {
// člen offline/bez routingu — v1 bez per-member retry fronty
}
}
return anyOk;
}
/// Membership guard příchozího skupinového obsahu: skupina existuje,
/// neopustil jsem ji a odesílatel je člen. Jinak null → drop.
Future<Group?> _groupForIncoming(String gid, String senderFp) async {
final group = await storage.loadGroup(gid);
if (group == null || group.left) return null;
if (!group.isMember(senderFp)) return null;
return group;
}
/// Zpracuje `ginv` (pozvánka / úplná aktualizace rosteru).
/// Bezpečnostní pravidla: fingerprint každého záznamu
/// musí sedět s jeho identity klíčem, odesílatel i já musíme být
/// v rosteru, identity klíč existujícího kontaktu se NIKDY nepřepisuje.
Future<void> _handleGroupInvite(
String senderFp, Map<String, dynamic> content) async {
final gid = content['gid'] as String?;
final gname = (content['gname'] as String?)?.trim();
final rosterRaw = content['roster'] as List?;
if (gid == null || gid.isEmpty || gid.length > 64) return;
if (gname == null || gname.isEmpty || rosterRaw == null) return;
if (rosterRaw.length > Group.maxMembers) return;
// Ověř roster: f musí být fingerprint ik (anti-spoof).
final members = <String, Map<String, dynamic>>{};
for (final raw in rosterRaw) {
if (raw is! Map) continue;
final e = raw.cast<String, dynamic>();
final f = e['f'] as String?;
final ikB64 = e['ik'] as String?;
if (f == null || ikB64 == null) continue;
final Uint8List ik;
try {
ik = base64Decode(ikB64);
} on FormatException {
continue;
}
if (SepotFingerprint.ofPublicKey(ik).display != f) continue;
members[f] = e;
}
if (!members.containsKey(senderFp)) return;
if (!members.containsKey(_myFingerprint)) return;
final existing = await storage.loadGroup(gid);
if (existing != null) {
if (existing.left) return;
// Aktualizaci rosteru smí poslat jen stávající člen.
if (!existing.isMember(senderFp)) return;
}
// Neznámé členy založ jako běžné (neověřené) kontakty; existujícím
// nanejvýš doplň chybějící routing (jen při shodě identity klíče).
final contacts = await storage.loadContacts();
final byFp = {for (final c in contacts) c.id: c};
for (final e in members.values) {
final f = e['f'] as String;
if (f == _myFingerprint) continue;
final aid = e['aid'] as String?;
final daB64 = e['da'] as String?;
final known = byFp[f];
if (known != null) {
final p = known.publicIdentity;
if (aid != null &&
daB64 != null &&
(p.accountId == null || p.deliveryAddress == null)) {
await storage.saveContact(Contact(
displayName: known.displayName,
publicIdentity: p.withRouting(
accountId: aid,
deliveryAddress: base64Decode(daB64),
),
addedAt: known.addedAt,
verified: known.verified,
notes: known.notes,
blocked: known.blocked,
));
}
continue;
}
final name = (e['n'] as String?)?.trim();
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity(
// Prázdné jméno = UI si doplní lokalizovaný fallback
displayName: (name == null || name.isEmpty) ? '' : name,
identityKey: base64Decode(e['ik'] as String),
createdAt: DateTime.now(),
accountId: aid,
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
)));
}
final group = Group(
id: gid,
name: gname,
memberIds: members.keys.toList(),
createdBy: existing?.createdBy ?? senderFp,
createdAt: existing?.createdAt,
);
await storage.saveGroup(group);
await storage.ensureConversation(gid);
onUpdate?.call();
}
/// Společná odesílací cesta: session bootstrap (X3DH s anti-MITM
/// kontrolou identity klíče), sealed encrypt, obálka na relay.
/// Vrací true = doručeno online, false = uloženo do offline fronty.
/// Kolik zaseklých zpráv poslat na jeden průchod. Po delším výpadku sítě
/// jich může být hodně; dávka drží start spojení svižný a zbytek dojde
/// při dalším ticku.
static const _outboxBatch = 30;
/// Jak staré zaseklé zprávy ještě dosílat. Co leží dýl, patří uživateli
/// do rukou — poslat týden starou zprávu bez zeptání by překvapilo.
static const _outboxMaxAge = Duration(hours: 24);
/// Došle zprávy, které zůstaly viset po výpadku spojení.
///
/// Bez tohohle skončila každá zpráva odeslaná do mrtvého socketu jako
/// `failed` (nebo věčné `sending` u příloh) a znovu ji poslal jedině
/// uživatel ručně. Volá se po každém úspěšném (re)connectu; id zprávy
/// zůstává stejné, takže protistrana duplicitu zahodí
/// ([Conversation.addMessage]).
Future<void> _flushOutbox() async {
if (_outboxRunning || !isStarted) return;
_outboxRunning = true;
try {
final identity = _identity;
if (identity == null) return;
final cutoff = DateTime.now().subtract(_outboxMaxAge);
final stuck = <(String convId, Message message)>[];
for (final conv in await storage.loadConversations()) {
for (final m in conv.messages) {
if (m.senderId != identity.id) continue;
if (m.status != MessageStatus.failed &&
m.status != MessageStatus.sending) {
continue;
}
if (m.timestamp.isBefore(cutoff) || m.isDeleted) continue;
stuck.add((conv.contactId, m));
}
}
if (stuck.isEmpty) return;
stuck.sort((a, b) => a.$2.timestamp.compareTo(b.$2.timestamp));
for (final (convId, message) in stuck.take(_outboxBatch)) {
if (connectionState != RelayConnectionState.connected) break;
await _resend(convId, message);
}
onUpdate?.call();
} finally {
_outboxRunning = false;
}
}
/// Jeden pokus o dodatečné odeslání. Trvalé chyby (chybný klíč protistrany,
/// kontakt bez doručovací adresy) nechají zprávu `failed` a dál se
/// neopakují — opakovat má smysl jen to, co shodila síť.
Future<void> _resend(String convId, Message message) async {
try {
final group = await storage.loadGroup(convId);
final bool delivered;
if (group != null) {
delivered = await _fanoutToGroup(group, _outboxWire(message, gid: convId));
} else {
final contact = (await storage.loadContacts())
.where((c) => c.id == convId)
.firstOrNull;
if (contact == null) return;
delivered = await _sendContent(contact, _outboxWire(message));
}
await storage.updateMessage(
convId,
message.copyWith(
isDelivered: delivered,
status: delivered ? MessageStatus.delivered : MessageStatus.sent,
),
);
} on SepotUserError {
// Srozumitelná chyba = stav, který se opakováním nespraví (chybí
// doručovací adresa, nesedí identity klíč, podvržený relay).
await storage.updateMessage(
convId,
message.copyWith(status: MessageStatus.failed),
);
} on Object {
// Síť/timeout — nechat viset a zkusit po příštím reconnectu.
await storage.updateMessage(
convId,
message.copyWith(status: MessageStatus.failed),
);
}
}
/// Obsah zprávy pro drát při dodatečném odeslání. Drží se tvaru, který
/// posílá [sendText]/[sendAttachment] — včetně `id`, aby šlo o TUTÉŽ
/// zprávu, ne novou.
Map<String, dynamic> _outboxWire(Message message, {String? gid}) {
final isAttachment = message.attachment != null;
final caption = message.content;
return {
'v': 1,
't': isAttachment ? 'att' : 'txt',
'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'gid': ?gid,
if (isAttachment) 'att': message.attachment!.toWire(),
if (!isAttachment || (caption != null && caption.isNotEmpty))
'txt': caption ?? '',
're': ?message.replyToId,
'ttl': ?message.disappearAfterSeconds,
'p': _profile(),
};
}
Future<bool> _sendContent(
Contact contact,
Map<String, dynamic> content,
) async {
final crypto = _crypto ?? (throw StateError('Service not started'));
final relay = _relay!;
// Vlastní registrace na relay ještě nedoběhla (čerstvá instalace,
// pomalá síť, Tor bez Orbotu) → nemáme sender adresu ani sender cert.
// Srozumitelná chyba místo null-check pádu; registrace běží na pozadí.
if (accountId == null || myDeliveryAddress == null) {
throw const NotYetRegisteredError();
}
final peer = contact.publicIdentity;
final peerAid = peer.accountId;
final peerDa = peer.deliveryAddress;
if (peerAid == null || peerDa == null) {
throw const ContactNoDeliveryAddressError();
}
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
if (!await crypto.hasSession(peerAddr)) {
final bundle = await relay.fetchBundle(peerDa);
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
throw const IdentityKeyMismatchError();
}
await crypto.processRemoteBundle(peerAddr, bundle);
}
final sealed = await crypto.sealedEncrypt(
recipient: peerAddr,
plaintext: utf8.encode(jsonEncode(content)),
senderCertificate: await _senderCertificate(),
);
final receipt =
await relay.sendEnvelope(dstAddress: peerDa, payload: sealed);
return receipt.status == pb.Receipt_Status.STATUS_DELIVERED_ONLINE;
}
// ── Příjem ──────────────────────────────────────────────────────────
Future<void> _onDeliver(pb.Envelope envelope) async {
final crypto = _crypto;
final trustRoot = _trustRoot;
if (crypto == null || trustRoot == null) return;
final SepotSealedMessage opened;
try {
opened = await crypto.sealedDecrypt(
ciphertext: Uint8List.fromList(envelope.payload),
trustRoot: trustRoot,
timestampMs: DateTime.now().millisecondsSinceEpoch,
);
} catch (_) {
// Nedešifrovatelná/cizí obálka: NEACKovat, ať ji fronta podrží
// (může jít o dočasný stav — např. cert refresh).
return;
}
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
// trvale otravovala frontu při každém reconnectu.
try {
final content =
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
final type = content['t'] as String?;
const known = {
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', // 1:1 + skupiny
'ginv', 'gleave', 'gmeta', // správa skupin
};
if (content['v'] != 1 || !known.contains(type)) {
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
_relay?.ack(envelope.msgUuid);
return;
}
final contact = await _findOrCreateContact(opened, content);
final senderFp = contact.id;
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
// nic neukládej ani nezobrazuj a neposílej doručenku.
if (contact.blocked) {
_relay!.ack(envelope.msgUuid);
return;
}
// Správa skupin: pozvánka/roster, odchod, přejmenování. Autorita
// = sealed-sender fingerprint; guardy uvnitř handlerů. ACK vždy
// (retry z fronty nepomůže).
if (type == 'ginv') {
await _handleGroupInvite(senderFp, content);
_relay!.ack(envelope.msgUuid);
return;
}
if (type == 'gleave' || type == 'gmeta') {
final gid = content['gid'] as String?;
final group =
gid == null ? null : await _groupForIncoming(gid, senderFp);
if (group != null) {
if (type == 'gleave') {
group.memberIds =
group.memberIds.where((f) => f != senderFp).toList();
} else {
final gname = (content['gname'] as String?)?.trim();
if (gname != null && gname.isNotEmpty) group.name = gname;
}
await storage.saveGroup(group);
onUpdate?.call();
}
_relay!.ack(envelope.msgUuid);
return;
}
// Skupinový obsah: membership guard (skupinu znám, neopustil jsem
// ji, odesílatel je člen) — jinak tichý drop s ACK.
final gid = content['gid'] as String?;
if (gid != null && await _groupForIncoming(gid, senderFp) == null) {
_relay!.ack(envelope.msgUuid);
return;
}
// Konverzace, do které obsah patří: skupina (gid), jinak 1:1.
final convId = gid ?? contact.id;
// Doručenka: příjemce potvrdil, že moje zpráva dorazila na jeho
// zařízení → povýšíme ✓ (odesláno) na ✓✓ (doručeno). Off-line zprávy
// se tak dočkají ✓✓ ve chvíli, kdy si je protistrana vyzvedne.
// Ve skupině povyšuje první doručenka (per-member matice v2).
if (type == 'dlv') {
final ref = content['ref'] as String?;
if (ref != null) {
await _mutateLocalMessage(
convId,
ref,
(m) => m.isMine(_identity!.id) &&
m.status != MessageStatus.delivered
? m.copyWith(
isDelivered: true, status: MessageStatus.delivered)
: m,
);
}
_relay!.ack(envelope.msgUuid);
onUpdate?.call();
return;
}
// Potvrzení přečtení: povýší moje doručené zprávy na „přečteno".
// Respektujeme jen když mám read receipts ZAPNUTÉ (reciproční).
if (type == 'rr') {
final enabled =
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
if (enabled) {
// whereType přeskočí netypové prvky — podvržené 'ids' nesmí
// shodit příjem (jinak by se obálka bez ACK doručovala donekonečna).
final ids =
(content['ids'] as List?)?.whereType<String>() ?? const <String>[];
for (final id in ids) {
await _mutateLocalMessage(
convId,
id,
(m) => m.isMine(_identity!.id) &&
m.status != MessageStatus.read
? m.copyWith(status: MessageStatus.read, isDelivered: true)
: m,
);
}
}
_relay!.ack(envelope.msgUuid);
onUpdate?.call();
return;
}
// Control zprávy (reakce/edit/del) mění existující zprávu, nevytvářejí
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
if (type == 'rx' || type == 'edit' || type == 'del') {
await _applyIncomingControl(convId, type!, content, opened);
_relay!.ack(envelope.msgUuid);
onUpdate?.call();
return;
}
final messageId = (content['id'] as String?) ?? const Uuid().v4();
// TTL mizící zprávy si diktuje odesílatel (jede E2E kanálem);
// strop 30 dní proti nesmyslným hodnotám z drátu
var ttl = content['ttl'] as int?;
if (ttl != null && (ttl < 5 || ttl > 30 * 86400)) ttl = null;
final replyTo = content['re'] as String?;
final Message message;
if (type == 'txt') {
final text = content['txt'];
if (text is! String) {
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
_relay!.ack(envelope.msgUuid);
return;
}
message = Message(
id: messageId,
senderId: contact.publicIdentity.fingerprint.display,
recipientId: _identity!.id,
type: MessageType.text,
content: text,
replyToId: replyTo,
timestamp: wireSentAt(content['ts']),
isDelivered: true,
disappearAfterSeconds: ttl,
);
} else {
final AttachmentMeta meta;
try {
meta = AttachmentMeta.fromWire(
content['att'] as Map<String, dynamic>);
if (!AttachmentStore.isValidBlobId(meta.blobId)) {
throw const FormatException('invalid blob_id');
}
} on Object {
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout
_relay!.ack(envelope.msgUuid);
return;
}
message = Message(
id: messageId,
senderId: contact.publicIdentity.fingerprint.display,
recipientId: _identity!.id,
type: meta.isImage ? MessageType.image : MessageType.file,
content: content['txt'] as String?,
fileName: meta.name,
fileSize: meta.size,
replyToId: replyTo,
timestamp: wireSentAt(content['ts']),
isDelivered: true,
attachment: meta,
disappearAfterSeconds: ttl,
);
}
final isNew = await storage.addMessage(convId, message);
// Uloženo na disku → teprve teď smí relay obálku zahodit.
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
// neúspěchu má UI retry, ACK na něj nečeká.)
_relay!.ack(envelope.msgUuid);
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
// doručenku pošli — protistrana ji nejspíš právě proto nedostala.
if (!isNew) {
unawaited(_sendControl(contact, {
't': 'dlv',
'ref': message.id,
'gid': ?gid,
}));
return;
}
onUpdate?.call();
onMessageReceived?.call(contact, message);
// Pošli odesílateli doručenku (✓✓) — best-effort, nesmí blokovat
// příjem ani ACK. Jen pro skutečné zprávy (txt/att), ne pro control.
// Ve skupině nese gid, aby ji odesílatel spároval se skupinovou
// konverzací.
unawaited(_sendControl(contact, {
't': 'dlv',
'ref': message.id,
'gid': ?gid,
}));
if (message.attachment != null) {
unawaited(downloadAttachment(convId, message));
}
} on FormatException {
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid);
} on TypeError {
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
// trvale vadné, ACK a zahodit.
_relay?.ack(envelope.msgUuid);
} catch (_) {
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
// pravděpodobně selže. Trvalé chyby obsahu řeší větve výše.
}
}
/// Čas odeslání z drátu (ms). Bez něj by zpráva dostala čas doručení —
/// po vyzvednutí offline fronty by celá konverzace uskočila na „teď"
/// a každý člen skupiny by u téže zprávy viděl jiný čas.
///
/// Klienti ≤1.1.1 `ts` neposílají → fallback na čas doručení (chová se
/// jako dřív). Odesílatel si čas diktuje sám (jde E2E kanálem), proto
/// strop: hodiny zařízení se běžně rozcházejí o minuty, ale zpráva
/// nesmí uskočit mimo konverzaci.
@visibleForTesting
static DateTime wireSentAt(Object? raw) {
final now = DateTime.now();
if (raw is! int) return now;
final sent = DateTime.fromMillisecondsSinceEpoch(raw);
if (sent.isAfter(now.add(const Duration(minutes: 5)))) return now;
if (sent.isBefore(DateTime(2020))) return now;
return sent;
}
/// Aplikuje příchozí reakci/edit/delete na cílovou zprávu v dané
/// konverzaci (1:1 = kontakt, skupina = gid). Klíč reakce = fingerprint
/// odesílatele (kryptograficky ověřený sealed senderem, ne
/// self-declared).
Future<void> _applyIncomingControl(
String convId,
String type,
Map<String, dynamic> content,
SepotSealedMessage opened,
) async {
final ref = content['ref'] as String?;
if (ref == null) return;
final senderFp =
SepotFingerprint.ofPublicKey(opened.senderIdentityKey).display;
await _mutateLocalMessage(convId, ref, (m) {
switch (type) {
case 'rx':
final emoji = content['e'] as String?;
final r = Map<String, String>.from(m.reactions);
if (emoji == null || emoji.isEmpty) {
r.remove(senderFp);
} else {
r[senderFp] = emoji;
}
return m.copyWith(reactions: r);
case 'edit':
// Upravit smí jen autor zprávy (odesílatel controlu = autor)
if (m.senderId != senderFp) return m;
return m.copyWith(
content: content['txt'] as String? ?? m.content,
editedAt: DateTime.now());
case 'del':
if (m.senderId != senderFp) return m;
return m.copyWith(
isDeleted: true, content: '', reactions: const {});
default:
return m;
}
});
}
// ── Přílohy: download + čtení ───────────────────────────────────────
/// Stáhne blob přílohy, ověří digest a uloží do lokálního store.
/// Idempotentní; při selhání nastaví stav failed (UI nabídne retry).
Future<void> downloadAttachment(String contactId, Message message) async {
final meta = message.attachment;
final relay = _relay;
if (meta == null || relay == null) return;
if (meta.state == AttachmentTransferState.downloading) return;
if (await _attachments.exists(meta.blobId)) {
await _setAttachmentState(
contactId, message.id, AttachmentTransferState.ready);
return;
}
await _setAttachmentState(
contactId, message.id, AttachmentTransferState.downloading);
try {
final blob = await relay.downloadAttachment(meta.blobId);
final digest = await AttachmentCipher.sha256(blob);
if (!_listEquals(digest, meta.digest)) {
throw const AttachmentIntegrityException('digest mismatch');
}
await _attachments.save(meta.blobId, blob);
await _setAttachmentState(
contactId, message.id, AttachmentTransferState.ready);
} catch (_) {
await _setAttachmentState(
contactId, message.id, AttachmentTransferState.failed);
}
}
/// Dešifruje přílohu do paměti (na disk plaintext nejde). Null = blob
/// ještě není stažený.
Future<Uint8List?> loadDecryptedAttachment(Message message) async {
final meta = message.attachment;
if (meta == null) return null;
final blob = await _attachments.load(meta.blobId);
if (blob == null) return null;
return AttachmentCipher.decrypt(
blob: blob,
key: meta.key,
nonce: meta.nonce,
expectedDigest: meta.digest,
);
}
/// Stav se přepisuje nad ČERSTVOU kopií zprávy z DB (mezitím mohla
/// dostat doručenku apod.), ne nad snapshotem volajícího.
Future<void> _setAttachmentState(
String contactId,
String messageId,
AttachmentTransferState state,
) async {
final conv = await storage.loadConversation(contactId);
if (conv == null) return;
final index = conv.messages.indexWhere((m) => m.id == messageId);
if (index < 0) return;
final current = conv.messages[index];
final meta = current.attachment;
if (meta == null || meta.state == state) return;
await storage.updateMessage(
contactId,
current.copyWith(attachment: meta.copyWith(state: state)),
);
onUpdate?.call();
}
/// Odesílatel se hledá podle FINGERPRINTU jeho identity klíče
/// (kryptograficky ověřen sealed senderem) — nikdy podle jména.
Future<Contact> _findOrCreateContact(
SepotSealedMessage opened,
Map<String, dynamic> content,
) async {
final senderFp =
SepotFingerprint.ofPublicKey(opened.senderIdentityKey).display;
final contacts = await storage.loadContacts();
final profile = content['p'] as Map<String, dynamic>?;
final profileDa = profile?['da'] as String?;
for (final c in contacts) {
if (c.publicIdentity.fingerprint.display != senderFp) continue;
// Doplnit/aktualizovat routing (accountId poprvé, rotace adresy…)
final known = c.publicIdentity;
if (known.accountId != opened.senderName ||
(profileDa != null &&
(known.deliveryAddress == null ||
base64Encode(known.deliveryAddress!) != profileDa))) {
final updated = Contact(
displayName: c.displayName,
publicIdentity: known.withRouting(
accountId: opened.senderName,
deliveryAddress: profileDa != null
? base64Decode(profileDa)
: known.deliveryAddress ?? Uint8List(0),
),
addedAt: c.addedAt,
verified: c.verified,
notes: c.notes,
);
await storage.saveContact(updated);
return updated;
}
return c;
}
// Neznámý odesílatel → nový (neověřený) kontakt
// Prázdné jméno = UI si doplní lokalizovaný fallback
final name = (profile?['n'] as String?)?.trim();
final identity = PublicIdentity(
displayName: (name == null || name.isEmpty) ? '' : name,
identityKey: opened.senderIdentityKey,
createdAt: DateTime.now(),
accountId: opened.senderName,
deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
);
final contact = Contact.fromPublicIdentity(identity);
await storage.saveContact(contact);
return contact;
}
// ── Sdílení vlastního kontaktu ──────────────────────────────────────
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
PublicIdentity? get shareableIdentity {
final identity = _identity;
final aid = accountId;
final da = myDeliveryAddress;
if (identity == null) return null;
if (aid == null || da == null) return identity.publicIdentity;
return identity.publicIdentity
.withRouting(accountId: aid, deliveryAddress: da);
}
/// Zaregistruje push endpoint u relaye (volá push vrstva po registraci
/// u UnifiedPush distributora). Bezpečné i před připojením — odešle se
/// po AUTH. platform=NONE push vypne.
void registerPush(pb.RegisterPush registration) {
_lastPush = registration;
_relay?.registerPush(registration);
}
/// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
void onPushWake() {
_relay?.start();
}
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
/// Best-effort: offline nebo bez běžícího spojení se smazání na serveru
/// nepovede — lokální wipe má proběhnout i tak, proto vrací jen úspěch
/// místo výjimky. Volat PŘED `SecureStorageService.deleteAll()` (podpis
/// požadavku potřebuje identity klíč).
Future<bool> deleteRelayAccount() async {
final relay = _relay;
if (relay == null) return false;
try {
await relay.deleteIdentity();
return true;
} catch (_) {
// Offline / server nedostupný — lokální wipe proběhne i tak,
// UI o neúspěchu na serveru informuje.
return false;
}
}
Future<void> stop() async {
_registerRetryTimer?.cancel();
_purgeTimer?.cancel();
_purgeTimer = null;
await _deliverSub?.cancel();
await _stateSub?.cancel();
await _relay?.dispose();
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
_db = null;
_crypto = null;
_relay = null;
_identity = null;
_senderCert = null;
_trustRoot = null;
}
static bool _listEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}