Šepot — end-to-end encrypted messenger
Initial public release. Anonymous on-device identity (no phone number, e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double Ratchet, sealed sender) via libsignal, a metadata-minimizing relay, group chats, disappearing messages, encrypted local storage, optional Tor transport, and a UI localized into Czech, English, German, Russian, and Italian. Monorepo: Flutter client (app/), Rust relay + wire types (rust/), protobuf definitions (proto/), self-host deploy (deploy/). Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde).
|
||||
enum AttachmentTransferState {
|
||||
/// Přijatá zpráva, blob se ještě nezačal stahovat.
|
||||
pending,
|
||||
downloading,
|
||||
|
||||
/// Blob leží (šifrovaný) v lokálním AttachmentStore.
|
||||
ready,
|
||||
|
||||
/// Download/ověření selhalo — UI nabídne opakování.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Metadata přílohy. Klíč/nonce/digest dorazily E2E kanálem (relay je
|
||||
/// nevidí); v lokální DB žijí uvnitř SQLCipher, takže at-rest jsou
|
||||
/// chráněné stejně jako zbytek zpráv.
|
||||
///
|
||||
/// Wire tvar (pole `att` v content JSON, krátké klíče kvůli obálce):
|
||||
/// { bid, k, n, d, s, mime, name?, w?, h?, th? }
|
||||
class AttachmentMeta {
|
||||
const AttachmentMeta({
|
||||
required this.blobId,
|
||||
required this.keyB64,
|
||||
required this.nonceB64,
|
||||
required this.digestB64,
|
||||
required this.size,
|
||||
required this.mime,
|
||||
this.name,
|
||||
this.width,
|
||||
this.height,
|
||||
this.thumbB64,
|
||||
this.durationMs,
|
||||
this.state = AttachmentTransferState.pending,
|
||||
});
|
||||
|
||||
final String blobId; // 32 hex znaků (id na relayi i lokálně)
|
||||
final String keyB64; // 32 B per-file AES klíč
|
||||
final String nonceB64; // 12 B GCM nonce
|
||||
final String digestB64; // SHA-256 šifrovaného blobu
|
||||
final int size; // velikost PLAINTEXTU v bajtech
|
||||
final String mime;
|
||||
final String? name; // původní název souboru (dokumenty)
|
||||
final int? width; // rozměry obrázku
|
||||
final int? height;
|
||||
final String? thumbB64; // JPEG náhled — jede UVNITŘ E2E plaintextu
|
||||
final int? durationMs; // délka hlasové zprávy / audia
|
||||
final AttachmentTransferState state;
|
||||
|
||||
bool get isImage => mime.startsWith('image/');
|
||||
bool get isAudio => mime.startsWith('audio/');
|
||||
|
||||
Uint8List get key => base64Decode(keyB64);
|
||||
Uint8List get nonce => base64Decode(nonceB64);
|
||||
Uint8List get digest => base64Decode(digestB64);
|
||||
Uint8List? get thumbnail =>
|
||||
thumbB64 == null ? null : base64Decode(thumbB64!);
|
||||
|
||||
AttachmentMeta copyWith({AttachmentTransferState? state}) {
|
||||
return AttachmentMeta(
|
||||
blobId: blobId,
|
||||
keyB64: keyB64,
|
||||
nonceB64: nonceB64,
|
||||
digestB64: digestB64,
|
||||
size: size,
|
||||
mime: mime,
|
||||
name: name,
|
||||
width: width,
|
||||
height: height,
|
||||
thumbB64: thumbB64,
|
||||
durationMs: durationMs,
|
||||
state: state ?? this.state,
|
||||
);
|
||||
}
|
||||
|
||||
/// Do content JSON zprávy (bez lokálního stavu).
|
||||
Map<String, dynamic> toWire() => {
|
||||
'bid': blobId,
|
||||
'k': keyB64,
|
||||
'n': nonceB64,
|
||||
'd': digestB64,
|
||||
's': size,
|
||||
'mime': mime,
|
||||
if (name != null) 'name': name,
|
||||
if (width != null) 'w': width,
|
||||
if (height != null) 'h': height,
|
||||
if (thumbB64 != null) 'th': thumbB64,
|
||||
if (durationMs != null) 'dur': durationMs,
|
||||
};
|
||||
|
||||
/// Z content JSON příchozí zprávy. Remote input — tvary se validují
|
||||
/// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy).
|
||||
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
|
||||
final size = wire['s'] as int;
|
||||
if (size <= 0 || size > 512 * 1024 * 1024) {
|
||||
throw const FormatException('implausible attachment size');
|
||||
}
|
||||
return AttachmentMeta(
|
||||
blobId: wire['bid'] as String,
|
||||
keyB64: wire['k'] as String,
|
||||
nonceB64: wire['n'] as String,
|
||||
digestB64: wire['d'] as String,
|
||||
size: size,
|
||||
mime: wire['mime'] as String,
|
||||
name: wire['name'] as String?,
|
||||
width: wire['w'] as int?,
|
||||
height: wire['h'] as int?,
|
||||
thumbB64: wire['th'] as String?,
|
||||
durationMs: wire['dur'] as int?,
|
||||
state: AttachmentTransferState.pending,
|
||||
);
|
||||
}
|
||||
|
||||
/// Lokální perzistence (SQLCipher) — wire tvar + stav.
|
||||
Map<String, dynamic> toJson() => {
|
||||
...toWire(),
|
||||
'state': state.name,
|
||||
};
|
||||
|
||||
factory AttachmentMeta.fromJson(Map<String, dynamic> json) {
|
||||
final base = AttachmentMeta.fromWire(json);
|
||||
final stateName = json['state'] as String?;
|
||||
return base.copyWith(
|
||||
state: AttachmentTransferState.values.asNameMap()[stateName] ??
|
||||
AttachmentTransferState.pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:sepot/models/identity.dart';
|
||||
|
||||
/// Kontakt — osoba, se kterou můžeme chatovat
|
||||
///
|
||||
/// Odpovídá Rust: Contact
|
||||
class Contact {
|
||||
String displayName;
|
||||
final PublicIdentity publicIdentity;
|
||||
final DateTime addedAt;
|
||||
bool verified;
|
||||
String? notes;
|
||||
|
||||
/// Blokovaný kontakt: příchozí zprávy se zahodí (ACKnou, ale neuloží ani
|
||||
/// nezobrazí) a odesílání je v UI zablokované.
|
||||
bool blocked;
|
||||
|
||||
Contact({
|
||||
required this.displayName,
|
||||
required this.publicIdentity,
|
||||
required this.addedAt,
|
||||
this.verified = false,
|
||||
this.notes,
|
||||
this.blocked = false,
|
||||
});
|
||||
|
||||
/// ID kontaktu = fingerprint identity klíče (identita = klíč).
|
||||
///
|
||||
/// Dřív náhodné UUID — vznikly tím DVĚ klíčové soustavy (konverzace se
|
||||
/// ukládaly pod UUID, UI ale routovalo fingerprintem), takže konverzace
|
||||
/// otevřená z kontaktů neuměla odeslat zprávu a chat list nenašel jméno.
|
||||
/// Bez datové migrace (žádná produkční data; testovací zařízení se
|
||||
/// re-párují — stejný precedens jako S9).
|
||||
String get id => publicIdentity.id;
|
||||
|
||||
/// Vytvoř nový kontakt z veřejné identity
|
||||
factory Contact.fromPublicIdentity(
|
||||
PublicIdentity identity, {
|
||||
bool verified = false,
|
||||
}) {
|
||||
return Contact(
|
||||
displayName: identity.displayName,
|
||||
publicIdentity: identity,
|
||||
addedAt: DateTime.now(),
|
||||
verified: verified,
|
||||
);
|
||||
}
|
||||
|
||||
/// Šepot ID kontaktu
|
||||
String get sepotId => publicIdentity.sepotId;
|
||||
|
||||
/// Otisk kontaktu (plné Crockford base32 zobrazení)
|
||||
String get fingerprint => publicIdentity.fingerprint.display;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'displayName': displayName,
|
||||
'publicIdentity': publicIdentity.toJson(),
|
||||
'addedAt': addedAt.toIso8601String(),
|
||||
'verified': verified,
|
||||
'notes': notes,
|
||||
'blocked': blocked,
|
||||
};
|
||||
|
||||
factory Contact.fromJson(Map<String, dynamic> json) {
|
||||
return Contact(
|
||||
displayName: json['displayName'] as String,
|
||||
publicIdentity: PublicIdentity.fromJson(
|
||||
json['publicIdentity'] as Map<String, dynamic>,
|
||||
),
|
||||
addedAt: DateTime.parse(json['addedAt'] as String),
|
||||
verified: json['verified'] as bool? ?? false,
|
||||
notes: json['notes'] as String?,
|
||||
blocked: json['blocked'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Konverzace s kontaktem
|
||||
///
|
||||
/// Odpovídá Rust: Conversation
|
||||
class Conversation {
|
||||
final String contactId;
|
||||
final List<Message> messages;
|
||||
DateTime lastActivity;
|
||||
int? disappearAfterSeconds; // Globální nastavení mizících zpráv
|
||||
|
||||
/// Ztlumení a archivace — čistě LOKÁLNÍ (neopouští zařízení).
|
||||
bool muted;
|
||||
DateTime? mutedUntil; // null + muted = navždy; jinak dočasně
|
||||
bool archived;
|
||||
|
||||
Conversation({
|
||||
required this.contactId,
|
||||
List<Message>? messages,
|
||||
DateTime? lastActivity,
|
||||
this.disappearAfterSeconds,
|
||||
this.muted = false,
|
||||
this.mutedUntil,
|
||||
this.archived = false,
|
||||
}) : messages = messages ?? [],
|
||||
lastActivity = lastActivity ?? DateTime.now();
|
||||
|
||||
/// Je konverzace teď ztlumená? (dočasné ztlumení sama vyprší)
|
||||
bool get isMuted =>
|
||||
muted && (mutedUntil == null || mutedUntil!.isAfter(DateTime.now()));
|
||||
|
||||
/// Poslední zpráva
|
||||
Message? get lastMessage => messages.isEmpty ? null : messages.last;
|
||||
|
||||
/// Počet nepřečtených zpráv
|
||||
int unreadCount(String myId) {
|
||||
return messages
|
||||
.where((m) => !m.isRead && m.senderId != myId)
|
||||
.length;
|
||||
}
|
||||
|
||||
/// Přidej zprávu
|
||||
void addMessage(Message message) {
|
||||
messages.add(message);
|
||||
lastActivity = DateTime.now();
|
||||
}
|
||||
|
||||
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
|
||||
/// Neexistující id se tiše ignoruje (zpráva mohla být smazána).
|
||||
void updateMessage(Message message) {
|
||||
final index = messages.indexWhere((m) => m.id == message.id);
|
||||
if (index >= 0) messages[index] = message;
|
||||
}
|
||||
|
||||
/// Označ všechny zprávy jako přečtené
|
||||
void markAllRead(String myId) {
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
if (!messages[i].isRead && messages[i].senderId != myId) {
|
||||
messages[i] = messages[i].copyWith(isRead: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'contactId': contactId,
|
||||
'messages': messages.map((m) => m.toJson()).toList(),
|
||||
'lastActivity': lastActivity.toIso8601String(),
|
||||
'disappearAfterSeconds': disappearAfterSeconds,
|
||||
'muted': muted,
|
||||
'mutedUntil': mutedUntil?.toIso8601String(),
|
||||
'archived': archived,
|
||||
};
|
||||
|
||||
factory Conversation.fromJson(Map<String, dynamic> json) {
|
||||
return Conversation(
|
||||
contactId: json['contactId'] as String,
|
||||
messages: (json['messages'] as List<dynamic>?)
|
||||
?.map((m) => Message.fromJson(m as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
lastActivity: DateTime.parse(json['lastActivity'] as String),
|
||||
disappearAfterSeconds: json['disappearAfterSeconds'] as int?,
|
||||
// Defaulty — legacy JSON bez těchto klíčů musí projít
|
||||
muted: json['muted'] as bool? ?? false,
|
||||
mutedUntil: json['mutedUntil'] == null
|
||||
? null
|
||||
: DateTime.parse(json['mutedUntil'] as String),
|
||||
archived: json['archived'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/// Skupina — párový fanout MVP.
|
||||
///
|
||||
/// Drží jen jméno a fingerprinty členů; routing a identity klíče členů
|
||||
/// žijí v tabulce contacts (člen = běžný kontakt založený z rosteru).
|
||||
/// Konverzace skupiny je řádek v conversations s contact_id = [id].
|
||||
class Group {
|
||||
Group({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.memberIds,
|
||||
required this.createdBy,
|
||||
DateTime? createdAt,
|
||||
this.left = false,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
/// UUID v4 skupiny (= `gid` na drátu, = contact_id konverzace).
|
||||
final String id;
|
||||
|
||||
String name;
|
||||
|
||||
/// Fingerprinty členů VČETNĚ mě (kompletní roster).
|
||||
List<String> memberIds;
|
||||
|
||||
/// Fingerprint zakladatele.
|
||||
final String createdBy;
|
||||
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Opustil jsem skupinu — historie zůstává, vstup je mrtvý a příchozí
|
||||
/// zprávy skupiny se zahazují.
|
||||
bool left;
|
||||
|
||||
/// Strop členů (doporučení: 128).
|
||||
static const maxMembers = 128;
|
||||
|
||||
bool isMember(String fingerprint) => memberIds.contains(fingerprint);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'memberIds': memberIds,
|
||||
'createdBy': createdBy,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'left': left,
|
||||
};
|
||||
|
||||
factory Group.fromJson(Map<String, dynamic> json) => Group(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
memberIds:
|
||||
(json['memberIds'] as List<dynamic>).cast<String>().toList(),
|
||||
createdBy: json['createdBy'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
left: json['left'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:base32/base32.dart';
|
||||
import 'package:base32/encodings.dart';
|
||||
import 'package:pointycastle/digests/blake2b.dart';
|
||||
import 'package:sepot/core/crypto/mnemonic.dart';
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
|
||||
/// Fingerprint identity — jediné user-facing ID Šepotu.
|
||||
///
|
||||
/// fingerprint = BLAKE2b-256( serializovaný identity pubkey, 33 B )
|
||||
///
|
||||
/// Zobrazuje se jako Crockford base32 (52 znaků), zkráceně „SEP-XXXXXXXXXX".
|
||||
/// POZOR: NEPLÉST s `account_id` — to je náhodný 16B routing handle, který
|
||||
/// přiděluje relay při registraci a nemá k identitě kryptografický vztah.
|
||||
class SepotFingerprint {
|
||||
SepotFingerprint(this.bytes)
|
||||
: assert(bytes.length == 32, 'BLAKE2b-256 = 32 bytes');
|
||||
|
||||
/// Spočítá fingerprint ze serializovaného veřejného identity klíče.
|
||||
factory SepotFingerprint.ofPublicKey(Uint8List identityPublicKey) {
|
||||
final digest = Blake2bDigest(digestSize: 32);
|
||||
return SepotFingerprint(digest.process(identityPublicKey));
|
||||
}
|
||||
|
||||
final Uint8List bytes;
|
||||
|
||||
/// Plné zobrazení — 52 znaků Crockford base32, bez paddingu.
|
||||
String get display =>
|
||||
base32.encode(bytes, encoding: Encoding.crockford).toUpperCase();
|
||||
|
||||
/// UX zkratka do titulků, QR popisků a seznamů.
|
||||
String get short => 'SEP-${display.substring(0, 10)}';
|
||||
|
||||
@override
|
||||
String toString() => display;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is SepotFingerprint && display == other.display;
|
||||
|
||||
@override
|
||||
int get hashCode => display.hashCode;
|
||||
}
|
||||
|
||||
/// Lokální identita uživatele — identity keypair + zobrazované jméno.
|
||||
///
|
||||
/// Identita = klíč. Žádné UUID, žádný telefon, žádný e-mail. Obnova
|
||||
/// přes 24slovný mnemonic (reverzibilní enkódování privátního klíče).
|
||||
class LocalIdentity {
|
||||
LocalIdentity({
|
||||
required this.displayName,
|
||||
required this.identityPrivateKey,
|
||||
required this.identityPublicKey,
|
||||
required this.createdAt,
|
||||
}) : fingerprint = SepotFingerprint.ofPublicKey(identityPublicKey);
|
||||
|
||||
final String displayName;
|
||||
|
||||
/// 32 B, serializovaný (clampnutý). Nikdy neopouští zařízení jinak než
|
||||
/// jako mnemonic záloha uživatele. ZÁKAZ kopírování do schránky.
|
||||
final Uint8List identityPrivateKey;
|
||||
|
||||
/// 33 B, serializovaný (libsignal type byte + 32 B).
|
||||
final Uint8List identityPublicKey;
|
||||
|
||||
final DateTime createdAt;
|
||||
final SepotFingerprint fingerprint;
|
||||
|
||||
/// Vytvoř novou identitu (onboarding).
|
||||
static Future<LocalIdentity> create(String displayName) async {
|
||||
final keys = SepotCrypto.generateIdentityKeys();
|
||||
return LocalIdentity(
|
||||
displayName: displayName,
|
||||
identityPrivateKey: keys.privateKey,
|
||||
identityPublicKey: keys.publicKey,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obnov identitu z 24slovného mnemonic (recovery flow).
|
||||
///
|
||||
/// Obnoví IDENTITU (stejný klíč → stejný fingerprint → „verified"
|
||||
/// kontakty drží), ne historii zpráv — ta je díky forward secrecy pryč
|
||||
/// a UI to musí říct.
|
||||
static Future<LocalIdentity> recover({
|
||||
required List<String> mnemonicWords,
|
||||
required String displayName,
|
||||
}) async {
|
||||
final privateKey = SepotMnemonic.decode(mnemonicWords);
|
||||
return LocalIdentity(
|
||||
displayName: displayName,
|
||||
identityPrivateKey: privateKey,
|
||||
identityPublicKey: SepotCrypto.derivePublicKey(privateKey),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 24 slov zálohy identity. Zobrazit jen v chráněném recovery UI.
|
||||
List<String> get mnemonic => SepotMnemonic.encode(identityPrivateKey);
|
||||
|
||||
/// Kompatibilní alias — dřív tu bylo UUID, teď je ID totožné
|
||||
/// s fingerprintem (deterministicky z klíče).
|
||||
String get id => fingerprint.display;
|
||||
|
||||
/// Šepot ID pro zobrazení v UI.
|
||||
String get sepotId => fingerprint.short;
|
||||
|
||||
/// Veřejná identita (bezpečné ke sdílení).
|
||||
PublicIdentity get publicIdentity => PublicIdentity(
|
||||
displayName: displayName,
|
||||
identityKey: identityPublicKey,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'v': 2,
|
||||
'displayName': displayName,
|
||||
'identityPrivateKey': base64Encode(identityPrivateKey),
|
||||
'identityPublicKey': base64Encode(identityPublicKey),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory LocalIdentity.fromJson(Map<String, dynamic> json) {
|
||||
if (json['v'] != 2) {
|
||||
throw const FormatException(
|
||||
'Unsupported identity format (pre-libsignal era) — new onboarding required',
|
||||
);
|
||||
}
|
||||
return LocalIdentity(
|
||||
displayName: json['displayName'] as String,
|
||||
identityPrivateKey: base64Decode(json['identityPrivateKey'] as String),
|
||||
identityPublicKey: base64Decode(json['identityPublicKey'] as String),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Veřejná identita — bezpečné ke sdílení, pro QR kódy a `sepot://` odkazy.
|
||||
///
|
||||
/// Vedle krypto identity (identity klíč → fingerprint) nese i ROUTING:
|
||||
/// `accountId` (náhodný server handle — pojmenovává libsignal session
|
||||
/// odesílatele, musí sedět se SenderCertificate) a `deliveryAddress`
|
||||
/// (32B blind token, podle kterého relay směruje obálky). Bez nich nejde
|
||||
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je
|
||||
/// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
|
||||
class PublicIdentity {
|
||||
PublicIdentity({
|
||||
required this.displayName,
|
||||
required this.identityKey,
|
||||
required this.createdAt,
|
||||
this.accountId,
|
||||
this.deliveryAddress,
|
||||
}) : fingerprint = SepotFingerprint.ofPublicKey(identityKey);
|
||||
|
||||
final String displayName;
|
||||
|
||||
/// 33 B, serializovaný veřejný identity klíč.
|
||||
final Uint8List identityKey;
|
||||
|
||||
final DateTime createdAt;
|
||||
final SepotFingerprint fingerprint;
|
||||
|
||||
/// Hex accountId na relayi (null = ještě neregistrováno / neznámo).
|
||||
final String? accountId;
|
||||
|
||||
/// 32 B routing token na relayi (null = neznámo).
|
||||
final Uint8List? deliveryAddress;
|
||||
|
||||
static const _linkPrefix = 'sepot://add/';
|
||||
|
||||
/// Kompatibilní alias na fingerprint (dřív UUID).
|
||||
String get id => fingerprint.display;
|
||||
|
||||
/// Šepot ID pro zobrazení.
|
||||
String get sepotId => fingerprint.short;
|
||||
|
||||
PublicIdentity withRouting({
|
||||
required String accountId,
|
||||
required Uint8List deliveryAddress,
|
||||
}) =>
|
||||
PublicIdentity(
|
||||
displayName: displayName,
|
||||
identityKey: identityKey,
|
||||
createdAt: createdAt,
|
||||
accountId: accountId,
|
||||
deliveryAddress: deliveryAddress,
|
||||
);
|
||||
|
||||
/// Export jako `sepot://add/...` odkaz (obsah QR kódu i sdílený link).
|
||||
String toShareString() {
|
||||
final payload = base64UrlEncode(
|
||||
utf8.encode(jsonEncode({
|
||||
'v': 2,
|
||||
'n': displayName,
|
||||
'ik': base64Encode(identityKey),
|
||||
'ts': createdAt.toIso8601String(),
|
||||
if (accountId != null) 'aid': accountId,
|
||||
if (deliveryAddress != null) 'da': base64Encode(deliveryAddress!),
|
||||
})),
|
||||
);
|
||||
return '$_linkPrefix$payload';
|
||||
}
|
||||
|
||||
/// Import z odkazu / QR. Vrací null, pokud vstup není platný.
|
||||
static PublicIdentity? fromShareString(String s) {
|
||||
try {
|
||||
var payload = s.trim();
|
||||
if (payload.startsWith(_linkPrefix)) {
|
||||
payload = payload.substring(_linkPrefix.length);
|
||||
}
|
||||
final map = jsonDecode(utf8.decode(base64Url.decode(payload)))
|
||||
as Map<String, dynamic>;
|
||||
if (map['v'] != 2) return null;
|
||||
final identityKey = base64Decode(map['ik'] as String);
|
||||
if (identityKey.length != 33) return null;
|
||||
final da = map['da'] as String?;
|
||||
Uint8List? deliveryAddress;
|
||||
if (da != null) {
|
||||
deliveryAddress = base64Decode(da);
|
||||
// deliveryAddress je 32B routing token — vadný odkaz odmítni
|
||||
// (stejně jako u identityKey), ať se do kontaktů nedostane nesmysl.
|
||||
if (deliveryAddress.length != 32) return null;
|
||||
}
|
||||
return PublicIdentity(
|
||||
displayName: map['n'] as String,
|
||||
identityKey: identityKey,
|
||||
createdAt: DateTime.parse(map['ts'] as String),
|
||||
accountId: map['aid'] as String?,
|
||||
deliveryAddress: deliveryAddress,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'v': 2,
|
||||
'displayName': displayName,
|
||||
'identityKey': base64Encode(identityKey),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'accountId': accountId,
|
||||
'deliveryAddress':
|
||||
deliveryAddress == null ? null : base64Encode(deliveryAddress!),
|
||||
};
|
||||
|
||||
factory PublicIdentity.fromJson(Map<String, dynamic> json) {
|
||||
if (json['v'] != 2) {
|
||||
throw const FormatException('Unsupported contact format');
|
||||
}
|
||||
final da = json['deliveryAddress'] as String?;
|
||||
return PublicIdentity(
|
||||
displayName: json['displayName'] as String,
|
||||
identityKey: base64Decode(json['identityKey'] as String),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
accountId: json['accountId'] as String?,
|
||||
deliveryAddress: da == null ? null : base64Decode(da),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'package:sepot/models/attachment.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Typy zpráv — odpovídá Rust: MessageType
|
||||
enum MessageType {
|
||||
text,
|
||||
image,
|
||||
file,
|
||||
readReceipt,
|
||||
typing,
|
||||
keyExchange,
|
||||
}
|
||||
|
||||
/// Stav odeslané (mojí) zprávy — poctivá hranice: read receipts zatím
|
||||
/// NEjsou (design: default OFF), takže „přečteno" se nezobrazuje.
|
||||
/// sending — lokálně zařazeno, čeká na potvrzení relaye (⏱)
|
||||
/// sent — relay přijal, příjemce byl offline → v jeho frontě (✓)
|
||||
/// delivered — doručeno na zařízení příjemce (✓✓)
|
||||
/// read — příjemce zprávu přečetl (✓✓ barevně; jen když obě strany
|
||||
/// mají zapnuté potvrzení přečtení)
|
||||
/// failed — odeslání selhalo (✕, klepnout pro opakování)
|
||||
enum MessageStatus { sending, sent, delivered, read, failed }
|
||||
|
||||
/// Zpráva v konverzaci
|
||||
///
|
||||
/// Odpovídá Rust: PlaintextMessage + StoredMessage
|
||||
class Message {
|
||||
final String id;
|
||||
final String senderId;
|
||||
final String recipientId;
|
||||
final MessageType type;
|
||||
final String? content; // Text zprávy (u příloh volitelný popisek)
|
||||
final String? fileName; // Pro typ file
|
||||
final int? fileSize;
|
||||
final String? replyToId;
|
||||
final DateTime timestamp;
|
||||
final bool isRead;
|
||||
final bool isDelivered;
|
||||
final MessageStatus status; // stav odeslání (jen u mých zpráv)
|
||||
final int? disappearAfterSeconds; // Mizící zprávy
|
||||
final AttachmentMeta? attachment; // image/file — klíč+blob_id+stav
|
||||
final DateTime? editedAt; // zpráva byla upravena
|
||||
final bool isDeleted; // smazáno pro všechny (tombstone)
|
||||
/// Reakce: fingerprint odesílatele reakce → emoji. Prázdné = žádné.
|
||||
final Map<String, String> reactions;
|
||||
|
||||
const Message({
|
||||
required this.id,
|
||||
required this.senderId,
|
||||
required this.recipientId,
|
||||
required this.type,
|
||||
this.content,
|
||||
this.fileName,
|
||||
this.fileSize,
|
||||
this.replyToId,
|
||||
required this.timestamp,
|
||||
this.isRead = false,
|
||||
this.isDelivered = false,
|
||||
this.status = MessageStatus.sent,
|
||||
this.disappearAfterSeconds,
|
||||
this.attachment,
|
||||
this.editedAt,
|
||||
this.isDeleted = false,
|
||||
this.reactions = const {},
|
||||
});
|
||||
|
||||
/// Vytvoř textovou zprávu
|
||||
factory Message.text({
|
||||
required String senderId,
|
||||
required String recipientId,
|
||||
required String content,
|
||||
String? replyToId,
|
||||
int? disappearAfterSeconds,
|
||||
MessageStatus status = MessageStatus.sending,
|
||||
}) {
|
||||
return Message(
|
||||
id: const Uuid().v4(),
|
||||
senderId: senderId,
|
||||
recipientId: recipientId,
|
||||
type: MessageType.text,
|
||||
content: content,
|
||||
replyToId: replyToId,
|
||||
timestamp: DateTime.now(),
|
||||
status: status,
|
||||
disappearAfterSeconds: disappearAfterSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
/// Vytvoř zprávu s přílohou (obrázek/dokument dle meta.isImage)
|
||||
factory Message.attachment({
|
||||
required String senderId,
|
||||
required String recipientId,
|
||||
required AttachmentMeta attachment,
|
||||
String? caption,
|
||||
String? id,
|
||||
int? disappearAfterSeconds,
|
||||
MessageStatus status = MessageStatus.sending,
|
||||
}) {
|
||||
return Message(
|
||||
id: id ?? const Uuid().v4(),
|
||||
senderId: senderId,
|
||||
recipientId: recipientId,
|
||||
type: attachment.isImage ? MessageType.image : MessageType.file,
|
||||
content: caption,
|
||||
fileName: attachment.name,
|
||||
fileSize: attachment.size,
|
||||
timestamp: DateTime.now(),
|
||||
attachment: attachment,
|
||||
status: status,
|
||||
disappearAfterSeconds: disappearAfterSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
/// Vytvoř read receipt
|
||||
factory Message.readReceipt({
|
||||
required String senderId,
|
||||
required String recipientId,
|
||||
required String messageId,
|
||||
}) {
|
||||
return Message(
|
||||
id: const Uuid().v4(),
|
||||
senderId: senderId,
|
||||
recipientId: recipientId,
|
||||
type: MessageType.readReceipt,
|
||||
content: messageId,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Kopie s aktualizovanými hodnotami
|
||||
Message copyWith({
|
||||
String? content,
|
||||
bool? isRead,
|
||||
bool? isDelivered,
|
||||
MessageStatus? status,
|
||||
AttachmentMeta? attachment,
|
||||
DateTime? editedAt,
|
||||
bool? isDeleted,
|
||||
Map<String, String>? reactions,
|
||||
}) {
|
||||
return Message(
|
||||
id: id,
|
||||
senderId: senderId,
|
||||
recipientId: recipientId,
|
||||
type: type,
|
||||
content: content ?? this.content,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
replyToId: replyToId,
|
||||
timestamp: timestamp,
|
||||
isRead: isRead ?? this.isRead,
|
||||
isDelivered: isDelivered ?? this.isDelivered,
|
||||
status: status ?? this.status,
|
||||
disappearAfterSeconds: disappearAfterSeconds,
|
||||
attachment: attachment ?? this.attachment,
|
||||
editedAt: editedAt ?? this.editedAt,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
reactions: reactions ?? this.reactions,
|
||||
);
|
||||
}
|
||||
|
||||
/// Má zpráva mizet?
|
||||
bool get isDisappearing => disappearAfterSeconds != null;
|
||||
|
||||
/// Je zpráva odeslána mnou?
|
||||
bool isMine(String myId) => senderId == myId;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'senderId': senderId,
|
||||
'recipientId': recipientId,
|
||||
'type': type.name,
|
||||
'content': content,
|
||||
'fileName': fileName,
|
||||
'fileSize': fileSize,
|
||||
'replyToId': replyToId,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'isRead': isRead,
|
||||
'isDelivered': isDelivered,
|
||||
'status': status.name,
|
||||
'disappearAfterSeconds': disappearAfterSeconds,
|
||||
'attachment': attachment?.toJson(),
|
||||
'editedAt': editedAt?.toIso8601String(),
|
||||
'isDeleted': isDeleted,
|
||||
'reactions': reactions,
|
||||
};
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) {
|
||||
return Message(
|
||||
id: json['id'] as String,
|
||||
senderId: json['senderId'] as String,
|
||||
recipientId: json['recipientId'] as String,
|
||||
type: MessageType.values.byName(json['type'] as String),
|
||||
content: json['content'] as String?,
|
||||
fileName: json['fileName'] as String?,
|
||||
fileSize: json['fileSize'] as int?,
|
||||
replyToId: json['replyToId'] as String?,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
isRead: json['isRead'] as bool? ?? false,
|
||||
isDelivered: json['isDelivered'] as bool? ?? false,
|
||||
// Legacy záznamy bez 'status' → odvoď z isDelivered
|
||||
status: MessageStatus.values.asNameMap()[json['status'] as String?] ??
|
||||
((json['isDelivered'] as bool? ?? false)
|
||||
? MessageStatus.delivered
|
||||
: MessageStatus.sent),
|
||||
disappearAfterSeconds: json['disappearAfterSeconds'] as int?,
|
||||
attachment: json['attachment'] == null
|
||||
? null
|
||||
: AttachmentMeta.fromJson(
|
||||
json['attachment'] as Map<String, dynamic>),
|
||||
editedAt: json['editedAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['editedAt'] as String),
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
reactions: (json['reactions'] as Map<String, dynamic>?)
|
||||
?.map((k, v) => MapEntry(k, v as String)) ??
|
||||
const {},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user