fix(security): treat a new identity key as a message request, never as a known contact
A stranger could pick the display name of an existing contact ("Mom")
and appear as that contact. Messages from a key that is not in the
contact list now open a message request labelled "(unknown)": no
replies, read receipts, typing indicators, calls or name in
notifications until the user accepts it and names the contact
themselves. Names that look like an existing contact (case, diacritics,
Cyrillic/Greek homoglyphs, invisible characters) also show a warning in
the real contact's chat.
When the relay account of a known contact shows up with a different
key, the chat shows "safety number changed", verification is cleared
and the session that now belongs to the other key is dropped before
sending, so a reply never goes to the new key.
This commit is contained in:
@@ -331,6 +331,18 @@ class SepotCrypto {
|
|||||||
Future<bool> hasSession(ProtocolAddress remote) =>
|
Future<bool> hasSession(ProtocolAddress remote) =>
|
||||||
_stores.session.containsSession(remote);
|
_stores.session.containsSession(remote);
|
||||||
|
|
||||||
|
/// Identity klíč, pod kterým naposledy vznikla relace s [remote]
|
||||||
|
/// (zapisuje ho libsignal při X3DH i při příjmu PreKey zprávy).
|
||||||
|
/// Null = zatím žádný.
|
||||||
|
Future<Uint8List?> sessionIdentity(ProtocolAddress remote) async {
|
||||||
|
final key = await _stores.identity.getIdentity(remote);
|
||||||
|
return key == null ? null : Uint8List.fromList(key.serialize().toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zahodí relaci s [remote]; další zpráva založí novou z bundle.
|
||||||
|
Future<void> dropSession(ProtocolAddress remote) =>
|
||||||
|
_stores.session.deleteSession(remote);
|
||||||
|
|
||||||
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
|
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
|
||||||
Future<void> processRemoteBundle(
|
Future<void> processRemoteBundle(
|
||||||
ProtocolAddress remote,
|
ProtocolAddress remote,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
|
||||||
|
/// Jméno kontaktu pro UI. U žádosti o zprávu s viditelnou značkou, že si
|
||||||
|
/// jméno zvolil odesílatel sám: „Máma (neznámý)“ se nesmí na pohled
|
||||||
|
/// shodovat s přijatou „Mámou“ v seznamu, hlavičce ani ve skupině.
|
||||||
|
String contactName(AppLocalizations l10n, Contact contact) {
|
||||||
|
if (contact.displayName.isEmpty) return l10n.commonUnknownContact;
|
||||||
|
return contact.accepted
|
||||||
|
? contact.displayName
|
||||||
|
: l10n.contactRequestName(contact.displayName);
|
||||||
|
}
|
||||||
@@ -75,6 +75,19 @@ class PushNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zpráva od neznámého odesílatele (žádost o zprávu): bez jména, které
|
||||||
|
/// si zvolil sám.
|
||||||
|
static Future<void> showMessageRequestNotification() async {
|
||||||
|
await _ensureInit();
|
||||||
|
final l10n = l10nFor(await LocaleNotifier.readStored());
|
||||||
|
await _plugin.show(
|
||||||
|
_notificationId,
|
||||||
|
l10n.commonAppName,
|
||||||
|
l10n.pushMessageRequest,
|
||||||
|
_details(l10n),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
|
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
|
||||||
/// WS připojení → fronta se vyleje normální cestou).
|
/// WS připojení → fronta se vyleje normální cestou).
|
||||||
static Future<void> showWakeNotification() async {
|
static Future<void> showWakeNotification() async {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/// Rozpoznání jmen, která se pletou s existujícím kontaktem.
|
||||||
|
///
|
||||||
|
/// Jméno v Šepotu si každý volí sám, takže „Máma“ od cizího klíče je
|
||||||
|
/// nejlevnější phishing. Porovnává se „kostra“ jména: bez velikosti
|
||||||
|
/// písmen, diakritiky, mezer, interpunkce a neviditelných znaků, s cyrilicí
|
||||||
|
/// a řečtinou převedenou na latinku. „MAMA“, „Мáма“ (cyrilské М) i
|
||||||
|
/// „Máma“ s neviditelnou mezerou uprostřed tak padnou na totéž jako „Máma“.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:sepot/core/utils/text_fold.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
|
||||||
|
// Znaky, které na displeji vypadají jako latinka. Jen ty, co se v malých
|
||||||
|
// písmenech opravdu pletou; ne celý Unicode confusables seznam — falešná
|
||||||
|
// shoda tu nic nerozbije, jen přidá varování navíc.
|
||||||
|
const _confusables = {
|
||||||
|
'а': 'a', 'в': 'b', 'е': 'e', 'ё': 'e', 'к': 'k', 'м': 'm', 'н': 'h',
|
||||||
|
'о': 'o', 'р': 'p', 'с': 'c', 'т': 't', 'у': 'y', 'х': 'x', 'і': 'l',
|
||||||
|
'ї': 'l', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'α': 'a', 'β': 'b',
|
||||||
|
'ε': 'e', 'ι': 'l', 'κ': 'k', 'ν': 'v', 'ο': 'o', 'ρ': 'p', 'τ': 't',
|
||||||
|
'υ': 'u', 'χ': 'x', 'ү': 'y', '0': 'o', '1': 'l', 'i': 'l', '|': 'l',
|
||||||
|
// Latinská diakritika mimo češtinu a slovenštinu (tu skládá už fold).
|
||||||
|
'à': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ą': 'a', 'ç': 'c',
|
||||||
|
'ć': 'c', 'è': 'e', 'ê': 'e', 'ē': 'e', 'ę': 'e', 'ì': 'l', 'î': 'l',
|
||||||
|
'ï': 'l', 'ł': 'l', 'ñ': 'n', 'ń': 'n', 'ò': 'o', 'õ': 'o', 'ø': 'o',
|
||||||
|
'ő': 'o', 'ś': 's', 'ù': 'u', 'û': 'u', 'ű': 'u', 'ÿ': 'y', 'ź': 'z',
|
||||||
|
'ż': 'z',
|
||||||
|
};
|
||||||
|
|
||||||
|
final _invisible = RegExp(
|
||||||
|
'[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E'
|
||||||
|
'\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF]');
|
||||||
|
final _combining = RegExp('[\u0300-\u036F]');
|
||||||
|
final _separators = RegExp(r'[\s\p{P}\p{S}]', unicode: true);
|
||||||
|
|
||||||
|
/// Kostra jména pro porovnání. Prázdný výsledek = jméno nic nenese.
|
||||||
|
String nameSkeleton(String name) {
|
||||||
|
final folded = fold(name
|
||||||
|
.replaceAll(_invisible, '')
|
||||||
|
.replaceAll(_combining, '')
|
||||||
|
.replaceAll(_separators, ''));
|
||||||
|
final out = StringBuffer();
|
||||||
|
for (final ch in folded.split('')) {
|
||||||
|
out.write(_confusables[ch] ?? ch);
|
||||||
|
}
|
||||||
|
// „rn“ se v bezpatkovém písmu čte jako „m“.
|
||||||
|
return out.toString().replaceAll('rn', 'm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Přijatý kontakt, se kterým se [name] plete (null = nic podobného).
|
||||||
|
/// [exceptId] = sám odesílatel, ten se se sebou neporovnává.
|
||||||
|
Contact? lookalikeContact(
|
||||||
|
String name,
|
||||||
|
Iterable<Contact> contacts, {
|
||||||
|
String? exceptId,
|
||||||
|
}) {
|
||||||
|
final skeleton = nameSkeleton(name);
|
||||||
|
if (skeleton.isEmpty) return null;
|
||||||
|
for (final c in contacts) {
|
||||||
|
if (!c.accepted || c.id == exceptId) continue;
|
||||||
|
if (nameSkeleton(c.displayName) == skeleton) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import 'package:sepot/core/errors.dart';
|
|||||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||||
import 'package:sepot/core/network/relay_client.dart';
|
import 'package:sepot/core/network/relay_client.dart';
|
||||||
import 'package:sepot/core/network/relay_transport.dart';
|
import 'package:sepot/core/network/relay_transport.dart';
|
||||||
|
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||||
import 'package:sepot/core/services/group_admin_policy.dart';
|
import 'package:sepot/core/services/group_admin_policy.dart';
|
||||||
import 'package:sepot/core/services/image_processor.dart';
|
import 'package:sepot/core/services/image_processor.dart';
|
||||||
import 'package:sepot/core/services/typing_tracker.dart';
|
import 'package:sepot/core/services/typing_tracker.dart';
|
||||||
@@ -218,7 +219,7 @@ class MessagingService {
|
|||||||
final targets = contacts.where((c) =>
|
final targets = contacts.where((c) =>
|
||||||
!c.blocked &&
|
!c.blocked &&
|
||||||
(group == null
|
(group == null
|
||||||
? c.id == convId
|
? c.id == convId && c.accepted
|
||||||
: group.memberIds.contains(c.id) && c.id != _identity?.id));
|
: group.memberIds.contains(c.id) && c.id != _identity?.id));
|
||||||
for (final c in targets) {
|
for (final c in targets) {
|
||||||
final until = _typingBackoffUntil[c.id];
|
final until = _typingBackoffUntil[c.id];
|
||||||
@@ -993,6 +994,8 @@ class MessagingService {
|
|||||||
Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
|
Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
|
||||||
{String? gid}) async {
|
{String? gid}) async {
|
||||||
if (messageIds.isEmpty) return;
|
if (messageIds.isEmpty) return;
|
||||||
|
// Neznámý odesílatel se nedozví, že jsem jeho zprávu otevřel.
|
||||||
|
if (gid == null && !contact.accepted) return;
|
||||||
final enabled =
|
final enabled =
|
||||||
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
|
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -1948,19 +1951,26 @@ class MessagingService {
|
|||||||
verified: known.verified,
|
verified: known.verified,
|
||||||
notes: known.notes,
|
notes: known.notes,
|
||||||
blocked: known.blocked,
|
blocked: known.blocked,
|
||||||
|
avatar: known.avatar,
|
||||||
|
accepted: known.accepted,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final name = (e['n'] as String?)?.trim();
|
final name = (e['n'] as String?)?.trim();
|
||||||
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity(
|
// Člena skupiny jsem si nepřidal já: jméno mu dal on sám (nebo ten,
|
||||||
|
// kdo roster poslal). Jako u příchozí zprávy je to žádost.
|
||||||
|
await storage.saveContact(Contact.fromPublicIdentity(
|
||||||
|
PublicIdentity(
|
||||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||||
displayName: (name == null || name.isEmpty) ? '' : name,
|
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
|
||||||
identityKey: base64Decode(e['ik'] as String),
|
identityKey: base64Decode(e['ik'] as String),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
accountId: aid,
|
accountId: aid,
|
||||||
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
|
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
|
||||||
)));
|
),
|
||||||
|
accepted: false,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2215,12 +2225,14 @@ class MessagingService {
|
|||||||
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
|
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
|
||||||
// nedrží — čekání na receipt by blokovalo všechny ostatní.
|
// nedrží — čekání na receipt by blokovalo všechny ostatní.
|
||||||
final sealed = await _serial<Uint8List?>(() async {
|
final sealed = await _serial<Uint8List?>(() async {
|
||||||
|
await _dropForeignSession(crypto, peerAddr, contact);
|
||||||
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
|
// 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í).
|
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
|
||||||
if (!await crypto.hasSession(peerAddr)) {
|
if (!await crypto.hasSession(peerAddr)) {
|
||||||
if (ephemeral) return null;
|
if (ephemeral) return null;
|
||||||
final bundle = await relay.fetchBundle(peerDa);
|
final bundle = await relay.fetchBundle(peerDa);
|
||||||
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
||||||
|
await _noteIdentityChanged(contact);
|
||||||
throw const IdentityKeyMismatchError();
|
throw const IdentityKeyMismatchError();
|
||||||
}
|
}
|
||||||
await crypto.processRemoteBundle(peerAddr, bundle);
|
await crypto.processRemoteBundle(peerAddr, bundle);
|
||||||
@@ -2245,6 +2257,29 @@ class MessagingService {
|
|||||||
return receipt.status;
|
return receipt.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// libsignal 5.0.9 nad prázdným paměťovým storem identit důvěřuje
|
||||||
|
/// čemukoli (TOFU bez paměti): příchozí PreKey zpráva s cizím klíčem pod
|
||||||
|
/// stejnou adresou relaci tiše přepíše a další odpověď by šla jemu.
|
||||||
|
/// Klíč relace proto porovnáváme s klíčem kontaktu sami; nesedí-li,
|
||||||
|
/// relace se zahodí (nová vznikne z bundle, který se ověřuje výše)
|
||||||
|
/// a kontakt dostane varování.
|
||||||
|
Future<void> _dropForeignSession(
|
||||||
|
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) async {
|
||||||
|
if (!await crypto.hasSession(peerAddr)) return;
|
||||||
|
final bound = await crypto.sessionIdentity(peerAddr);
|
||||||
|
if (bound == null ||
|
||||||
|
_listEquals(bound, contact.publicIdentity.identityKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await crypto.dropSession(peerAddr);
|
||||||
|
await _noteIdentityChanged(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> debugDropForeignSession(
|
||||||
|
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) =>
|
||||||
|
_dropForeignSession(crypto, peerAddr, contact);
|
||||||
|
|
||||||
// ── Příjem ──────────────────────────────────────────────────────────
|
// ── Příjem ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
|
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
|
||||||
@@ -2279,7 +2314,17 @@ class MessagingService {
|
|||||||
// (může jít o dočasný stav — např. cert refresh).
|
// (může jít o dočasný stav — např. cert refresh).
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await _processOpened(opened, envelope.msgUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> debugProcessOpened(
|
||||||
|
SepotSealedMessage opened, List<int> msgUuid) =>
|
||||||
|
_processOpened(opened, msgUuid);
|
||||||
|
|
||||||
|
/// Zpracuje už dešifrovanou obálku: kontakt, typ obsahu, uložení, ACK.
|
||||||
|
Future<void> _processOpened(
|
||||||
|
SepotSealedMessage opened, List<int> msgUuid) async {
|
||||||
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
|
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
|
||||||
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
|
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
|
||||||
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
|
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
|
||||||
@@ -2292,7 +2337,7 @@ class MessagingService {
|
|||||||
if (content['v'] != 1 || !known.contains(type)) {
|
if (content['v'] != 1 || !known.contains(type)) {
|
||||||
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
// 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.
|
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
||||||
_relay?.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2302,15 +2347,19 @@ class MessagingService {
|
|||||||
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
|
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
|
||||||
// nic neukládej ani nezobrazuj a neposílej doručenku.
|
// nic neukládej ani nezobrazuj a neposílej doručenku.
|
||||||
if (contact.blocked) {
|
if (contact.blocked) {
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hovory jen 1:1; skupinový gid tu nemá co dělat.
|
// 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.
|
// Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru.
|
||||||
|
// Žádost o zprávu nezvoní: cizí klíč by jinak rozezvonil telefon
|
||||||
|
// s vlastním zvoleným jménem („Máma volá“).
|
||||||
if (type == 'call') {
|
if (type == 'call') {
|
||||||
if (content['gid'] == null) onCallSignal?.call(contact, content);
|
if (content['gid'] == null && contact.accepted) {
|
||||||
_relay!.ack(envelope.msgUuid);
|
onCallSignal?.call(contact, content);
|
||||||
|
}
|
||||||
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2320,7 +2369,7 @@ class MessagingService {
|
|||||||
// nepomůže).
|
// nepomůže).
|
||||||
if (_groupControlTypes.contains(type)) {
|
if (_groupControlTypes.contains(type)) {
|
||||||
await _handleGroupControl(senderFp, type, content);
|
await _handleGroupControl(senderFp, type, content);
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2330,7 +2379,7 @@ class MessagingService {
|
|||||||
final group =
|
final group =
|
||||||
gid == null ? null : await _groupForIncoming(gid, senderFp);
|
gid == null ? null : await _groupForIncoming(gid, senderFp);
|
||||||
if (gid != null && group == null) {
|
if (gid != null && group == null) {
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (group != null && _noteCapability(group, senderFp, content)) {
|
if (group != null && _noteCapability(group, senderFp, content)) {
|
||||||
@@ -2345,7 +2394,7 @@ class MessagingService {
|
|||||||
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
|
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
|
||||||
TypingTracker.showFor;
|
TypingTracker.showFor;
|
||||||
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
|
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2380,7 +2429,7 @@ class MessagingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2389,7 +2438,7 @@ class MessagingService {
|
|||||||
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
|
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
|
||||||
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
|
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
|
||||||
await _applyIncomingControl(convId, type!, content, opened);
|
await _applyIncomingControl(convId, type!, content, opened);
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2407,7 +2456,7 @@ class MessagingService {
|
|||||||
if (raw is! String) {
|
if (raw is! String) {
|
||||||
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
||||||
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
|
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
|
||||||
@@ -2441,7 +2490,7 @@ class MessagingService {
|
|||||||
}
|
}
|
||||||
} on Object {
|
} on Object {
|
||||||
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout
|
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
message = Message(
|
message = Message(
|
||||||
@@ -2466,7 +2515,7 @@ class MessagingService {
|
|||||||
// Uloženo na disku → teprve teď smí relay obálku zahodit.
|
// Uloženo na disku → teprve teď smí relay obálku zahodit.
|
||||||
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
|
// (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á.)
|
// neúspěchu má UI retry, ACK na něj nečeká.)
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
|
|
||||||
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
|
// 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
|
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
|
||||||
@@ -2499,11 +2548,11 @@ class MessagingService {
|
|||||||
} on FormatException {
|
} on FormatException {
|
||||||
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
|
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
|
||||||
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
|
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
|
||||||
_relay?.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
} on TypeError {
|
} on TypeError {
|
||||||
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
|
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
|
||||||
// trvale vadné, ACK a zahodit.
|
// trvale vadné, ACK a zahodit.
|
||||||
_relay?.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
|
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
|
||||||
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
|
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
|
||||||
@@ -2733,6 +2782,7 @@ class MessagingService {
|
|||||||
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
|
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
|
||||||
blocked: c.blocked,
|
blocked: c.blocked,
|
||||||
avatar: avatarChanged ? avatar : c.avatar,
|
avatar: avatarChanged ? avatar : c.avatar,
|
||||||
|
accepted: c.accepted,
|
||||||
);
|
);
|
||||||
await storage.saveContact(updated);
|
await storage.saveContact(updated);
|
||||||
if (avatarChanged) {
|
if (avatarChanged) {
|
||||||
@@ -2743,7 +2793,9 @@ class MessagingService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neznámý odesílatel → nový (neověřený) kontakt
|
// Neznámý klíč = nový kontakt, NIKDY splynutí s existujícím. Identita
|
||||||
|
// je klíč: jméno ani fotka, které si odesílatel zvolil, neříkají nic
|
||||||
|
// o tom, kdo to je. Proto žádost o zprávu, ne kontakt.
|
||||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||||
final name = (profile?['n'] as String?)?.trim();
|
final name = (profile?['n'] as String?)?.trim();
|
||||||
final identity = PublicIdentity(
|
final identity = PublicIdentity(
|
||||||
@@ -2753,12 +2805,62 @@ class MessagingService {
|
|||||||
accountId: opened.senderName,
|
accountId: opened.senderName,
|
||||||
deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
|
deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
|
||||||
);
|
);
|
||||||
final contact = Contact.fromPublicIdentity(identity)..avatar = avatar;
|
final contact = Contact.fromPublicIdentity(identity, accepted: false)
|
||||||
|
..avatar = avatar;
|
||||||
await storage.saveContact(contact);
|
await storage.saveContact(contact);
|
||||||
if (avatar != null) unawaited(_downloadAvatar(avatar));
|
if (avatar != null) unawaited(_downloadAvatar(avatar));
|
||||||
|
|
||||||
|
// Účet na relayi patří jednomu klíči navždy (identities.identity_key je
|
||||||
|
// unikátní). Stejný účet s jiným klíčem = relay lže nebo byl převzat:
|
||||||
|
// dosavadní kontakt ztrácí ověření a dostane varování.
|
||||||
|
final previous = contacts
|
||||||
|
.where((c) => c.publicIdentity.accountId == opened.senderName)
|
||||||
|
.firstOrNull;
|
||||||
|
if (previous != null) await _noteIdentityChanged(previous);
|
||||||
|
|
||||||
|
final lookalike =
|
||||||
|
lookalikeContact(identity.displayName, contacts, exceptId: contact.id);
|
||||||
|
if (lookalike != null && lookalike.id != previous?.id) {
|
||||||
|
await _noteLookalike(lookalike, identity.displayName);
|
||||||
|
}
|
||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bezpečnostní kód kontaktu se změnil: zrušit ověření a dát to najevo
|
||||||
|
/// v jeho konverzaci. Opakovaný výskyt za sebou nepřidává další řádky.
|
||||||
|
Future<void> _noteIdentityChanged(Contact contact) async {
|
||||||
|
if (contact.verified) {
|
||||||
|
contact.verified = false;
|
||||||
|
await storage.saveContact(contact);
|
||||||
|
}
|
||||||
|
await _addNotice(contact, MessageNotice.identityChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Někdo cizí se jmenuje skoro stejně jako [contact]. Připomínka do jeho
|
||||||
|
/// konverzace (jen když existuje — nezakládat chat kvůli útočníkovi).
|
||||||
|
Future<void> _noteLookalike(Contact contact, String claimedName) async {
|
||||||
|
if (await storage.loadConversation(contact.id) == null) return;
|
||||||
|
await _addNotice(contact, MessageNotice.lookalikeRequest,
|
||||||
|
content: claimedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNotice(Contact contact, MessageNotice notice,
|
||||||
|
{String? content}) async {
|
||||||
|
final me = _identity;
|
||||||
|
if (me == null) return;
|
||||||
|
final last = (await storage.loadConversation(contact.id))?.lastMessage;
|
||||||
|
if (last?.notice == notice && last?.content == content) return;
|
||||||
|
await storage.addMessage(
|
||||||
|
contact.id,
|
||||||
|
Message.notice(
|
||||||
|
contactId: contact.id,
|
||||||
|
myId: me.id,
|
||||||
|
notice: notice,
|
||||||
|
content: content),
|
||||||
|
);
|
||||||
|
onUpdate?.call();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sdílení vlastního kontaktu ──────────────────────────────────────
|
// ── Sdílení vlastního kontaktu ──────────────────────────────────────
|
||||||
|
|
||||||
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
|
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
@@ -45,13 +46,15 @@ class ArchivedChatsScreen extends ConsumerWidget {
|
|||||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
final name = group?.name ??
|
final name = group?.name ??
|
||||||
contact?.displayName ??
|
(contact == null
|
||||||
context.l10n.commonUnknownContact;
|
? context.l10n.commonUnknownContact
|
||||||
|
: contactName(context.l10n, contact));
|
||||||
return ChatListTile(
|
return ChatListTile(
|
||||||
conversation: conv,
|
conversation: conv,
|
||||||
contactName: name,
|
contactName: name,
|
||||||
myId: myId,
|
myId: myId,
|
||||||
isGroup: group != null,
|
isGroup: group != null,
|
||||||
|
isRequest: group == null && contact?.accepted == false,
|
||||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||||
onLongPress: () => _actions(context, ref, conv),
|
onLongPress: () => _actions(context, ref, conv),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
@@ -206,7 +207,7 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
|||||||
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
|
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
|
||||||
? l10n.notesTitle
|
? l10n.notesTitle
|
||||||
: groupOf(c.contactId)?.name ??
|
: groupOf(c.contactId)?.name ??
|
||||||
contactOf(c.contactId)?.displayName ??
|
_nameOrNull(l10n, contactOf(c.contactId)) ??
|
||||||
l10n.commonUnknownContact;
|
l10n.commonUnknownContact;
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -265,7 +266,8 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
|||||||
? l10n.chatTyping
|
? l10n.chatTyping
|
||||||
: writers.length == 1
|
: writers.length == 1
|
||||||
? l10n.chatTypingNamed(
|
? l10n.chatTypingNamed(
|
||||||
(contactOf(writers.first)?.displayName ?? '?')
|
(_nameOrNull(l10n, contactOf(writers.first)) ??
|
||||||
|
'?')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.first)
|
.first)
|
||||||
: l10n.chatTypingSeveral;
|
: l10n.chatTypingSeveral;
|
||||||
@@ -275,8 +277,10 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
|||||||
myId: myId,
|
myId: myId,
|
||||||
isGroup: group != null,
|
isGroup: group != null,
|
||||||
verified: contactOf(c.contactId)?.verified ?? false,
|
verified: contactOf(c.contactId)?.verified ?? false,
|
||||||
|
isRequest: group == null &&
|
||||||
|
contactOf(c.contactId)?.accepted == false,
|
||||||
lastAuthor: group != null && last != null
|
lastAuthor: group != null && last != null
|
||||||
? contactOf(last.senderId)?.displayName
|
? _nameOrNull(l10n, contactOf(last.senderId))
|
||||||
: null,
|
: null,
|
||||||
highlight: hits[c],
|
highlight: hits[c],
|
||||||
typingLabel: typingLabel,
|
typingLabel: typingLabel,
|
||||||
@@ -685,3 +689,6 @@ class _EmptyState extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _nameOrNull(AppLocalizations l10n, Contact? c) =>
|
||||||
|
c == null ? null : contactName(l10n, c);
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class ChatListTile extends StatelessWidget {
|
|||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
final bool verified;
|
final bool verified;
|
||||||
|
|
||||||
|
/// Žádost o zprávu (neznámý odesílatel): náhled místo textu hlásí, o co
|
||||||
|
/// jde — první věta útočníka nemá dostat prostor v seznamu chatů.
|
||||||
|
final bool isRequest;
|
||||||
|
|
||||||
/// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
|
/// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
|
||||||
final String? lastAuthor;
|
final String? lastAuthor;
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ class ChatListTile extends StatelessWidget {
|
|||||||
required this.myId,
|
required this.myId,
|
||||||
this.isGroup = false,
|
this.isGroup = false,
|
||||||
this.verified = false,
|
this.verified = false,
|
||||||
|
this.isRequest = false,
|
||||||
this.lastAuthor,
|
this.lastAuthor,
|
||||||
this.highlight,
|
this.highlight,
|
||||||
this.typingLabel,
|
this.typingLabel,
|
||||||
@@ -54,7 +59,9 @@ class ChatListTile extends StatelessWidget {
|
|||||||
final unread = conversation.unreadCount(myId);
|
final unread = conversation.unreadCount(myId);
|
||||||
final muted = conversation.isMuted;
|
final muted = conversation.isMuted;
|
||||||
final mine = lastMsg != null && lastMsg.isMine(myId);
|
final mine = lastMsg != null && lastMsg.isMine(myId);
|
||||||
final preview = previewOf(context.l10n, lastMsg);
|
final preview = isRequest
|
||||||
|
? (icon: Icons.person_search_rounded, text: context.l10n.requestTitle)
|
||||||
|
: previewOf(context.l10n, lastMsg);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -245,6 +252,9 @@ class ChatListTile extends StatelessWidget {
|
|||||||
final label = callLogLabel(l10n, call);
|
final label = callLogLabel(l10n, call);
|
||||||
return (icon: label.icon, text: label.text);
|
return (icon: label.icon, text: label.text);
|
||||||
}
|
}
|
||||||
|
if (msg.notice != null) {
|
||||||
|
return (icon: Icons.gpp_maybe_rounded, text: l10n.noticePreview);
|
||||||
|
}
|
||||||
return switch (msg.type) {
|
return switch (msg.type) {
|
||||||
MessageType.text => (icon: null, text: msg.content ?? ''),
|
MessageType.text => (icon: null, text: msg.content ?? ''),
|
||||||
MessageType.image => (
|
MessageType.image => (
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: contactsAsync.when(
|
child: contactsAsync.when(
|
||||||
data: (contacts) {
|
data: (contacts) {
|
||||||
|
// Žádosti o zprávu kontakty nejsou — žijí jen v seznamu chatů.
|
||||||
final filtered = contacts.where((c) {
|
final filtered = contacts.where((c) {
|
||||||
|
if (!c.accepted) return false;
|
||||||
if (_searchQuery.isEmpty) return true;
|
if (_searchQuery.isEmpty) return true;
|
||||||
return c.displayName.toLowerCase().contains(_searchQuery) ||
|
return c.displayName.toLowerCase().contains(_searchQuery) ||
|
||||||
c.sepotId.toLowerCase().contains(_searchQuery);
|
c.sepotId.toLowerCase().contains(_searchQuery);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
@@ -17,9 +18,7 @@ import 'package:sepot/providers/groups_provider.dart';
|
|||||||
import 'package:sepot/providers/storage_provider.dart';
|
import 'package:sepot/providers/storage_provider.dart';
|
||||||
|
|
||||||
String contactLabel(BuildContext context, Contact contact) =>
|
String contactLabel(BuildContext context, Contact contact) =>
|
||||||
contact.displayName.isEmpty
|
contactName(context.l10n, contact);
|
||||||
? context.l10n.commonUnknownContact
|
|
||||||
: contact.displayName;
|
|
||||||
|
|
||||||
void _snack(BuildContext context, String text) {
|
void _snack(BuildContext context, String text) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/calls/call_controller.dart';
|
import 'package:sepot/core/calls/call_controller.dart';
|
||||||
import 'package:sepot/core/constants/app_constants.dart';
|
import 'package:sepot/core/constants/app_constants.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
@@ -21,6 +22,8 @@ import 'package:sepot/core/theme/app_theme.dart';
|
|||||||
import 'package:sepot/core/utils/text_fold.dart';
|
import 'package:sepot/core/utils/text_fold.dart';
|
||||||
import 'package:sepot/features/calls/call_log_text.dart';
|
import 'package:sepot/features/calls/call_log_text.dart';
|
||||||
import 'package:sepot/features/conversation/chat_actions.dart';
|
import 'package:sepot/features/conversation/chat_actions.dart';
|
||||||
|
import 'package:sepot/features/conversation/message_request_panel.dart';
|
||||||
|
import 'package:sepot/features/conversation/widgets/safety_notice_row.dart';
|
||||||
import 'package:sepot/features/conversation/forward_sheet.dart';
|
import 'package:sepot/features/conversation/forward_sheet.dart';
|
||||||
import 'package:sepot/features/conversation/widgets/attachment_content.dart';
|
import 'package:sepot/features/conversation/widgets/attachment_content.dart';
|
||||||
import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart';
|
import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart';
|
||||||
@@ -272,14 +275,13 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
// v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
|
// v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
|
||||||
String autorJmeno(String fingerprint) {
|
String autorJmeno(String fingerprint) {
|
||||||
if (fingerprint == identity?.id) return context.l10n.groupsYou;
|
if (fingerprint == identity?.id) return context.l10n.groupsYou;
|
||||||
final jmeno = contacts
|
final autor = contacts
|
||||||
.where((c) => c.publicIdentity.id == fingerprint)
|
.where((c) => c.publicIdentity.id == fingerprint)
|
||||||
.firstOrNull
|
.firstOrNull;
|
||||||
?.displayName;
|
if (autor == null) {
|
||||||
if (jmeno == null) {
|
|
||||||
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}';
|
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}';
|
||||||
}
|
}
|
||||||
return jmeno.isEmpty ? context.l10n.commonUnknownContact : jmeno;
|
return contactName(context.l10n, autor);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Widget bottom;
|
final Widget bottom;
|
||||||
@@ -293,6 +295,8 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
child: Text(context.l10n.chatUnblock),
|
child: Text(context.l10n.chatUnblock),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else if (group == null && contact != null && !contact.accepted) {
|
||||||
|
bottom = MessageRequestPanel(contact: contact);
|
||||||
} else {
|
} else {
|
||||||
bottom = _buildComposer();
|
bottom = _buildComposer();
|
||||||
}
|
}
|
||||||
@@ -472,9 +476,9 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContactHeader(Contact? contact, {bool typing = false}) {
|
Widget _buildContactHeader(Contact? contact, {bool typing = false}) {
|
||||||
final name = (contact == null || contact.displayName.isEmpty)
|
final name = contact == null
|
||||||
? context.l10n.commonUnknownContact
|
? context.l10n.commonUnknownContact
|
||||||
: contact.displayName;
|
: contactName(context.l10n, contact);
|
||||||
final verified = contact?.verified ?? false;
|
final verified = contact?.verified ?? false;
|
||||||
return _buildHeaderFrame(children: [
|
return _buildHeaderFrame(children: [
|
||||||
_backButton(),
|
_backButton(),
|
||||||
@@ -485,19 +489,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
// Stav ověření místo syrového ID (UX-FLOWS §3.1)
|
// Stav ověření místo syrového ID (UX-FLOWS §3.1)
|
||||||
subtitle: typing
|
subtitle: typing
|
||||||
? _typingLabel(context.l10n.chatTyping)
|
? _typingLabel(context.l10n.chatTyping)
|
||||||
|
: contact != null && !contact.accepted
|
||||||
|
? Text(
|
||||||
|
context.l10n.requestTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: SepotColors.warning),
|
||||||
|
)
|
||||||
: Text(
|
: Text(
|
||||||
verified
|
verified
|
||||||
? context.l10n.verifyVerified
|
? context.l10n.verifyVerified
|
||||||
: context.l10n.verifyUnverified,
|
: context.l10n.verifyUnverified,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color:
|
color: verified
|
||||||
verified ? SepotColors.sage : SepotColors.textSecondary,
|
? SepotColors.sage
|
||||||
|
: SepotColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () => _openProfile('/contact/${widget.contactId}'),
|
onTap: () => _openProfile('/contact/${widget.contactId}'),
|
||||||
),
|
),
|
||||||
if (contact != null && !contact.blocked) ...[
|
// Žádosti o zprávu se nevolá — hovor by prozradil, že jsem u telefonu.
|
||||||
|
if (contact != null && !contact.blocked && contact.accepted) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: context.l10n.callVoice,
|
tooltip: context.l10n.callVoice,
|
||||||
icon: const Icon(Icons.call_outlined,
|
icon: const Icon(Icons.call_outlined,
|
||||||
@@ -823,6 +835,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
? null
|
? null
|
||||||
: messages.where((m) => m.id == msg.replyToId).firstOrNull;
|
: messages.where((m) => m.id == msg.replyToId).firstOrNull;
|
||||||
|
|
||||||
|
final notice = msg.notice;
|
||||||
|
if (notice != null) {
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: _keys.putIfAbsent(msg.id, GlobalKey.new),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (showDate) _buildDateDivider(msg.timestamp),
|
||||||
|
SafetyNoticeRow(
|
||||||
|
notice: notice,
|
||||||
|
contactName: autorJmeno(widget.contactId),
|
||||||
|
claimedName: msg.content,
|
||||||
|
onVerify: group == null
|
||||||
|
? () => context.push('/verify/${widget.contactId}')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final callLog = msg.callLog;
|
final callLog = msg.callLog;
|
||||||
if (callLog != null) {
|
if (callLog != null) {
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
@@ -1745,9 +1778,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
String jmeno(String f) {
|
String jmeno(String f) {
|
||||||
final c = contacts.where((c) => c.id == f).firstOrNull;
|
final c = contacts.where((c) => c.id == f).firstOrNull;
|
||||||
if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}';
|
if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}';
|
||||||
return c.displayName.isEmpty
|
return contactName(context.l10n, c);
|
||||||
? context.l10n.commonUnknownContact
|
|
||||||
: c.displayName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget radek(IconData icon, String label, DateTime? t, {Color? color}) {
|
Widget radek(IconData icon, String label, DateTime? t, {Color? color}) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/constants/app_constants.dart';
|
import 'package:sepot/core/constants/app_constants.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
@@ -37,8 +38,8 @@ Future<void> forwardMessage(
|
|||||||
for (final g in groups)
|
for (final g in groups)
|
||||||
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
|
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
|
||||||
for (final c in contacts)
|
for (final c in contacts)
|
||||||
if (!c.blocked && c.id != fromId)
|
if (!c.blocked && c.accepted && c.id != fromId)
|
||||||
_Target(c.id, c.displayName, verified: c.verified),
|
_Target(c.id, contactName(context.l10n, c), verified: c.verified),
|
||||||
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
|
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
|
||||||
if (!ConversationsNotifier.isNotes(fromId)) {
|
if (!ConversationsNotifier.isNotes(fromId)) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
import 'package:sepot/models/attachment.dart';
|
import 'package:sepot/models/attachment.dart';
|
||||||
@@ -103,9 +104,9 @@ class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
|
|||||||
final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
|
final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
|
||||||
.where((c) => c.publicIdentity.id == m.senderId)
|
.where((c) => c.publicIdentity.id == m.senderId)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
return contact?.displayName.isNotEmpty == true
|
return contact == null
|
||||||
? contact!.displayName
|
? context.l10n.commonUnknownContact
|
||||||
: context.l10n.commonUnknownContact;
|
: contactName(context.l10n, contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/// Žádost o zprávu: místo psacího pole u odesílatele, kterého jsem si
|
||||||
|
/// nepřidal. Jméno i fotku si zvolil sám, takže dokud ho nepřijmu, nesmí
|
||||||
|
/// vypadat jako můj kontakt a nesmí ode mě dostat odpověď omylem.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
|
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||||
|
import 'package:sepot/core/services/messaging_service.dart';
|
||||||
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
|
import 'package:sepot/features/conversation/chat_actions.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
import 'package:sepot/providers/contacts_provider.dart';
|
||||||
|
|
||||||
|
class MessageRequestPanel extends ConsumerWidget {
|
||||||
|
const MessageRequestPanel({super.key, required this.contact});
|
||||||
|
|
||||||
|
final Contact contact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||||
|
final claimed = contact.displayName.isEmpty
|
||||||
|
? l10n.commonUnknownContact
|
||||||
|
: contact.displayName;
|
||||||
|
final lookalike =
|
||||||
|
lookalikeContact(contact.displayName, contacts, exceptId: contact.id);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
minimum: const EdgeInsets.fromLTRB(10, 6, 10, 10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: SepotColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: lookalike != null ? SepotColors.error : SepotColors.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
lookalike != null
|
||||||
|
? Icons.gpp_maybe_rounded
|
||||||
|
: Icons.person_search_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: lookalike != null
|
||||||
|
? SepotColors.error
|
||||||
|
: SepotColors.warning,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.requestTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.requestBody(claimed),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, color: SepotColors.textSecondary),
|
||||||
|
),
|
||||||
|
if (lookalike != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.requestLookalikeWarning(lookalike.displayName),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: SepotColors.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
setContactBlocked(context, ref, contact, true),
|
||||||
|
child: Text(l10n.chatMenuBlockContact,
|
||||||
|
style: const TextStyle(color: SepotColors.error)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => acceptMessageRequest(context, ref, contact),
|
||||||
|
child: Text(l10n.requestAccept),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Přijetí = uživatel kontakt pojmenuje sám. Jméno, které se plete
|
||||||
|
/// s jiným přijatým kontaktem, neprojde: dvě „Mámy“ v seznamu by
|
||||||
|
/// podvrh jen schovaly.
|
||||||
|
Future<void> acceptMessageRequest(
|
||||||
|
BuildContext context, WidgetRef ref, Contact contact) async {
|
||||||
|
final contacts = ref.read(contactsProvider).valueOrNull ?? const [];
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: lookalikeContact(contact.displayName, contacts,
|
||||||
|
exceptId: contact.id) ==
|
||||||
|
null
|
||||||
|
? contact.displayName
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
final name = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _AcceptDialog(
|
||||||
|
controller: controller,
|
||||||
|
isTaken: (n) =>
|
||||||
|
lookalikeContact(n, contacts, exceptId: contact.id) != null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
controller.dispose();
|
||||||
|
if (name == null) return;
|
||||||
|
await ref.read(contactsProvider.notifier).accept(contact.id, name: name);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AcceptDialog extends StatefulWidget {
|
||||||
|
const _AcceptDialog({required this.controller, required this.isTaken});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool Function(String name) isTaken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AcceptDialog> createState() => _AcceptDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AcceptDialogState extends State<_AcceptDialog> {
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final name = widget.controller.text.trim();
|
||||||
|
if (name.isEmpty) {
|
||||||
|
setState(() => _error = l10n.requestNameEmpty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.isTaken(name)) {
|
||||||
|
setState(() => _error = l10n.requestNameTaken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.requestAcceptTitle),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.requestAcceptBody, style: const TextStyle(fontSize: 13)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: widget.controller,
|
||||||
|
autofocus: true,
|
||||||
|
maxLength: MessagingService.maxDisplayNameLength,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.requestAcceptNameLabel,
|
||||||
|
errorText: _error,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.commonCancel),
|
||||||
|
),
|
||||||
|
FilledButton(onPressed: _submit, child: Text(l10n.requestAccept)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/// Bezpečnostní upozornění uprostřed konverzace. Vypadá jinak než bublina
|
||||||
|
/// (nikdo ho neposlal), aby nešlo zaměnit se zprávou protistrany.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
|
import 'package:sepot/models/message.dart';
|
||||||
|
|
||||||
|
class SafetyNoticeRow extends StatelessWidget {
|
||||||
|
const SafetyNoticeRow({
|
||||||
|
super.key,
|
||||||
|
required this.notice,
|
||||||
|
required this.contactName,
|
||||||
|
this.claimedName,
|
||||||
|
this.onVerify,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MessageNotice notice;
|
||||||
|
final String contactName;
|
||||||
|
|
||||||
|
/// Jméno, které si zvolil neznámý odesílatel ([MessageNotice.lookalikeRequest]).
|
||||||
|
final String? claimedName;
|
||||||
|
|
||||||
|
/// Odkaz na porovnání bezpečnostního kódu (null = bez tlačítka).
|
||||||
|
final VoidCallback? onVerify;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final text = switch (notice) {
|
||||||
|
MessageNotice.identityChanged => l10n.noticeIdentityChanged(contactName),
|
||||||
|
MessageNotice.lookalikeRequest =>
|
||||||
|
l10n.noticeLookalikeRequest(claimedName ?? '', contactName),
|
||||||
|
};
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: SepotColors.surfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: SepotColors.warning),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.gpp_maybe_rounded,
|
||||||
|
size: 18, color: SepotColors.warning),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, color: SepotColors.textPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (onVerify != null &&
|
||||||
|
notice == MessageNotice.identityChanged)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: onVerify,
|
||||||
|
child: Text(l10n.noticeVerifyAction),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,9 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
|||||||
: null;
|
: null;
|
||||||
final contactsAsync = ref.watch(contactsProvider);
|
final contactsAsync = ref.watch(contactsProvider);
|
||||||
final contacts = (contactsAsync.valueOrNull ?? [])
|
final contacts = (contactsAsync.valueOrNull ?? [])
|
||||||
.where((c) => !c.blocked)
|
// Do skupiny jen lidi, které jsem přijal — žádost o zprávu by
|
||||||
|
// jinak dostala roster všech členů pod jménem, které si vymyslela.
|
||||||
|
.where((c) => !c.blocked && c.accepted)
|
||||||
.where((c) => existing == null || !existing.memberIds.contains(c.id))
|
.where((c) => existing == null || !existing.memberIds.contains(c.id))
|
||||||
.toList();
|
.toList();
|
||||||
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
|
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ class Contact {
|
|||||||
/// Profilová fotka, kterou kontakt sdílí (null = iniciály).
|
/// Profilová fotka, kterou kontakt sdílí (null = iniciály).
|
||||||
AvatarRef? avatar;
|
AvatarRef? avatar;
|
||||||
|
|
||||||
|
/// Přijatý kontakt. False = žádost o zprávu: napsal mi někdo, koho jsem
|
||||||
|
/// si sám nepřidal (nebo jsem ho potkal jen ve skupině), takže jméno
|
||||||
|
/// i fotku si zvolil on. Dokud ho nepřijmu, appka ho značí jako
|
||||||
|
/// neznámého, neukazuje jeho fotku, nezvoní jeho hovory a nedává mu
|
||||||
|
/// vědět, že čtu nebo píšu.
|
||||||
|
bool accepted;
|
||||||
|
|
||||||
Contact({
|
Contact({
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.publicIdentity,
|
required this.publicIdentity,
|
||||||
@@ -26,6 +33,7 @@ class Contact {
|
|||||||
this.notes,
|
this.notes,
|
||||||
this.blocked = false,
|
this.blocked = false,
|
||||||
this.avatar,
|
this.avatar,
|
||||||
|
this.accepted = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// ID kontaktu = fingerprint identity klíče (identita = klíč).
|
/// ID kontaktu = fingerprint identity klíče (identita = klíč).
|
||||||
@@ -41,12 +49,14 @@ class Contact {
|
|||||||
factory Contact.fromPublicIdentity(
|
factory Contact.fromPublicIdentity(
|
||||||
PublicIdentity identity, {
|
PublicIdentity identity, {
|
||||||
bool verified = false,
|
bool verified = false,
|
||||||
|
bool accepted = true,
|
||||||
}) {
|
}) {
|
||||||
return Contact(
|
return Contact(
|
||||||
displayName: identity.displayName,
|
displayName: identity.displayName,
|
||||||
publicIdentity: identity,
|
publicIdentity: identity,
|
||||||
addedAt: DateTime.now(),
|
addedAt: DateTime.now(),
|
||||||
verified: verified,
|
verified: verified,
|
||||||
|
accepted: accepted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +74,7 @@ class Contact {
|
|||||||
'notes': notes,
|
'notes': notes,
|
||||||
'blocked': blocked,
|
'blocked': blocked,
|
||||||
'avatar': avatar?.toJson(),
|
'avatar': avatar?.toJson(),
|
||||||
|
'accepted': accepted,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory Contact.fromJson(Map<String, dynamic> json) {
|
factory Contact.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -77,6 +88,9 @@ class Contact {
|
|||||||
notes: json['notes'] as String?,
|
notes: json['notes'] as String?,
|
||||||
blocked: json['blocked'] as bool? ?? false,
|
blocked: json['blocked'] as bool? ?? false,
|
||||||
avatar: AvatarRef.fromJson(json['avatar']),
|
avatar: AvatarRef.fromJson(json['avatar']),
|
||||||
|
// Kontakty z doby před žádostmi o zprávu uživatel už léta používá —
|
||||||
|
// zpětně je vydávat za neznámé by jen naučilo varování odklikávat.
|
||||||
|
accepted: json['accepted'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import 'package:sepot/models/group.dart';
|
|||||||
import 'package:sepot/models/poll.dart';
|
import 'package:sepot/models/poll.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Bezpečnostní upozornění, které appka sama vloží do konverzace.
|
||||||
|
/// Nikdy nejde po drátě — protistrana ho podvrhnout nemůže.
|
||||||
|
enum MessageNotice {
|
||||||
|
/// Pod adresou tohohle kontaktu se ozval jiný identity klíč (relay
|
||||||
|
/// hlásí jiný klíč, nebo relace patří cizímu klíči). Ověření se ruší.
|
||||||
|
identityChanged,
|
||||||
|
|
||||||
|
/// Neznámý odesílatel si zvolil jméno, které se plete s tímhle
|
||||||
|
/// kontaktem. [Message.content] nese jméno, které si zvolil.
|
||||||
|
lookalikeRequest,
|
||||||
|
}
|
||||||
|
|
||||||
/// Typy zpráv — odpovídá Rust: MessageType
|
/// Typy zpráv — odpovídá Rust: MessageType
|
||||||
enum MessageType {
|
enum MessageType {
|
||||||
text,
|
text,
|
||||||
@@ -62,6 +74,9 @@ class Message {
|
|||||||
/// Lokální záznam hovoru — taková zpráva nemá text ani přílohu.
|
/// Lokální záznam hovoru — taková zpráva nemá text ani přílohu.
|
||||||
final CallLog? callLog;
|
final CallLog? callLog;
|
||||||
|
|
||||||
|
/// Bezpečnostní upozornění appky (není to zpráva od nikoho).
|
||||||
|
final MessageNotice? notice;
|
||||||
|
|
||||||
const Message({
|
const Message({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.senderId,
|
required this.senderId,
|
||||||
@@ -85,8 +100,30 @@ class Message {
|
|||||||
this.poll,
|
this.poll,
|
||||||
this.mentions = const [],
|
this.mentions = const [],
|
||||||
this.callLog,
|
this.callLog,
|
||||||
|
this.notice,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Upozornění appky v konverzaci [contactId]. Odesílatel = kontakt, aby
|
||||||
|
/// se konverzace v seznamu ukázala jako nepřečtená a upozornění si
|
||||||
|
/// uživatel nepřehlédl.
|
||||||
|
factory Message.notice({
|
||||||
|
required String contactId,
|
||||||
|
required String myId,
|
||||||
|
required MessageNotice notice,
|
||||||
|
String? content,
|
||||||
|
}) {
|
||||||
|
return Message(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
senderId: contactId,
|
||||||
|
recipientId: myId,
|
||||||
|
type: MessageType.text,
|
||||||
|
content: content,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
isDelivered: true,
|
||||||
|
notice: notice,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Vytvoř textovou zprávu
|
/// Vytvoř textovou zprávu
|
||||||
factory Message.text({
|
factory Message.text({
|
||||||
required String senderId,
|
required String senderId,
|
||||||
@@ -191,6 +228,7 @@ class Message {
|
|||||||
poll: poll ?? this.poll,
|
poll: poll ?? this.poll,
|
||||||
mentions: mentions,
|
mentions: mentions,
|
||||||
callLog: callLog,
|
callLog: callLog,
|
||||||
|
notice: notice,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +269,7 @@ class Message {
|
|||||||
'poll': poll?.toJson(),
|
'poll': poll?.toJson(),
|
||||||
if (mentions.isNotEmpty) 'mentions': mentions,
|
if (mentions.isNotEmpty) 'mentions': mentions,
|
||||||
if (callLog != null) 'call': callLog!.toJson(),
|
if (callLog != null) 'call': callLog!.toJson(),
|
||||||
|
if (notice != null) 'notice': notice!.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
static Map<String, int> _timesToJson(Map<String, DateTime> times) =>
|
static Map<String, int> _timesToJson(Map<String, DateTime> times) =>
|
||||||
@@ -282,6 +321,7 @@ class Message {
|
|||||||
poll: Poll.fromJson(json['poll']),
|
poll: Poll.fromJson(json['poll']),
|
||||||
mentions: mentionsFromWire(json['mentions']),
|
mentions: mentionsFromWire(json['mentions']),
|
||||||
callLog: CallLog.fromJson(json['call']),
|
callLog: CallLog.fromJson(json['call']),
|
||||||
|
notice: MessageNotice.values.asNameMap()[json['notice']],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,19 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
|
|||||||
throw const ContactLinkIncompleteError();
|
throw const ContactLinkIncompleteError();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kontrola duplicity
|
// Kontrola duplicity. Výjimka: žádost o zprávu od téhož klíče — ruční
|
||||||
if (contacts.any((c) => c.publicIdentity.id == identity.id)) {
|
// přidání (QR, odkaz) je právě to přijetí, na které čekala.
|
||||||
throw const ContactAlreadyExistsError();
|
final existing =
|
||||||
|
contacts.where((c) => c.publicIdentity.id == identity.id).firstOrNull;
|
||||||
|
if (existing != null) {
|
||||||
|
if (existing.accepted) throw const ContactAlreadyExistsError();
|
||||||
|
await accept(existing.id,
|
||||||
|
name: identity.displayName.isEmpty
|
||||||
|
? existing.displayName
|
||||||
|
: identity.displayName);
|
||||||
|
if (verified) existing.verified = true;
|
||||||
|
await storage.saveContact(existing);
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
final contact = Contact.fromPublicIdentity(identity, verified: verified);
|
final contact = Contact.fromPublicIdentity(identity, verified: verified);
|
||||||
@@ -38,6 +48,20 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
|
|||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Přijme žádost o zprávu pod jménem, které jí dal uživatel (jméno
|
||||||
|
/// zvolené odesílatelem se tím nepřebírá automaticky).
|
||||||
|
Future<void> accept(String contactId, {required String name}) async {
|
||||||
|
final storage = ref.read(storageProvider);
|
||||||
|
final contacts = await future;
|
||||||
|
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||||
|
if (contact == null) return;
|
||||||
|
contact
|
||||||
|
..displayName = name.trim()
|
||||||
|
..accepted = true;
|
||||||
|
await storage.saveContact(contact);
|
||||||
|
state = AsyncData([...contacts]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Smaž kontakt
|
/// Smaž kontakt
|
||||||
Future<void> removeContact(String contactId) async {
|
Future<void> removeContact(String contactId) async {
|
||||||
final storage = ref.read(storageProvider);
|
final storage = ref.read(storageProvider);
|
||||||
|
|||||||
@@ -341,8 +341,10 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
|||||||
|
|
||||||
// Příchozí zprávy, které jsem právě přečetl → pošlu za ně potvrzení
|
// Příchozí zprávy, které jsem právě přečetl → pošlu za ně potvrzení
|
||||||
// přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá).
|
// přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá).
|
||||||
|
// Upozornění appky (notice) nikdo neposlal — potvrzovat ho nelze.
|
||||||
final justRead = conv.messages
|
final justRead = conv.messages
|
||||||
.where((m) => !m.isMine(identity.id) && !m.isRead)
|
.where((m) =>
|
||||||
|
!m.isMine(identity.id) && !m.isRead && m.notice == null)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
conv.markAllRead(identity.id);
|
conv.markAllRead(identity.id);
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ final avatarProvider =
|
|||||||
final me = ref.watch(identityProvider).valueOrNull;
|
final me = ref.watch(identityProvider).valueOrNull;
|
||||||
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||||
if (!service.isStarted) return null;
|
if (!service.isStarted) return null;
|
||||||
|
final contact = contacts.where((c) => c.id == fingerprint).firstOrNull;
|
||||||
|
// Fotka neznámého odesílatele může být fotka mámy z Facebooku — do
|
||||||
|
// přijetí jen iniciály na tónu z klíče, ne jeho obrázek.
|
||||||
final avatar = fingerprint == me?.id
|
final avatar = fingerprint == me?.id
|
||||||
? service.myAvatar
|
? service.myAvatar
|
||||||
: contacts.where((c) => c.id == fingerprint).firstOrNull?.avatar;
|
: contact != null && contact.accepted
|
||||||
|
? contact.avatar
|
||||||
|
: null;
|
||||||
return avatar == null ? null : service.loadAvatar(avatar);
|
return avatar == null ? null : service.loadAvatar(avatar);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +88,15 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
|||||||
final conv = await ref.read(storageProvider).loadConversation(convId);
|
final conv = await ref.read(storageProvider).loadConversation(convId);
|
||||||
final mentionedMe = message.mentions.contains(identity.id);
|
final mentionedMe = message.mentions.contains(identity.id);
|
||||||
if (conv?.isMuted == true && !mentionedMe) return;
|
if (conv?.isMuted == true && !mentionedMe) return;
|
||||||
|
// Jméno, které si neznámý zvolil sám, nepatří do notifikace —
|
||||||
|
// „Nová zpráva od Máma“ by byl phishing ještě před otevřením appky.
|
||||||
|
if (contact.accepted) {
|
||||||
PushNotifier.showMessageNotification(contact.displayName);
|
PushNotifier.showMessageNotification(contact.displayName);
|
||||||
|
} else if (convId != contact.id) {
|
||||||
|
PushNotifier.showWakeNotification();
|
||||||
|
} else {
|
||||||
|
PushNotifier.showMessageRequestNotification();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
service.onCallSignal = (contact, signal) =>
|
service.onCallSignal = (contact, signal) =>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
import 'package:sepot/models/identity.dart';
|
||||||
|
|
||||||
|
/// Jméno zvolené odesílatelem se nesmí dát „obejít“ diakritikou, velikostí
|
||||||
|
/// písmen, cizí abecedou ani neviditelnými znaky.
|
||||||
|
void main() {
|
||||||
|
Contact kontakt(String jmeno, {bool accepted = true, int seed = 1}) =>
|
||||||
|
Contact.fromPublicIdentity(
|
||||||
|
PublicIdentity(
|
||||||
|
displayName: jmeno,
|
||||||
|
identityKey: Uint8List.fromList(
|
||||||
|
[0x05, ...List.generate(32, (i) => (i * seed + 3) & 0xff)]),
|
||||||
|
createdAt: DateTime.utc(2026),
|
||||||
|
),
|
||||||
|
accepted: accepted,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('stejná kostra pro varianty téhož jména', () {
|
||||||
|
final mama = nameSkeleton('Máma');
|
||||||
|
for (final varianta in [
|
||||||
|
'mama',
|
||||||
|
'MAMA',
|
||||||
|
' Má ma ',
|
||||||
|
'Máma!',
|
||||||
|
'M\u00E1\u200Bma',
|
||||||
|
'\u041C\u00E1\u043C\u0430', // cyrilské М, м, а
|
||||||
|
'Ma\u0301ma', // kombinující čárka
|
||||||
|
'Mámа\u202E',
|
||||||
|
]) {
|
||||||
|
expect(nameSkeleton(varianta), mama, reason: varianta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homoglyfy l/I/1 a rn/m', () {
|
||||||
|
expect(nameSkeleton('Pavel'), nameSkeleton('PaveI'));
|
||||||
|
expect(nameSkeleton('Pavel'), nameSkeleton('Pave1'));
|
||||||
|
expect(nameSkeleton('Marek'), nameSkeleton('rnarek'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('různá jména se nepletou', () {
|
||||||
|
expect(nameSkeleton('Máma'), isNot(nameSkeleton('Táta')));
|
||||||
|
expect(nameSkeleton('Jana'), isNot(nameSkeleton('Jan')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('porovnává jen s přijatými kontakty a ne se sebou', () {
|
||||||
|
final mama = kontakt('Máma');
|
||||||
|
final zadost = kontakt('MAMA', accepted: false, seed: 2);
|
||||||
|
expect(lookalikeContact('mama', [mama, zadost]), same(mama));
|
||||||
|
expect(lookalikeContact('mama', [zadost]), isNull);
|
||||||
|
expect(lookalikeContact('mama', [mama], exceptId: mama.id), isNull);
|
||||||
|
expect(lookalikeContact('', [kontakt('')]), isNull,
|
||||||
|
reason: 'prázdné jméno nic nenese');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// Potřebuje nativní libsignal (FFI) — na builderu bez Rust toolchainu se vynechává (--exclude-tags ffi).
|
||||||
|
@Tags(['ffi'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:libsignal/libsignal.dart' show ProtocolAddress;
|
||||||
|
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||||
|
import 'package:sepot/core/services/messaging_service.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/contact.dart';
|
||||||
|
import 'package:sepot/models/conversation.dart';
|
||||||
|
import 'package:sepot/models/identity.dart';
|
||||||
|
import 'package:sepot/models/message.dart';
|
||||||
|
|
||||||
|
/// Phishing „Máma“: cizí klíč se jménem existujícího kontaktu nesmí
|
||||||
|
/// splynout s kontaktem ani převzít jeho jméno, ověření nebo hovory.
|
||||||
|
/// Změna klíče pod adresou známého kontaktu ruší ověření a varuje.
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
late CryptoDb db;
|
||||||
|
late SecureStorageService storage;
|
||||||
|
late MessagingService service;
|
||||||
|
late LocalIdentity ja;
|
||||||
|
late LocalIdentity mama;
|
||||||
|
late Contact mamaContact;
|
||||||
|
final rng = Random.secure();
|
||||||
|
final sent = <(String, Map<String, dynamic>)>[];
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
Uint8List randomBytes(int n) =>
|
||||||
|
Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
|
||||||
|
|
||||||
|
const mamaAccount = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
|
|
||||||
|
setUpAll(() async => SepotCrypto.init());
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('sepot_request');
|
||||||
|
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomBytes(32));
|
||||||
|
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
|
||||||
|
..debugAttachDb(db);
|
||||||
|
service = MessagingService(storage: storage);
|
||||||
|
ja = await LocalIdentity.create('Já');
|
||||||
|
service.debugSetIdentity(ja);
|
||||||
|
sent.clear();
|
||||||
|
calls.clear();
|
||||||
|
service.debugSendTap = (to, content) => sent.add((to, content));
|
||||||
|
service.onCallSignal = (contact, _) => calls.add(contact.id);
|
||||||
|
|
||||||
|
mama = await LocalIdentity.create('Máma');
|
||||||
|
mamaContact = Contact.fromPublicIdentity(
|
||||||
|
mama.publicIdentity
|
||||||
|
.withRouting(accountId: mamaAccount, deliveryAddress: randomBytes(32)),
|
||||||
|
verified: true,
|
||||||
|
);
|
||||||
|
await storage.saveContact(mamaContact);
|
||||||
|
await storage.saveConversation(Conversation(contactId: mamaContact.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
db.close();
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Obálka od [from] po dešifrování (sealed sender už ověřil klíč a účet).
|
||||||
|
Future<void> deliver(
|
||||||
|
LocalIdentity from,
|
||||||
|
String account,
|
||||||
|
Map<String, dynamic> content,
|
||||||
|
) =>
|
||||||
|
service.debugProcessOpened(
|
||||||
|
SepotSealedMessage(
|
||||||
|
plaintext: utf8.encode(jsonEncode({'v': 1, ...content})),
|
||||||
|
senderName: account,
|
||||||
|
senderDeviceId: 1,
|
||||||
|
senderIdentityKey: from.publicIdentity.identityKey,
|
||||||
|
),
|
||||||
|
randomBytes(16),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> text(String name, String txt) => {
|
||||||
|
't': 'txt',
|
||||||
|
'id': 'm-${rng.nextInt(1 << 30)}',
|
||||||
|
'txt': txt,
|
||||||
|
'p': {'n': name, 'da': base64Encode(randomBytes(32))},
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Contact> contactOf(LocalIdentity id) async =>
|
||||||
|
(await storage.loadContacts()).singleWhere((c) => c.id == id.id);
|
||||||
|
|
||||||
|
test('cizí klíč se jménem „Máma“ = žádost o zprávu, ne splynutí', () async {
|
||||||
|
final podvrh = await LocalIdentity.create('Máma');
|
||||||
|
await deliver(podvrh, 'b' * 32, text('Máma', 'Pošli mi kód z SMS'));
|
||||||
|
|
||||||
|
final kontakty = await storage.loadContacts();
|
||||||
|
expect(kontakty, hasLength(2), reason: 'nový klíč = nový kontakt');
|
||||||
|
|
||||||
|
final skutecna = await contactOf(mama);
|
||||||
|
expect(skutecna.displayName, 'Máma');
|
||||||
|
expect(skutecna.verified, isTrue, reason: 'cizí klíč ověření nemění');
|
||||||
|
expect(skutecna.publicIdentity.identityKey, mama.publicIdentity.identityKey);
|
||||||
|
|
||||||
|
final zadost = await contactOf(podvrh);
|
||||||
|
expect(zadost.accepted, isFalse);
|
||||||
|
expect(zadost.verified, isFalse);
|
||||||
|
|
||||||
|
final zprava = (await storage.loadConversation(zadost.id))!.messages.single;
|
||||||
|
expect(zprava.content, 'Pošli mi kód z SMS');
|
||||||
|
expect(zprava.senderId, podvrh.id, reason: 'zpráva patří podvrhu');
|
||||||
|
|
||||||
|
final upozorneni =
|
||||||
|
(await storage.loadConversation(skutecna.id))!.messages.single;
|
||||||
|
expect(upozorneni.notice, MessageNotice.lookalikeRequest);
|
||||||
|
expect(upozorneni.content, 'Máma');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('podobné jméno (cyrilice, velká písmena, mezery) se taky pozná',
|
||||||
|
() async {
|
||||||
|
final podvrh = await LocalIdentity.create('x');
|
||||||
|
await deliver(podvrh, 'c' * 32, text(' МÁМА ', 'ahoj'));
|
||||||
|
final upozorneni =
|
||||||
|
(await storage.loadConversation(mamaContact.id))!.messages.single;
|
||||||
|
expect(upozorneni.notice, MessageNotice.lookalikeRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stejný účet na relayi s jiným klíčem = změna bezpečnostního kódu',
|
||||||
|
() async {
|
||||||
|
final novyKlic = await LocalIdentity.create('Máma');
|
||||||
|
await deliver(novyKlic, mamaAccount, text('Máma', 'nový telefon'));
|
||||||
|
|
||||||
|
final skutecna = await contactOf(mama);
|
||||||
|
expect(skutecna.verified, isFalse, reason: 'ověření se ruší');
|
||||||
|
expect(skutecna.displayName, 'Máma');
|
||||||
|
final upozorneni =
|
||||||
|
(await storage.loadConversation(skutecna.id))!.messages.single;
|
||||||
|
expect(upozorneni.notice, MessageNotice.identityChanged);
|
||||||
|
|
||||||
|
final zadost = await contactOf(novyKlic);
|
||||||
|
expect(zadost.accepted, isFalse, reason: 'nový klíč se nepřebírá');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opakovaná změna nepřidává další a další upozornění', () async {
|
||||||
|
final novyKlic = await LocalIdentity.create('Máma');
|
||||||
|
await deliver(novyKlic, mamaAccount, text('Máma', '1'));
|
||||||
|
await deliver(novyKlic, mamaAccount, text('Máma', '2'));
|
||||||
|
final zpravy = (await storage.loadConversation(mamaContact.id))!.messages;
|
||||||
|
expect(zpravy.where((m) => m.notice != null), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('žádost o zprávu nezvoní, přijatý kontakt ano', () async {
|
||||||
|
final cizi = await LocalIdentity.create('Máma');
|
||||||
|
await deliver(cizi, 'd' * 32, text('Máma', 'ahoj'));
|
||||||
|
await deliver(cizi, 'd' * 32, {'t': 'call', 'id': 'c1', 'k': 'offer'});
|
||||||
|
expect(calls, isEmpty);
|
||||||
|
|
||||||
|
await deliver(mama, mamaAccount, {'t': 'call', 'id': 'c2', 'k': 'offer'});
|
||||||
|
expect(calls, [mama.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neznámý odesílatel nedostane potvrzení přečtení', () async {
|
||||||
|
await storage.setSetting('read_receipts_enabled', true);
|
||||||
|
final cizi = await LocalIdentity.create('Někdo');
|
||||||
|
await deliver(cizi, 'e' * 32, text('Někdo', 'ahoj'));
|
||||||
|
final zadost = await contactOf(cizi);
|
||||||
|
sent.clear();
|
||||||
|
await service.sendReadReceipts(zadost, ['m1']);
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
|
||||||
|
await service.sendReadReceipts(await contactOf(mama), ['m2']);
|
||||||
|
expect(sent.single.$2['t'], 'rr');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('známý kontakt dál chodí do své konverzace beze změny', () async {
|
||||||
|
await deliver(mama, mamaAccount, text('Úplně jiné jméno', 'ahoj'));
|
||||||
|
final kontakty = await storage.loadContacts();
|
||||||
|
expect(kontakty, hasLength(1));
|
||||||
|
expect(kontakty.single.displayName, 'Máma',
|
||||||
|
reason: 'jméno z profilu odesílatele nepřepisuje moje');
|
||||||
|
expect(kontakty.single.accepted, isTrue);
|
||||||
|
expect(kontakty.single.verified, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kontakt uložený před 1.2.3 (bez příznaku) je přijatý', () {
|
||||||
|
final json = mamaContact.toJson()..remove('accepted');
|
||||||
|
expect(Contact.fromJson(json).accepted, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('odesílání: relace patřící cizímu klíči', () {
|
||||||
|
late SepotCrypto ja2;
|
||||||
|
late ProtocolAddress addr;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
addr = ProtocolAddress(name: mamaAccount, deviceId: 1);
|
||||||
|
ja2 = SepotCrypto.fromPrivateKey(
|
||||||
|
privateKey: ja.identityPrivateKey,
|
||||||
|
accountId: 'f' * 32,
|
||||||
|
registrationId: 7,
|
||||||
|
storesFactory: (pair) => SepotStores(
|
||||||
|
session: DbSessionStore(db),
|
||||||
|
identity: DbIdentityKeyStore(db,
|
||||||
|
identityKeyPair: pair, registrationId: 7),
|
||||||
|
preKey: DbPreKeyStore(db),
|
||||||
|
signedPreKey: DbSignedPreKeyStore(db),
|
||||||
|
kyberPreKey: DbKyberPreKeyStore(db),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<SepotPreKeyBundle> bundleOf(LocalIdentity id) =>
|
||||||
|
SepotCrypto.fromPrivateKey(
|
||||||
|
privateKey: id.identityPrivateKey,
|
||||||
|
accountId: mamaAccount,
|
||||||
|
registrationId: 9,
|
||||||
|
).generatePreKeyBundle(
|
||||||
|
preKeyId: 1,
|
||||||
|
signedPreKeyId: 1,
|
||||||
|
kyberPreKeyId: 1,
|
||||||
|
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('relace s klíčem kontaktu zůstává', () async {
|
||||||
|
await ja2.processRemoteBundle(addr, await bundleOf(mama));
|
||||||
|
await service.debugDropForeignSession(ja2, addr, mamaContact);
|
||||||
|
expect(await ja2.hasSession(addr), isTrue);
|
||||||
|
expect((await contactOf(mama)).verified, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relace přepsaná cizím klíčem se zahodí a kontakt varuje', () async {
|
||||||
|
await ja2.processRemoteBundle(addr, await bundleOf(mama));
|
||||||
|
final utocnik = await LocalIdentity.create('Máma');
|
||||||
|
await ja2.processRemoteBundle(addr, await bundleOf(utocnik));
|
||||||
|
|
||||||
|
await service.debugDropForeignSession(ja2, addr, mamaContact);
|
||||||
|
|
||||||
|
expect(await ja2.hasSession(addr), isFalse,
|
||||||
|
reason: 'odpověď nesmí odejít útočníkovi');
|
||||||
|
final kontakt = await contactOf(mama);
|
||||||
|
expect(kontakt.verified, isFalse);
|
||||||
|
final upozorneni =
|
||||||
|
(await storage.loadConversation(kontakt.id))!.messages.single;
|
||||||
|
expect(upozorneni.notice, MessageNotice.identityChanged);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user