5246a97db2
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.
2653 lines
96 KiB
Dart
2653 lines
96 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:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
import 'package:libsignal/libsignal.dart'
|
|
show IdentityKeyPair, PrivateKey, 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/crypto/group_admin.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/group_admin_policy.dart';
|
|
import 'package:sepot/core/services/image_processor.dart';
|
|
import 'package:sepot/core/services/typing_tracker.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/avatar_ref.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/models/poll.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í.
|
|
/// `convId` = gid u skupiny, jinak id kontaktu — podle něj se řídí
|
|
/// ztlumení (ztlumená skupina ≠ ztlumený chat s autorem).
|
|
void Function(Contact contact, Message message, String convId)?
|
|
onMessageReceived;
|
|
|
|
/// TURN údaje pro hovor (null = relay je nevydal, zkusí se jen STUN).
|
|
Future<pb.TurnCredentials?> turnCredentials() async {
|
|
final relay = _relay;
|
|
if (relay == null || relay.state != RelayConnectionState.connected) {
|
|
return null;
|
|
}
|
|
try {
|
|
return await relay.requestTurn();
|
|
} on TimeoutException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Příchozí signalizace hovoru (nabídka, odpověď, zavěšení…).
|
|
void Function(Contact contact, Map<String, dynamic> signal)? onCallSignal;
|
|
|
|
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;
|
|
|
|
// ── „píše…“ ────────────────────────────────────────────────────────
|
|
//
|
|
// Stejně jako potvrzení přečtení: volitelné, výchozí vypnuto, reciproční.
|
|
// Jde šifrovaným kanálem jako obálka EPHEMERAL — relay ji doručí jen
|
|
// živému spojení, offline příjemce se nebudí a nic se neukládá.
|
|
|
|
static const typingSettingKey = 'typing_indicators_enabled';
|
|
|
|
/// Nejvýš jedno „píšu“ za tuhle dobu na konverzaci.
|
|
static const _typingThrottle = Duration(seconds: 4);
|
|
|
|
/// Po DROPPED (příjemce offline) chvíli nic neposílat: každá zahozená
|
|
/// obálka posune ratchet a příjemce by pak mezeru musel přeskakovat.
|
|
static const _typingBackoff = Duration(minutes: 1);
|
|
|
|
/// Ve větší skupině by každé „píšu“ znamenalo desítky obálek.
|
|
static const _typingMaxGroup = 20;
|
|
|
|
final typing = TypingTracker();
|
|
final _typingSentAt = <String, DateTime>{};
|
|
final _typingBackoffUntil = <String, DateTime>{};
|
|
|
|
Future<bool> _typingEnabled() async =>
|
|
(await storage.getSetting<bool>(typingSettingKey)) ?? false;
|
|
|
|
/// Dá protistraně (členům skupiny) vědět, že píšu. Volat při psaní —
|
|
/// omezení frekvence řeší metoda sama. Best-effort, nikdy nehází.
|
|
Future<void> sendTyping(String convId) async {
|
|
if (!isStarted || !await _typingEnabled()) return;
|
|
final now = DateTime.now();
|
|
final last = _typingSentAt[convId];
|
|
if (last != null && now.difference(last) < _typingThrottle) return;
|
|
_typingSentAt[convId] = now;
|
|
|
|
final group = await storage.loadGroup(convId);
|
|
if (group != null &&
|
|
(group.left || group.memberIds.length > _typingMaxGroup)) {
|
|
return;
|
|
}
|
|
final contacts = await storage.loadContacts();
|
|
final targets = contacts.where((c) =>
|
|
!c.blocked &&
|
|
(group == null
|
|
? c.id == convId
|
|
: group.memberIds.contains(c.id) && c.id != _identity?.id));
|
|
for (final c in targets) {
|
|
final until = _typingBackoffUntil[c.id];
|
|
if (until != null && until.isAfter(now)) continue;
|
|
unawaited(_sendTypingTo(c, group?.id));
|
|
}
|
|
}
|
|
|
|
Future<void> _sendTypingTo(Contact contact, String? gid) async {
|
|
try {
|
|
final status = await _sendSealed(
|
|
contact,
|
|
{
|
|
'v': 1,
|
|
'id': const Uuid().v4(),
|
|
't': 'typ',
|
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
|
'gid': ?gid,
|
|
},
|
|
ephemeral: true,
|
|
);
|
|
if (status == pb.Receipt_Status.STATUS_DROPPED) {
|
|
_typingBackoffUntil[contact.id] =
|
|
DateTime.now().add(_typingBackoff);
|
|
} else {
|
|
_typingBackoffUntil.remove(contact.id);
|
|
}
|
|
} catch (_) {
|
|
// „píše…“ je jen ozdoba — výpadek sítě nesmí nic rozbít ani hlásit.
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
|
|
_myAvatar = AvatarRef.fromJson(
|
|
await storage.getSetting<Map<String, dynamic>>(_myAvatarKey));
|
|
|
|
// 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());
|
|
unawaited(_refreshMyAvatarIfOld());
|
|
}
|
|
});
|
|
|
|
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());
|
|
unawaited(_refreshMyAvatarIfOld());
|
|
}
|
|
});
|
|
|
|
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í ────────────────────────────────────────────────────────
|
|
|
|
/// S [poll] je [text] záložní podoba ankety pro starší appky.
|
|
Future<Message> sendText(Contact contact, String text,
|
|
{String? replyToId, Poll? poll}) 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,
|
|
poll: poll,
|
|
);
|
|
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,
|
|
'poll': ?poll?.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 (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,
|
|
bool upload = true,
|
|
}) async {
|
|
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 = upload
|
|
? await _relay!.uploadAttachment(encrypted.blob)
|
|
: _localBlobId();
|
|
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,
|
|
);
|
|
}
|
|
|
|
/// Příloha poznámky pro sebe: stejné zpracování i šifrování jako při
|
|
/// odeslání, ale blob zůstane jen v telefonu — na relay nemá co dělat.
|
|
Future<AttachmentMeta> prepareNoteAttachment({
|
|
required Uint8List bytes,
|
|
required String fileName,
|
|
String? mime,
|
|
int? durationMs,
|
|
}) =>
|
|
_prepareAttachment(
|
|
bytes: bytes,
|
|
fileName: fileName,
|
|
mime: mime,
|
|
durationMs: durationMs,
|
|
upload: false,
|
|
);
|
|
|
|
/// Stejný tvar jako id blobu na relayi (32 hex), aby úložiště příloh
|
|
/// nemuselo rozlišovat původ.
|
|
String _localBlobId() {
|
|
final rnd = Random.secure();
|
|
return [
|
|
for (var i = 0; i < 16; i++)
|
|
rnd.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
|
].join();
|
|
}
|
|
|
|
/// 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)),
|
|
// Profil BEZ 'av' = fotku jsem odebral (protistrana ji smaže).
|
|
'av': ?_myAvatar?.toWire(),
|
|
};
|
|
}
|
|
|
|
// ── Profilová fotka ─────────────────────────────────────────────────
|
|
|
|
static const _myAvatarKey = 'my_avatar';
|
|
|
|
/// Blob na relayi žije 30 dní — s týdenní rezervou se nahraje znovu,
|
|
/// aby ho stáhli i lidé, kteří mi napíšou poprvé až za měsíc.
|
|
static const _avatarRefreshAfter = Duration(days: 23);
|
|
|
|
AvatarRef? _myAvatar;
|
|
final _avatarCache = <String, Uint8List>{};
|
|
|
|
AvatarRef? get myAvatar => _myAvatar;
|
|
|
|
/// Nastaví moji fotku ([jpeg] už zmenšený a bez metadat). Protistrany
|
|
/// se o ní dozví s další zprávou (odkaz jede v profilu).
|
|
Future<void> setMyAvatar(Uint8List jpeg) async {
|
|
final relay = _relay ?? (throw StateError('Service not started'));
|
|
final encrypted = await AttachmentCipher.encrypt(jpeg);
|
|
final blobId = await relay.uploadAttachment(encrypted.blob);
|
|
await _attachments.save(blobId, encrypted.blob);
|
|
final old = _myAvatar;
|
|
_myAvatar = AvatarRef(
|
|
blobId: blobId,
|
|
key: encrypted.key,
|
|
nonce: encrypted.nonce,
|
|
digest: encrypted.digest,
|
|
uploadedAt: DateTime.now(),
|
|
);
|
|
await storage.setSetting(_myAvatarKey, _myAvatar!.toJson());
|
|
_avatarCache[blobId] = jpeg;
|
|
if (old != null) await _forgetAvatar(old);
|
|
onUpdate?.call();
|
|
}
|
|
|
|
Future<void> removeMyAvatar() async {
|
|
final old = _myAvatar;
|
|
if (old == null) return;
|
|
_myAvatar = null;
|
|
await storage.setSetting(_myAvatarKey, null);
|
|
await _forgetAvatar(old);
|
|
onUpdate?.call();
|
|
}
|
|
|
|
Future<void> _forgetAvatar(AvatarRef ref) async {
|
|
_avatarCache.remove(ref.blobId);
|
|
await _attachments.delete(ref.blobId);
|
|
}
|
|
|
|
/// Znovu nahraje TENTÝŽ šifrovaný blob pod novým id (klíč se nemění),
|
|
/// než na relayi vyprší. Best-effort — zkusí se při dalším připojení.
|
|
Future<void> _refreshMyAvatarIfOld() async {
|
|
final ref = _myAvatar;
|
|
final relay = _relay;
|
|
if (ref == null || relay == null) return;
|
|
final uploaded = ref.uploadedAt;
|
|
if (uploaded != null &&
|
|
DateTime.now().difference(uploaded) < _avatarRefreshAfter) {
|
|
return;
|
|
}
|
|
try {
|
|
final blob = await _attachments.load(ref.blobId);
|
|
if (blob == null) return;
|
|
final blobId = await relay.uploadAttachment(blob);
|
|
await _attachments.save(blobId, blob);
|
|
_myAvatar = AvatarRef(
|
|
blobId: blobId,
|
|
key: ref.key,
|
|
nonce: ref.nonce,
|
|
digest: ref.digest,
|
|
uploadedAt: DateTime.now(),
|
|
);
|
|
await storage.setSetting(_myAvatarKey, _myAvatar!.toJson());
|
|
await _attachments.delete(ref.blobId);
|
|
} catch (_) {
|
|
// Bez sítě / relay ještě nezná zařízení — zkusí se příště.
|
|
}
|
|
}
|
|
|
|
/// Stáhne fotku kontaktu, ověří velikost a digest a uloží ji šifrovanou.
|
|
Future<void> _downloadAvatar(AvatarRef ref) async {
|
|
final relay = _relay;
|
|
if (relay == null || await _attachments.exists(ref.blobId)) return;
|
|
try {
|
|
final blob = await relay.downloadAttachment(ref.blobId);
|
|
if (blob.length > AvatarRef.maxBlobBytes) return;
|
|
final digest = await AttachmentCipher.sha256(blob);
|
|
if (!_listEquals(digest, ref.digest)) return;
|
|
await _attachments.save(ref.blobId, blob);
|
|
onUpdate?.call();
|
|
} catch (_) {
|
|
// Blob vypršel nebo síť selhala — ukáží se iniciály, další profil
|
|
// (s obnoveným blobem) to zkusí znovu.
|
|
}
|
|
}
|
|
|
|
/// Dešifrovaná fotka pro UI (null = není stažená / vadná → iniciály).
|
|
Future<Uint8List?> loadAvatar(AvatarRef ref) async {
|
|
final cached = _avatarCache[ref.blobId];
|
|
if (cached != null) return cached;
|
|
final blob = await _attachments.load(ref.blobId);
|
|
if (blob == null) {
|
|
unawaited(_downloadAvatar(ref));
|
|
return null;
|
|
}
|
|
try {
|
|
final bytes = await AttachmentCipher.decrypt(
|
|
blob: blob,
|
|
key: ref.key,
|
|
nonce: ref.nonce,
|
|
expectedDigest: ref.digest,
|
|
);
|
|
_avatarCache[ref.blobId] = bytes;
|
|
return bytes;
|
|
} on Object {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── 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. Ve skupině
|
|
/// (`gid`) jde jen autorovi zpráv, ne všem členům.
|
|
Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
|
|
{String? gid}) 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, 'gid': ?gid});
|
|
}
|
|
|
|
/// 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(),
|
|
// Čas vzniku, ne doručení — potvrzení přečtení z offline telefonu
|
|
// má ukázat, kdy si to člověk přečetl, ne kdy se připojil.
|
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
|
...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)
|
|
}
|
|
}
|
|
|
|
/// Signalizace hovoru (`t:'call'`). Na rozdíl od ostatních
|
|
/// řídicích zpráv chybu nepolyká — hovor, který neodešel, musí UI ukončit.
|
|
/// [ring] = nabídka: relay offline příjemce budí VoIP pushem.
|
|
Future<pb.Receipt_Status?> sendCallSignal(
|
|
Contact contact,
|
|
Map<String, dynamic> signal, {
|
|
bool ring = false,
|
|
}) {
|
|
final content = {
|
|
'v': 1,
|
|
'id': const Uuid().v4(),
|
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
|
't': 'call',
|
|
...signal,
|
|
};
|
|
final tap = debugSendTap;
|
|
if (tap != null) {
|
|
tap(contact.id, content);
|
|
return Future.value(pb.Receipt_Status.STATUS_DELIVERED_ONLINE);
|
|
}
|
|
return _sendSealed(contact, content, ring: ring);
|
|
}
|
|
|
|
/// 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) ─────────────────────────────────────────
|
|
|
|
static const _groupControlTypes = {'ginv', 'gleave', 'gmeta', 'gop', 'gsync'};
|
|
//
|
|
// 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). Pozvánka
|
|
/// nese i podepsaný stav epochy 0, takže v2 členové znají správce hned;
|
|
/// v1 členové použijí samotný `ginv`.
|
|
/// 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();
|
|
|
|
final genesis = GroupAdminOp(
|
|
gid: group.id,
|
|
epoch: 0,
|
|
action: GroupAdminAction.add,
|
|
actor: _myFingerprint,
|
|
members: group.memberIds,
|
|
admins: group.admins,
|
|
name: group.name,
|
|
prev: Uint8List(0),
|
|
);
|
|
await _fanoutInvite(group, op: _signOp(genesis));
|
|
return group;
|
|
}
|
|
|
|
/// Přidá členy: rozšíří roster a rozešle nový úplný stav VŠEM členům
|
|
/// (noví z něj poprvé uvidí skupinu). Správce přidává podepsanou
|
|
/// operací; ne-správce jen v kompatibilním režimu starým `ginv`, jak to
|
|
/// v1 klienti ve skupině stejně umějí.
|
|
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);
|
|
}
|
|
if (group.isAdmin(_myFingerprint)) {
|
|
await _publishAdminOp(
|
|
group,
|
|
buildGroupOp(
|
|
group: group,
|
|
me: _myFingerprint,
|
|
action: GroupAdminAction.add,
|
|
members: ids.toList(),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (group.isEnforced(_myFingerprint)) throw const NotGroupAdminError();
|
|
group.memberIds = ids.toList();
|
|
await storage.saveGroup(group);
|
|
onUpdate?.call();
|
|
await _fanoutInvite(group);
|
|
}
|
|
|
|
/// Odebere člena (jen správce). Odebraný dostane výsledný stav taky —
|
|
/// v2 klient se z něj dozví, že ve skupině už není.
|
|
Future<void> removeGroupMember(Group group, String fingerprint) async {
|
|
if (fingerprint == _myFingerprint) return leaveGroup(group);
|
|
await _publishAdminOp(
|
|
group,
|
|
buildGroupOp(
|
|
group: group,
|
|
me: _myFingerprint,
|
|
action: GroupAdminAction.remove,
|
|
members: group.memberIds.where((f) => f != fingerprint).toList(),
|
|
admins: group.admins.where((f) => f != fingerprint).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Udělí nebo odebere roli správce (jen správce). Sebe smí správce
|
|
/// degradovat, jen když zůstane jiný správce.
|
|
Future<void> setGroupAdmin(
|
|
Group group, String fingerprint, bool admin) async {
|
|
if (!group.isMember(fingerprint)) return;
|
|
if (group.isAdmin(fingerprint) == admin) return;
|
|
await _publishAdminOp(
|
|
group,
|
|
buildGroupOp(
|
|
group: group,
|
|
me: _myFingerprint,
|
|
action: admin ? GroupAdminAction.promote : GroupAdminAction.demote,
|
|
admins: admin
|
|
? [...group.admins, fingerprint]
|
|
: group.admins.where((f) => f != fingerprint).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Přejmenuje skupinu (jen správce; lokálně hned, členům best-effort).
|
|
Future<void> renameGroup(Group group, String newName) async {
|
|
await _publishAdminOp(
|
|
group,
|
|
buildGroupOp(
|
|
group: group,
|
|
me: _myFingerprint,
|
|
action: GroupAdminAction.rename,
|
|
name: newName.trim(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Opustí skupinu: oznámí členům `gleave`, lokálně označí `left`
|
|
/// (historie zůstává, vstup i příjem jsou mrtvé).
|
|
///
|
|
/// Správce navíc odchod podepíše jako odebrání sebe sama, aby v2 členové
|
|
/// zůstali na stejném řetězu. Kdyby odcházel poslední správce, předá
|
|
/// roli nejmenšímu fingerprintu — tomu, koho by si bez podpisu stejně
|
|
/// vybrali všichni přes Group.normalizeAdmins, takže `gleave` i podepsaná
|
|
/// operace vedou ke stejnému výsledku.
|
|
Future<void> leaveGroup(Group group) async {
|
|
final others = group.memberIds.where((f) => f != _myFingerprint).toList();
|
|
if (group.isAdmin(_myFingerprint) && others.isNotEmpty) {
|
|
var admins = group.admins.where((f) => f != _myFingerprint).toList();
|
|
if (admins.isEmpty) admins = [([...others]..sort()).first];
|
|
await _publishAdminOp(
|
|
group,
|
|
buildGroupOp(
|
|
group: group,
|
|
me: _myFingerprint,
|
|
action: GroupAdminAction.remove,
|
|
members: others,
|
|
admins: admins,
|
|
),
|
|
);
|
|
}
|
|
await _fanoutToGroup(
|
|
group,
|
|
{
|
|
'v': 1,
|
|
't': 'gleave',
|
|
'id': const Uuid().v4(),
|
|
'gid': group.id,
|
|
'p': _profile(),
|
|
},
|
|
recipients: others,
|
|
);
|
|
group.left = true;
|
|
group.memberIds = others;
|
|
group.normalizeAdmins();
|
|
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, Poll? poll, List<String> mentions = const []}) 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,
|
|
poll: poll,
|
|
mentions: mentions,
|
|
);
|
|
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,
|
|
'poll': ?poll?.toWire(),
|
|
if (mentions.isNotEmpty) 'mn': mentions,
|
|
'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;
|
|
}
|
|
}
|
|
|
|
/// Můj hlas v anketě (prázdná volba = hlas zrušen). Lokálně hned,
|
|
/// ostatním jako řídicí zpráva — ve skupině všem členům, ať počty sedí
|
|
/// každému.
|
|
Future<void> votePoll(String convId, String pollId, List<int> choice,
|
|
{Group? group, Contact? contact}) async {
|
|
await _mutateLocalMessage(convId, pollId, (m) {
|
|
final poll = m.poll;
|
|
return poll == null
|
|
? m
|
|
: m.copyWith(poll: poll.withVote(_myFingerprint, choice));
|
|
});
|
|
final control = {'t': 'vote', 'ref': pollId, 'o': choice};
|
|
if (group != null) {
|
|
await _fanoutToGroup(group, {
|
|
'v': 1,
|
|
'id': const Uuid().v4(),
|
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
|
'gid': group.id,
|
|
...control,
|
|
'p': _profile(),
|
|
});
|
|
} else if (contact != null) {
|
|
await _sendControl(contact, control);
|
|
}
|
|
}
|
|
|
|
/// 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ě. S [op] nese
|
|
/// pozvánka i podepsaný stav pro v2 členy (v1 klient pole `gop` ignoruje).
|
|
Future<void> _fanoutInvite(Group group, {GroupOpMessage? op}) 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,
|
|
'gop': ?op?.toWire(),
|
|
'p': _profile(),
|
|
});
|
|
}
|
|
|
|
/// Podepíše operaci mým identity klíčem (XEdDSA, stejný klíč jako AUTH).
|
|
GroupOpMessage _signOp(GroupAdminOp op,
|
|
{List<Map<String, dynamic>>? roster}) {
|
|
final key = PrivateKey.deserialize(
|
|
bytes: _identity!.identityPrivateKey.toList());
|
|
return GroupOpMessage(
|
|
op: op,
|
|
sig: op.signWith(
|
|
(m) => Uint8List.fromList(key.sign(message: m).toList())),
|
|
roster: roster,
|
|
);
|
|
}
|
|
|
|
/// Aplikuje mou správcovskou operaci lokálně a rozešle ji starým i novým
|
|
/// členům (odebraný se tak dozví, že už ve skupině není).
|
|
///
|
|
/// Dokud je ve skupině člen bez v2, jede operace uvnitř legacy `ginv` /
|
|
/// `gmeta` — v1 klient z nich převezme výsledný roster a jméno, v2 klient
|
|
/// ověří vnořený `gop` a stín ignoruje. Jedna obálka místo dvou: nemůže
|
|
/// se stát, že stín dorazí bez operace nebo v opačném pořadí.
|
|
Future<void> _publishAdminOp(Group group, GroupAdminOp op) async {
|
|
final before = {...group.memberIds};
|
|
applyGroupOp(group, op, _myFingerprint);
|
|
await storage.saveGroup(group);
|
|
onUpdate?.call();
|
|
|
|
final recipients = {...before, ...group.memberIds}..remove(_myFingerprint);
|
|
final enforced = group.isEnforced(_myFingerprint);
|
|
final isRename = op.action == GroupAdminAction.rename;
|
|
// Roster potřebuje nový člen (add) a v1 stín `ginv`; `gmeta` ho nenese.
|
|
final roster = (enforced ? op.action == GroupAdminAction.add : !isRename)
|
|
? await _buildRoster(group)
|
|
: null;
|
|
final Map<String, dynamic> content;
|
|
if (enforced) {
|
|
content = {
|
|
'v': 1,
|
|
't': 'gop',
|
|
'id': const Uuid().v4(),
|
|
..._signOp(op, roster: roster).toWire(),
|
|
'p': _profile(),
|
|
};
|
|
} else if (isRename) {
|
|
content = {
|
|
'v': 1,
|
|
't': 'gmeta',
|
|
'id': const Uuid().v4(),
|
|
'gid': group.id,
|
|
'gname': group.name,
|
|
'gop': _signOp(op).toWire(),
|
|
'p': _profile(),
|
|
};
|
|
} else {
|
|
content = {
|
|
'v': 1,
|
|
't': 'ginv',
|
|
'id': const Uuid().v4(),
|
|
'gid': group.id,
|
|
'gname': group.name,
|
|
'roster': roster,
|
|
'gop': _signOp(op).toWire(),
|
|
'p': _profile(),
|
|
};
|
|
}
|
|
await _fanoutToGroup(group, content, recipients: recipients);
|
|
}
|
|
|
|
/// 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);
|
|
|
|
/// Libovolná správa skupiny (`ginv`/`gmeta`/`gleave`/`gop`/`gsync`).
|
|
@visibleForTesting
|
|
Future<void> debugHandleGroupControl(
|
|
String senderFp, Map<String, dynamic> content) =>
|
|
_handleGroupControl(senderFp, content['t'] as String?, content);
|
|
|
|
@visibleForTesting
|
|
Future<Group?> debugGroupForIncoming(String gid, String senderFp) =>
|
|
_groupForIncoming(gid, senderFp);
|
|
|
|
/// Když je nastavený, odchozí obsah místo relaye dostane test
|
|
/// (fingerprint příjemce + content JSON) a odeslání se bere jako úspěšné.
|
|
@visibleForTesting
|
|
void Function(String toFingerprint, Map<String, dynamic> content)?
|
|
debugSendTap;
|
|
|
|
/// Roster = [{f, n, ik, aid, da, v}] 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). `v: 2` = člen inzeroval
|
|
/// podporu správců; v1 klient pole ignoruje.
|
|
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),
|
|
// Před doběhnutím registrace routing nemám — vynechat, ne `!`.
|
|
'aid': ?me.accountId,
|
|
'da': ?(me.deliveryAddress == null
|
|
? null
|
|
: base64Encode(me.deliveryAddress!)),
|
|
'v': groupProtocolVersion,
|
|
});
|
|
|
|
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!),
|
|
if (group.v2Members.contains(fp)) 'v': groupProtocolVersion,
|
|
});
|
|
}
|
|
return roster;
|
|
}
|
|
|
|
/// Verze skupinového protokolu, kterou inzeruju (`gv` v obsahu, `v`
|
|
/// v rosteru). 2 = rozumím správcům a `gop`.
|
|
static const groupProtocolVersion = 2;
|
|
|
|
/// Fanout obsahu členům kromě mě (nebo jen [recipients]). Vrací true,
|
|
/// když aspoň jedno odeslání prošlo (per-member retry v1 není — stejná
|
|
/// hranice jako u 1:1 control zpráv). Každý skupinový obsah nese `gv`,
|
|
/// aby ostatní poznali, kdy už ve skupině žádný v1 klient není.
|
|
Future<bool> _fanoutToGroup(Group group, Map<String, dynamic> content,
|
|
{Iterable<String>? recipients}) async {
|
|
final contacts = await storage.loadContacts();
|
|
final byFp = {for (final c in contacts) c.id: c};
|
|
final payload = {...content, 'gv': groupProtocolVersion};
|
|
var anyOk = false;
|
|
for (final fp in recipients ?? group.memberIds) {
|
|
if (fp == _myFingerprint) continue;
|
|
final c = byFp[fp];
|
|
if (c == null || c.blocked) continue;
|
|
try {
|
|
await _sendContent(c, payload);
|
|
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;
|
|
}
|
|
|
|
/// Zapamatuje si, že člen inzeruje v2 (`gv` je v obsahu každé skupinové
|
|
/// zprávy v2 klienta). Vrací true, když se stav skupiny změnil.
|
|
bool _noteCapability(Group group, String fp, Map<String, dynamic> content) {
|
|
final gv = content['gv'];
|
|
if (gv is! int || gv < groupProtocolVersion) return false;
|
|
if (!group.isMember(fp)) return false;
|
|
return group.v2Members.add(fp);
|
|
}
|
|
|
|
// ── Správa skupin: příjem ───────────────────────────────────────────
|
|
//
|
|
// Pravda o členství/rolích/jménu je řetěz podepsaných operací `gop`.
|
|
// Legacy `ginv`/`gmeta` od ne-správce se bere jen
|
|
// v kompatibilním režimu (ve skupině je aspoň jeden v1 člen) — v1 klient
|
|
// jinou cestu nemá a odmítnutí by skupinu rozdělilo.
|
|
|
|
/// Jak často nejvýš žádat správce o stav jedné skupiny. Chrání před
|
|
/// smyčkou i zahlcením, když správce nemá co poslat nebo je offline.
|
|
static const _groupSyncRequestEvery = Duration(minutes: 10);
|
|
|
|
/// Jak často nejvýš odpovídat stejnému členovi na žádost o stav.
|
|
static const _groupSyncAnswerEvery = Duration(minutes: 1);
|
|
|
|
final _groupSyncRequestedAt = <String, DateTime>{};
|
|
final _groupSyncAnsweredAt = <String, DateTime>{};
|
|
|
|
Future<void> _handleGroupControl(
|
|
String senderFp, String? type, Map<String, dynamic> content) async {
|
|
final gid = content['gid'];
|
|
if (gid is! String || gid.isEmpty || gid.length > 64) return;
|
|
if (type == 'gsync') return _answerGroupSync(senderFp, gid, content);
|
|
|
|
final opRaw = type == 'gop' ? content : content['gop'];
|
|
if (opRaw != null) {
|
|
final msg = GroupOpMessage.fromWire(opRaw);
|
|
final consumed = msg != null &&
|
|
msg.op.gid == gid &&
|
|
await _handleGroupOp(senderFp, msg, content['roster']);
|
|
// Odmítnutý vnořený `gop` nechá promluvit legacy stín: odesílatel je
|
|
// pak posuzován jako kterýkoli v1 člen (kompatibilní režim).
|
|
if (consumed || type == 'gop') return;
|
|
}
|
|
|
|
switch (type) {
|
|
case 'ginv':
|
|
await _handleGroupInvite(senderFp, content);
|
|
case 'gmeta':
|
|
await _handleLegacyMeta(senderFp, gid, content);
|
|
case 'gleave':
|
|
await _handleLeave(senderFp, gid);
|
|
}
|
|
}
|
|
|
|
/// Zpracuje podepsanou operaci. Vrací true, když je tím zpráva vyřízená
|
|
/// (aplikovaná, zastaralá, podržená); false = operace neplatí a legacy
|
|
/// stín se má posoudit podle pravidel kompatibilního režimu.
|
|
Future<bool> _handleGroupOp(
|
|
String senderFp, GroupOpMessage msg, Object? outerRoster) async {
|
|
final local = await storage.loadGroup(msg.op.gid);
|
|
if (local == null) return _joinFromOp(senderFp, msg, outerRoster);
|
|
// Opuštěná skupina se znovu neotevírá (stejně jako v1 pozvánka).
|
|
if (local.left) return true;
|
|
|
|
final keys = await _identityKeysByFp();
|
|
final verdict = checkGroupOp(
|
|
local: local,
|
|
msg: msg,
|
|
senderFp: senderFp,
|
|
me: _myFingerprint,
|
|
actorIdentityKey: keys[msg.op.actor],
|
|
);
|
|
switch (verdict) {
|
|
case GopVerdict.apply:
|
|
await _applyVerifiedOp(local, msg, senderFp, outerRoster);
|
|
return true;
|
|
case GopVerdict.stale:
|
|
return true;
|
|
case GopVerdict.gap:
|
|
await _holdAndResync(local, senderFp, msg, outerRoster);
|
|
return true;
|
|
case GopVerdict.fork:
|
|
// Operace navazuje na stav, který nemám — nejde ji podržet (nikdy
|
|
// nenaváže). Stav od správce s vyšší epochou ji stejně obsahuje.
|
|
local.needsResync = true;
|
|
await storage.saveGroup(local);
|
|
onUpdate?.call();
|
|
await _requestGroupSync(local, senderFp);
|
|
return true;
|
|
case GopVerdict.notAdmin when msg.op.epoch > local.epoch + 1:
|
|
// Z budoucnosti: podepisující mohl správcem (nebo i členem) být
|
|
// jmenován v epoše, která mi chybí. Neaplikovat, ale ani nezahodit.
|
|
await _holdAndResync(local, senderFp, msg, outerRoster);
|
|
return true;
|
|
case GopVerdict.notAdmin:
|
|
case GopVerdict.badSignature:
|
|
case GopVerdict.spoof:
|
|
case GopVerdict.invalid:
|
|
local.rejectedOpAt = DateTime.now();
|
|
await storage.saveGroup(local);
|
|
onUpdate?.call();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<Map<String, Uint8List>> _identityKeysByFp() async => {
|
|
for (final c in await storage.loadContacts())
|
|
c.id: c.publicIdentity.identityKey,
|
|
};
|
|
|
|
Future<void> _applyVerifiedOp(Group group, GroupOpMessage msg,
|
|
String senderFp, Object? outerRoster) async {
|
|
applyGroupOp(group, msg.op, _myFingerprint);
|
|
group.v2Members.add(senderFp);
|
|
await _absorbOpRoster(group, msg, outerRoster);
|
|
await _drainPendingOps(group);
|
|
group.needsResync = group.pendingOps.isNotEmpty;
|
|
await storage.saveGroup(group);
|
|
onUpdate?.call();
|
|
}
|
|
|
|
/// Z rosteru ověřené operace založí kontakty nových členů (stejná TOFU
|
|
/// pravidla jako `ginv`) a převezme jejich inzerovanou v2 — tu tu ručí
|
|
/// podepsaný správce.
|
|
Future<void> _absorbOpRoster(
|
|
Group group, GroupOpMessage msg, Object? outerRoster) async {
|
|
final roster = _validRosterEntries(msg.roster ?? outerRoster);
|
|
roster.removeWhere((f, _) => !group.isMember(f));
|
|
await _ingestRoster(roster.values);
|
|
for (final e in roster.values) {
|
|
if (e['v'] == groupProtocolVersion) group.v2Members.add(e['f'] as String);
|
|
}
|
|
}
|
|
|
|
Future<void> _holdAndResync(Group group, String senderFp,
|
|
GroupOpMessage msg, Object? outerRoster) async {
|
|
final roster = msg.roster ??
|
|
(outerRoster is List
|
|
? [
|
|
for (final e in outerRoster)
|
|
if (e is Map) e.cast<String, dynamic>(),
|
|
]
|
|
: null);
|
|
holdGroupOp(
|
|
group,
|
|
PendingGroupOp(
|
|
senderFp: senderFp,
|
|
wire: GroupOpMessage(op: msg.op, sig: msg.sig, roster: roster)
|
|
.toWire(),
|
|
),
|
|
);
|
|
group.needsResync = true;
|
|
await storage.saveGroup(group);
|
|
onUpdate?.call();
|
|
await _requestGroupSync(group, senderFp);
|
|
}
|
|
|
|
/// Zkusí podržené operace znovu po posunu stavu. Každá se ověřuje celá
|
|
/// znovu (správci se mezitím mohli změnit).
|
|
Future<void> _drainPendingOps(Group group) async {
|
|
var progressed = true;
|
|
while (progressed && group.pendingOps.isNotEmpty) {
|
|
progressed = false;
|
|
final keys = await _identityKeysByFp();
|
|
for (final p in [...group.pendingOps]) {
|
|
final msg = GroupOpMessage.fromWire(p.wire);
|
|
final verdict = msg == null
|
|
? GopVerdict.invalid
|
|
: checkGroupOp(
|
|
local: group,
|
|
msg: msg,
|
|
senderFp: p.senderFp,
|
|
me: _myFingerprint,
|
|
actorIdentityKey: keys[msg.op.actor],
|
|
);
|
|
final stillFuture = verdict == GopVerdict.gap ||
|
|
(verdict == GopVerdict.notAdmin &&
|
|
msg!.op.epoch > group.epoch + 1);
|
|
if (stillFuture) continue;
|
|
group.pendingOps = group.pendingOps.where((x) => x != p).toList();
|
|
if (verdict == GopVerdict.apply) {
|
|
applyGroupOp(group, msg!.op, _myFingerprint);
|
|
group.v2Members.add(p.senderFp);
|
|
await _absorbOpRoster(group, msg, null);
|
|
progressed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Požádá správce (a odesílatele operace, která nenavázala) o aktuální
|
|
/// podepsaný stav. Omezené frekvencí — odpověď žádost nikdy nevyvolá,
|
|
/// takže smyčka nevznikne.
|
|
Future<void> _requestGroupSync(Group group, String hintFp) async {
|
|
final now = DateTime.now();
|
|
final last = _groupSyncRequestedAt[group.id];
|
|
if (last != null && now.difference(last) < _groupSyncRequestEvery) return;
|
|
_groupSyncRequestedAt[group.id] = now;
|
|
await _fanoutToGroup(
|
|
group,
|
|
{
|
|
'v': 1,
|
|
't': 'gsync',
|
|
'id': const Uuid().v4(),
|
|
'gid': group.id,
|
|
'ep': group.epoch,
|
|
'p': _profile(),
|
|
},
|
|
recipients: {...group.admins, if (group.isMember(hintFp)) hintFp},
|
|
);
|
|
}
|
|
|
|
/// Odpověď správce na žádost o stav: můj aktuální stav podepsaný jako
|
|
/// `sync`. Jen členovi, jen když jsem opravdu dál, a jen jednou za chvíli.
|
|
Future<void> _answerGroupSync(
|
|
String senderFp, String gid, Map<String, dynamic> content) async {
|
|
final group = await _groupForIncoming(gid, senderFp);
|
|
if (group == null || !group.isAdmin(_myFingerprint)) return;
|
|
final theirs = content['ep'];
|
|
if (theirs is! int || theirs >= group.epoch) return;
|
|
|
|
final key = '$gid|$senderFp';
|
|
final now = DateTime.now();
|
|
final last = _groupSyncAnsweredAt[key];
|
|
if (last != null && now.difference(last) < _groupSyncAnswerEvery) return;
|
|
_groupSyncAnsweredAt[key] = now;
|
|
|
|
final snapshot = _signOp(
|
|
buildGroupOp(
|
|
group: group,
|
|
me: _myFingerprint,
|
|
action: GroupAdminAction.sync,
|
|
),
|
|
roster: await _buildRoster(group),
|
|
);
|
|
await _fanoutToGroup(
|
|
group,
|
|
{
|
|
'v': 1,
|
|
't': 'gop',
|
|
'id': const Uuid().v4(),
|
|
...snapshot.toWire(),
|
|
'p': _profile(),
|
|
},
|
|
recipients: [senderFp],
|
|
);
|
|
}
|
|
|
|
/// Pozvánka do skupiny, kterou ještě neznám, s podepsaným stavem. Klíč
|
|
/// podepisujícího: z mých kontaktů, jinak z rosteru (fingerprint ověřený
|
|
/// proti klíči — stejná důvěra jako u v1 pozvánky).
|
|
Future<bool> _joinFromOp(
|
|
String senderFp, GroupOpMessage msg, Object? outerRoster) async {
|
|
final op = msg.op;
|
|
final roster = _validRosterEntries(msg.roster ?? outerRoster);
|
|
final keys = await _identityKeysByFp();
|
|
final rosterIk = roster[op.actor]?['ik'] as String?;
|
|
final verdict = checkJoinOp(
|
|
msg: msg,
|
|
senderFp: senderFp,
|
|
me: _myFingerprint,
|
|
actorIdentityKey:
|
|
keys[op.actor] ?? (rosterIk == null ? null : base64Decode(rosterIk)),
|
|
);
|
|
if (verdict != GopVerdict.apply) return false;
|
|
|
|
roster.removeWhere((f, _) => !op.members.contains(f));
|
|
await _ingestRoster(roster.values);
|
|
final group = Group(
|
|
id: op.gid,
|
|
name: op.name,
|
|
memberIds: op.members,
|
|
createdBy: op.actor,
|
|
admins: op.admins,
|
|
epoch: op.epoch,
|
|
v2Members: {
|
|
senderFp,
|
|
for (final e in roster.values)
|
|
if (e['v'] == groupProtocolVersion) e['f'] as String,
|
|
},
|
|
);
|
|
await storage.saveGroup(group);
|
|
await storage.ensureConversation(group.id);
|
|
onUpdate?.call();
|
|
return true;
|
|
}
|
|
|
|
/// Roster entry, jejichž fingerprint sedí s identity klíčem (anti-spoof);
|
|
/// ostatní se tiše vynechají. Nad strop členů = nic.
|
|
Map<String, Map<String, dynamic>> _validRosterEntries(Object? rosterRaw) {
|
|
final members = <String, Map<String, dynamic>>{};
|
|
if (rosterRaw is! List || rosterRaw.length > Group.maxMembers) {
|
|
return members;
|
|
}
|
|
for (final raw in rosterRaw) {
|
|
if (raw is! Map) continue;
|
|
final e = raw.cast<String, dynamic>();
|
|
final f = e['f'];
|
|
final ikB64 = e['ik'];
|
|
if (f is! String || ikB64 is! String) continue;
|
|
final Uint8List ik;
|
|
try {
|
|
ik = base64Decode(ikB64);
|
|
} on FormatException {
|
|
continue;
|
|
}
|
|
if (SepotFingerprint.ofPublicKey(ik).display != f) continue;
|
|
members[f] = e;
|
|
}
|
|
return members;
|
|
}
|
|
|
|
/// 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).
|
|
Future<void> _ingestRoster(Iterable<Map<String, dynamic>> entries) async {
|
|
final contacts = await storage.loadContacts();
|
|
final byFp = {for (final c in contacts) c.id: c};
|
|
for (final e in entries) {
|
|
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),
|
|
)));
|
|
}
|
|
}
|
|
|
|
/// Zpracuje legacy `ginv` (pozvánka / aktualizace rosteru bez podpisu).
|
|
/// 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.
|
|
///
|
|
/// U známé skupiny jen PŘIDÁVÁ: v1 klient jinou změnu než přidání
|
|
/// neumí, takže chybějící člen v jeho rosteru znamená nanejvýš, že ho
|
|
/// nezná — ne že ho má kdo odebrat. Odebírá jen podepsaný `gop` a `gleave`.
|
|
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;
|
|
|
|
final members = _validRosterEntries(rosterRaw);
|
|
if (!members.containsKey(senderFp)) return;
|
|
if (!members.containsKey(_myFingerprint)) return;
|
|
final senderV2 = members[senderFp]!['v'] == groupProtocolVersion;
|
|
|
|
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;
|
|
if (senderV2) existing.v2Members.add(senderFp);
|
|
_noteCapability(existing, senderFp, content);
|
|
final merged = {...existing.memberIds, ...members.keys};
|
|
if (merged.length > Group.maxMembers) return;
|
|
if (!existing.isAdmin(senderFp) &&
|
|
existing.isEnforced(_myFingerprint)) {
|
|
existing.rejectedOpAt = DateTime.now();
|
|
await storage.saveGroup(existing);
|
|
onUpdate?.call();
|
|
return;
|
|
}
|
|
await _ingestRoster(members.values);
|
|
existing
|
|
..memberIds = merged.toList()
|
|
..name = gname;
|
|
await storage.saveGroup(existing);
|
|
onUpdate?.call();
|
|
return;
|
|
}
|
|
|
|
await _ingestRoster(members.values);
|
|
// Bez podpisu nevím, kdo je skutečný správce — odhad je odesílatel,
|
|
// první podepsaný stav ho opraví (Group.provisional).
|
|
final group = Group(
|
|
id: gid,
|
|
name: gname,
|
|
memberIds: members.keys.toList(),
|
|
createdBy: senderFp,
|
|
provisional: true,
|
|
v2Members: {if (senderV2) senderFp},
|
|
);
|
|
await storage.saveGroup(group);
|
|
await storage.ensureConversation(gid);
|
|
onUpdate?.call();
|
|
}
|
|
|
|
/// Legacy přejmenování (`gmeta` bez podpisu): od správce vždy, od
|
|
/// ostatních jen v kompatibilním režimu.
|
|
Future<void> _handleLegacyMeta(
|
|
String senderFp, String gid, Map<String, dynamic> content) async {
|
|
final group = await _groupForIncoming(gid, senderFp);
|
|
if (group == null) return;
|
|
_noteCapability(group, senderFp, content);
|
|
final gname = (content['gname'] as String?)?.trim();
|
|
if (!group.isAdmin(senderFp) && group.isEnforced(_myFingerprint)) {
|
|
group.rejectedOpAt = DateTime.now();
|
|
} else if (gname != null && gname.isNotEmpty) {
|
|
group.name = gname;
|
|
}
|
|
await storage.saveGroup(group);
|
|
onUpdate?.call();
|
|
}
|
|
|
|
/// Odchod smí oznámit každý sám za sebe; odešel-li poslední správce,
|
|
/// nastoupí náhradník podle Group.normalizeAdmins.
|
|
Future<void> _handleLeave(String senderFp, String gid) async {
|
|
final group = await _groupForIncoming(gid, senderFp);
|
|
if (group == null) return;
|
|
group.memberIds = group.memberIds.where((f) => f != senderFp).toList();
|
|
group.normalizeAdmins();
|
|
await storage.saveGroup(group);
|
|
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 tap = debugSendTap;
|
|
if (tap != null) {
|
|
tap(contact.id, content);
|
|
return true;
|
|
}
|
|
return await _sendSealed(contact, content) ==
|
|
pb.Receipt_Status.STATUS_DELIVERED_ONLINE;
|
|
}
|
|
|
|
/// Zašifruje obsah pro kontakt a pošle obálku. U [ephemeral] se nová
|
|
/// session nezakládá (X3DH kvůli „píše…“ nedává smysl) — bez session
|
|
/// vrací null.
|
|
Future<pb.Receipt_Status?> _sendSealed(
|
|
Contact contact,
|
|
Map<String, dynamic> content, {
|
|
bool ephemeral = false,
|
|
bool ring = false,
|
|
}) 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)) {
|
|
if (ephemeral) return null;
|
|
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,
|
|
hint: ephemeral
|
|
? pb.ContentHint.CONTENT_HINT_EPHEMERAL
|
|
: ring
|
|
? pb.ContentHint.CONTENT_HINT_CALL
|
|
: pb.ContentHint.CONTENT_HINT_DEFAULT,
|
|
);
|
|
return receipt.status;
|
|
}
|
|
|
|
// ── 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', 'typ', 'vote',
|
|
..._groupControlTypes,
|
|
};
|
|
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;
|
|
}
|
|
|
|
// Hovory jen 1:1; skupinový gid tu nemá co dělat.
|
|
// Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru.
|
|
if (type == 'call') {
|
|
if (content['gid'] == null) onCallSignal?.call(contact, content);
|
|
_relay!.ack(envelope.msgUuid);
|
|
return;
|
|
}
|
|
|
|
// Správa skupin: pozvánka/roster, odchod, přejmenování, podepsané
|
|
// operace správců a žádost o jejich stav. Autorita = sealed-sender
|
|
// fingerprint; guardy uvnitř handlerů. ACK vždy (retry z fronty
|
|
// nepomůže).
|
|
if (_groupControlTypes.contains(type)) {
|
|
await _handleGroupControl(senderFp, type, content);
|
|
_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?;
|
|
final group =
|
|
gid == null ? null : await _groupForIncoming(gid, senderFp);
|
|
if (gid != null && group == null) {
|
|
_relay!.ack(envelope.msgUuid);
|
|
return;
|
|
}
|
|
if (group != null && _noteCapability(group, senderFp, content)) {
|
|
await storage.saveGroup(group);
|
|
}
|
|
// Konverzace, do které obsah patří: skupina (gid), jinak 1:1.
|
|
final convId = gid ?? contact.id;
|
|
|
|
// „píše…“: jen když ho sám sdílím a jen čerstvé — zdržené v síti
|
|
// by ukazovalo psaní, které už dávno skončilo.
|
|
if (type == 'typ') {
|
|
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
|
|
TypingTracker.showFor;
|
|
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
|
|
_relay!.ack(envelope.msgUuid);
|
|
return;
|
|
}
|
|
|
|
// Doručenka (✓✓) a potvrzení přečtení: zapíšou čas od daného
|
|
// příjemce. Off-line zprávy se ✓✓ dočkají, až si je protistrana
|
|
// vyzvedne. Potvrzení přečtení respektujeme jen když ho sám sdílím
|
|
// (reciproční, default vypnuto).
|
|
if (type == 'dlv' || type == 'rr') {
|
|
final read = type == 'rr';
|
|
final enabled = !read ||
|
|
((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 = read
|
|
? (content['ids'] as List?)?.whereType<String>() ??
|
|
const <String>[]
|
|
: [?content['ref'] as String?];
|
|
final at = wireSentAt(content['ts']);
|
|
final recipients = group == null
|
|
? 1
|
|
: group.memberIds.where((f) => f != _identity!.id).length;
|
|
for (final id in ids) {
|
|
await _mutateLocalMessage(
|
|
convId,
|
|
id,
|
|
(m) => m.isMine(_identity!.id)
|
|
? applyReceipt(m,
|
|
from: senderFp, at: at, read: read, recipients: recipients)
|
|
: 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' || type == 'vote') {
|
|
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,
|
|
// Vadná anketa = null → ukáže se záložní text, nic se nerozbije.
|
|
poll: Poll.fromWire(content['poll']),
|
|
// Zmínky dávají smysl jen ve skupině; v 1:1 by šly zneužít
|
|
// k obejití ztlumení.
|
|
mentions: gid == null ? const [] : mentionsFromWire(content['mn']),
|
|
);
|
|
} 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);
|
|
// Zpráva dorazila → „píše…“ od autora skončilo.
|
|
typing.clear(convId, senderFp);
|
|
|
|
// 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, convId);
|
|
|
|
// 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.
|
|
}
|
|
}
|
|
|
|
/// Zapíše doručenku/potvrzení přečtení od příjemce `from` do mé zprávy.
|
|
///
|
|
/// Stav nikdy nesestupuje: doručenka poslaná znovu po výpadku spojení
|
|
/// často dorazí až PO potvrzení přečtení a nesmí vrátit „přečteno" na
|
|
/// „doručeno". Ve skupině platí ✓✓ / přečteno až za všechny ostatní
|
|
/// členy (`recipients`); dílčí stav ukáže Info o zprávě. Opakované
|
|
/// potvrzení čas nepřepíše — platí první.
|
|
@visibleForTesting
|
|
static Message applyReceipt(
|
|
Message m, {
|
|
required String from,
|
|
required DateTime at,
|
|
required bool read,
|
|
int recipients = 1,
|
|
}) {
|
|
// Hodiny příjemce se můžou opožďovat — přečtení před odesláním
|
|
// by v Info o zprávě vypadalo jako chyba.
|
|
final when = at.isBefore(m.timestamp) ? m.timestamp : at;
|
|
final deliveredTo = {...m.deliveredTo}..putIfAbsent(from, () => when);
|
|
final readBy =
|
|
read ? ({...m.readBy}..putIfAbsent(from, () => when)) : m.readBy;
|
|
|
|
int rank(MessageStatus s) => switch (s) {
|
|
MessageStatus.sending || MessageStatus.failed => 0,
|
|
MessageStatus.sent => 1,
|
|
MessageStatus.delivered => 2,
|
|
MessageStatus.read => 3,
|
|
};
|
|
// Doručenka sama dokazuje, že zpráva odešla — i když ji odesílatel
|
|
// stihl označit za neodeslanou (timeout před ACK relaye).
|
|
final target = readBy.length >= recipients
|
|
? MessageStatus.read
|
|
: deliveredTo.length >= recipients
|
|
? MessageStatus.delivered
|
|
: MessageStatus.sent;
|
|
final status = rank(target) > rank(m.status) ? target : m.status;
|
|
|
|
return m.copyWith(
|
|
status: status,
|
|
isDelivered: true,
|
|
deliveredTo: deliveredTo,
|
|
readBy: readBy,
|
|
);
|
|
}
|
|
|
|
/// Č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 {});
|
|
case 'vote':
|
|
final poll = m.poll;
|
|
final choice = content['o'];
|
|
if (poll == null || m.isDeleted || choice is! List) return m;
|
|
return m.copyWith(
|
|
poll: poll.withVote(senderFp, choice.whereType<int>().toList()));
|
|
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?;
|
|
// Fotka: jen zprávy s profilem ji mění; profil bez 'av' = odebraná.
|
|
final avatar = AvatarRef.fromWire(profile?['av']);
|
|
|
|
for (final c in contacts) {
|
|
if (c.publicIdentity.fingerprint.display != senderFp) continue;
|
|
// Doplnit/aktualizovat routing (accountId poprvé, rotace adresy…)
|
|
final known = c.publicIdentity;
|
|
final routingChanged = known.accountId != opened.senderName ||
|
|
(profileDa != null &&
|
|
(known.deliveryAddress == null ||
|
|
base64Encode(known.deliveryAddress!) != profileDa));
|
|
final avatarChanged =
|
|
profile != null && avatar?.blobId != c.avatar?.blobId;
|
|
if (!routingChanged && !avatarChanged) return c;
|
|
|
|
final updated = Contact(
|
|
displayName: c.displayName,
|
|
publicIdentity: routingChanged
|
|
? known.withRouting(
|
|
accountId: opened.senderName,
|
|
deliveryAddress: profileDa != null
|
|
? base64Decode(profileDa)
|
|
: known.deliveryAddress ?? Uint8List(0),
|
|
)
|
|
: known,
|
|
addedAt: c.addedAt,
|
|
verified: c.verified,
|
|
notes: c.notes,
|
|
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
|
|
blocked: c.blocked,
|
|
avatar: avatarChanged ? avatar : c.avatar,
|
|
);
|
|
await storage.saveContact(updated);
|
|
if (avatarChanged) {
|
|
final old = c.avatar;
|
|
if (old != null) await _forgetAvatar(old);
|
|
if (avatar != null) unawaited(_downloadAvatar(avatar));
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
// 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)..avatar = avatar;
|
|
await storage.saveContact(contact);
|
|
if (avatar != null) unawaited(_downloadAvatar(avatar));
|
|
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();
|
|
typing.dispose();
|
|
_avatarCache.clear();
|
|
_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;
|
|
}
|
|
}
|