Š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,262 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/auth/change_pin_screen.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/features/onboarding/onboarding_screen.dart';
|
||||
import 'package:sepot/features/onboarding/restore_screen.dart';
|
||||
import 'package:sepot/features/auth/pin_setup_screen.dart';
|
||||
import 'package:sepot/features/auth/pin_lock_screen.dart';
|
||||
import 'package:sepot/features/chat_list/chat_list_screen.dart';
|
||||
import 'package:sepot/features/chat_list/archived_chats_screen.dart';
|
||||
import 'package:sepot/features/conversation/conversation_screen.dart';
|
||||
import 'package:sepot/features/contacts/contact_verify_screen.dart';
|
||||
import 'package:sepot/features/contacts/contacts_screen.dart';
|
||||
import 'package:sepot/features/groups/create_group_screen.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/features/safety/report_screen.dart';
|
||||
import 'package:sepot/features/qr_pairing/qr_share_screen.dart';
|
||||
import 'package:sepot/features/qr_pairing/qr_scan_screen.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/locale_provider.dart';
|
||||
import 'package:sepot/providers/settings_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Hlavní router aplikace
|
||||
final _router = GoRouter(
|
||||
initialLocation: '/loading',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/loading',
|
||||
builder: (context, state) => const _LoadingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding',
|
||||
builder: (context, state) => const OnboardingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/restore',
|
||||
builder: (context, state) => const RestoreScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/pin-setup',
|
||||
builder: (context, state) => const PinSetupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/pin-lock',
|
||||
builder: (context, state) => const PinLockScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chats',
|
||||
builder: (context, state) => const ChatListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/archived',
|
||||
builder: (context, state) => const ArchivedChatsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/conversation/:contactId',
|
||||
builder: (context, state) => ConversationScreen(
|
||||
contactId: state.pathParameters['contactId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/contacts',
|
||||
builder: (context, state) => const ContactsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/create-group',
|
||||
builder: (context, state) => const CreateGroupScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify/:contactId',
|
||||
builder: (context, state) => ContactVerifyScreen(
|
||||
contactId: state.pathParameters['contactId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/report',
|
||||
builder: (context, state) => ReportScreen(
|
||||
reportedFingerprint: state.uri.queryParameters['contact'],
|
||||
reportedName: state.uri.queryParameters['name'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/qr-share',
|
||||
builder: (context, state) => const QrShareScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/qr-scan',
|
||||
builder: (context, state) => const QrScanScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/change-pin',
|
||||
builder: (context, state) => const ChangePinScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// Obrazovky, ze kterých nemá smysl zamykat (nebo už zamčené jsou).
|
||||
const _bezZamku = {'/pin-lock', '/onboarding', '/restore', '/pin-setup', '/loading'};
|
||||
|
||||
/// Hlavní widget aplikace
|
||||
class SepotApp extends ConsumerWidget {
|
||||
const SepotApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// null = systémový jazyk; než se volba načte z keystore, jede systém.
|
||||
final locale = ref.watch(localeProvider).valueOrNull;
|
||||
|
||||
return _AutoLockGate(
|
||||
child: MaterialApp.router(
|
||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
||||
theme: SepotTheme.dark,
|
||||
routerConfig: _router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
// Explicitní pořadí: první položka je fallback pro systémové jazyky,
|
||||
// které nepodporujeme (např. francouzština → angličtina, ne čeština).
|
||||
supportedLocales: const [
|
||||
Locale('en'),
|
||||
Locale('cs'),
|
||||
Locale('de'),
|
||||
Locale('ru'),
|
||||
Locale('it'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hlídá, jak dlouho byla appka na pozadí, a po vypršení limitu zamkne.
|
||||
///
|
||||
/// Zamknout znamená zavřít SQLCipher DB. Kdyby přitom běžel messaging,
|
||||
/// příchozí zpráva by sáhla na zavřenou DB → proto se služba nejdřív
|
||||
/// zastaví a `messagingStartProvider` invaliduje, aby se po odemčení
|
||||
/// nastartovala znovu (FutureProvider by se sám nespustil).
|
||||
class _AutoLockGate extends ConsumerStatefulWidget {
|
||||
const _AutoLockGate({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<_AutoLockGate> createState() => _AutoLockGateState();
|
||||
}
|
||||
|
||||
class _AutoLockGateState extends ConsumerState<_AutoLockGate>
|
||||
with WidgetsBindingObserver {
|
||||
DateTime? _naPozadiOd;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.detached:
|
||||
_naPozadiOd ??= DateTime.now();
|
||||
case AppLifecycleState.resumed:
|
||||
final od = _naPozadiOd;
|
||||
_naPozadiOd = null;
|
||||
if (od != null) unawaited(_zvazZamek(DateTime.now().difference(od)));
|
||||
case AppLifecycleState.inactive:
|
||||
break; // krátký přechod (notifikační lišta, hovor) — neřešit
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _zvazZamek(Duration pryc) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
if (!storage.isUnlocked) return;
|
||||
if (!await storage.hasPin()) return;
|
||||
|
||||
final limit = await ref.read(autoLockProvider.future);
|
||||
if (limit <= 0 || pryc.inSeconds < limit) return;
|
||||
|
||||
final kde = _router.state.uri.path;
|
||||
if (_bezZamku.contains(kde)) return;
|
||||
|
||||
await ref.read(messagingServiceProvider).stop();
|
||||
ref.invalidate(messagingStartProvider);
|
||||
storage.lock();
|
||||
_router.go('/pin-lock');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
/// Načítací obrazovka — rozhodne, kam navigovat
|
||||
class _LoadingScreen extends ConsumerStatefulWidget {
|
||||
const _LoadingScreen();
|
||||
|
||||
@override
|
||||
ConsumerState<_LoadingScreen> createState() => _LoadingScreenState();
|
||||
}
|
||||
|
||||
class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Aplikuj zabezpečení obrazovky co nejdřív (window flag přetrvá napříč
|
||||
// obrazovkami) — ať je chráněný i lock/onboarding.
|
||||
ref.read(screenSecurityProvider);
|
||||
_checkState();
|
||||
}
|
||||
|
||||
Future<void> _checkState() async {
|
||||
// Minimální doba splashe, ať načítací obrazovka neproblikne (skutečná
|
||||
// inicializace se awaituje níž přes provider.future).
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final isOnboarded = await ref.read(isOnboardingCompleteProvider.future);
|
||||
final hasPin = await ref.read(hasPinProvider.future);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (!isOnboarded) {
|
||||
context.go('/onboarding');
|
||||
} else if (hasPin) {
|
||||
// Odemkne PIN obrazovka (Argon2id unwrap DEK)
|
||||
context.go('/pin-lock');
|
||||
} else {
|
||||
// Bez PINu — DEK přímo z OS keystore. Před-S9 instalace s PBKDF2
|
||||
// pin_hash jde nastavit PIN znovu (starý formát DEK nechránil).
|
||||
final storage = ref.read(storageProvider);
|
||||
final legacyPin = await storage.hasLegacyPin();
|
||||
await storage.unlockWithoutPin();
|
||||
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:sepot/core/errors.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';
|
||||
|
||||
/// Šifrovaná záloha historie (kontakty + konverzace).
|
||||
///
|
||||
/// Klíč se odvozuje z privátního klíče identity (HKDF-SHA256) → zálohu
|
||||
/// dešifruje POUZE tvoje identita (po obnově ze slov). Přílohy (bloby)
|
||||
/// v záloze nejsou — obnoví se metadata zpráv, samotný soubor se případně
|
||||
/// dotáhne z relaye znovu (dokud tam je).
|
||||
class BackupService {
|
||||
BackupService(this._storage);
|
||||
|
||||
final SecureStorageService _storage;
|
||||
|
||||
// Formát: magic(8) ‖ nonce(12) ‖ ciphertext ‖ GCM tag(16)
|
||||
static const _magic = 'SEPOTBK1';
|
||||
static const _nonceLen = 12;
|
||||
static const _tagLen = 16;
|
||||
|
||||
Future<SecretKey> _deriveKey(Uint8List privateKey) async {
|
||||
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||
return hkdf.deriveKey(
|
||||
secretKey: SecretKey(privateKey),
|
||||
nonce: utf8.encode('sepot-backup-salt-v1'),
|
||||
info: utf8.encode('sepot-backup-v1'),
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List _randomNonce() {
|
||||
final r = Random.secure();
|
||||
return Uint8List.fromList(List.generate(_nonceLen, (_) => r.nextInt(256)));
|
||||
}
|
||||
|
||||
/// Vytvoř šifrovanou zálohu — vrací bajty souboru `.sepotbackup`.
|
||||
Future<Uint8List> export(LocalIdentity identity) async {
|
||||
final contacts = await _storage.loadContacts();
|
||||
final conversations = await _storage.loadConversations();
|
||||
final payload = utf8.encode(jsonEncode({
|
||||
'v': 1,
|
||||
'contacts': contacts.map((c) => c.toJson()).toList(),
|
||||
'conversations': conversations.map((c) => c.toJson()).toList(),
|
||||
}));
|
||||
|
||||
final key = await _deriveKey(identity.identityPrivateKey);
|
||||
final nonce = _randomNonce();
|
||||
final box = await AesGcm.with256bits()
|
||||
.encrypt(payload, secretKey: key, nonce: nonce);
|
||||
|
||||
final out = BytesBuilder();
|
||||
out.add(utf8.encode(_magic));
|
||||
out.add(nonce);
|
||||
out.add(box.cipherText);
|
||||
out.add(box.mac.bytes);
|
||||
return out.toBytes();
|
||||
}
|
||||
|
||||
/// Obnov ze zálohy. Vrací počet obnovených konverzací. Vyhodí typovanou
|
||||
/// chybu ([BackupCorruptedError], [BackupUnknownFormatError],
|
||||
/// [BackupWrongIdentityError]) u poškozeného souboru nebo zálohy cizí
|
||||
/// identity — UI ji překládá přes describeError.
|
||||
Future<int> import(LocalIdentity identity, Uint8List bytes) async {
|
||||
if (bytes.length < _magic.length + _nonceLen + _tagLen) {
|
||||
throw const BackupCorruptedError();
|
||||
}
|
||||
if (utf8.decode(bytes.sublist(0, _magic.length)) != _magic) {
|
||||
throw const BackupUnknownFormatError();
|
||||
}
|
||||
final nonce = bytes.sublist(_magic.length, _magic.length + _nonceLen);
|
||||
final body = bytes.sublist(_magic.length + _nonceLen);
|
||||
final ct = body.sublist(0, body.length - _tagLen);
|
||||
final mac = Mac(body.sublist(body.length - _tagLen));
|
||||
|
||||
final key = await _deriveKey(identity.identityPrivateKey);
|
||||
List<int> plain;
|
||||
try {
|
||||
plain = await AesGcm.with256bits().decrypt(
|
||||
SecretBox(ct, nonce: nonce, mac: mac),
|
||||
secretKey: key,
|
||||
);
|
||||
} on SecretBoxAuthenticationError {
|
||||
throw const BackupWrongIdentityError();
|
||||
}
|
||||
|
||||
final json = jsonDecode(utf8.decode(plain)) as Map<String, dynamic>;
|
||||
for (final c in (json['contacts'] as List? ?? const [])) {
|
||||
await _storage.saveContact(Contact.fromJson(c as Map<String, dynamic>));
|
||||
}
|
||||
var restored = 0;
|
||||
for (final c in (json['conversations'] as List? ?? const [])) {
|
||||
await _storage
|
||||
.saveConversation(Conversation.fromJson(c as Map<String, dynamic>));
|
||||
restored++;
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Konstanty aplikace Šepot
|
||||
class AppConstants {
|
||||
AppConstants._();
|
||||
|
||||
static const String appName = 'Šepot';
|
||||
/// Musí sedět s `version:` v pubspec.yaml (bez build čísla).
|
||||
static const String appVersion = '0.3.2';
|
||||
static const String appAuthor = 'ajtak.it';
|
||||
|
||||
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
|
||||
// flutter build --dart-define=SEPOT_RELAY=http://<dev-relay-host>:8470
|
||||
static const String relayUrl = String.fromEnvironment(
|
||||
'SEPOT_RELAY',
|
||||
defaultValue: 'https://relay.sepot.cz',
|
||||
);
|
||||
|
||||
// Tor onion vstup relaye (HiddenServicePort 80 → relay:8470; onion vrstva
|
||||
// šifruje, TLS uvnitř netřeba). Používá se, když je v nastavení zapnutý
|
||||
// Tor — provoz jde přes SOCKS5 (Orbot) a jméno .onion resolvuje Tor
|
||||
// (viz TorTransport / MessagingService.setTorEnabled).
|
||||
static const String relayOnion =
|
||||
'http://cipjsctoeyf4fcrntvdvt22o2ansgr73qvthmewo246vuntd7cqmykyd.onion';
|
||||
|
||||
// Kryptografické konstanty
|
||||
static const int pinLength = 6;
|
||||
// 24 slov = 32 B identity klíče enkódovaného přes BIP39 (žádný KDF)
|
||||
static const int seedPhraseWords = 24;
|
||||
|
||||
// Úložiště - názvy Hive boxů
|
||||
static const String identityBox = 'identity';
|
||||
static const String contactsBox = 'contacts';
|
||||
static const String conversationsBox = 'conversations';
|
||||
static const String messagesBox = 'messages';
|
||||
static const String settingsBox = 'settings';
|
||||
|
||||
// Nastavení zpráv
|
||||
static const int maxMessageLength = 4096;
|
||||
static const int defaultDisappearingSeconds = 86400; // 24 hodin
|
||||
|
||||
// Přílohy — musí sedět s relayem (SEPOT_MAX_ATTACHMENT_BYTES, default
|
||||
// 25 MiB na ŠIFROVANÝ blob; plaintext strop držíme kousek pod tím)
|
||||
static const int maxAttachmentBytes = 25 * 1024 * 1024 - 64 * 1024;
|
||||
|
||||
// Auto-lock timeout (v sekundách)
|
||||
static const int autoLockTimeout = 300; // 5 minut
|
||||
|
||||
// Nahlašování zneužití. Endpoint NENÍ na relayi: relay drží jen opaque
|
||||
// obálky a nesmí získat čitelný kanál k obsahu. Nahlášení jde na web,
|
||||
// odkud chodí e-mail — a odesílatel je o tom v UI zpraven.
|
||||
static const String reportEndpoint = String.fromEnvironment(
|
||||
'SEPOT_REPORT_ENDPOINT',
|
||||
defaultValue: 'https://sepot.cz/api/nahlaseni',
|
||||
);
|
||||
|
||||
/// Veřejné standardy ochrany dětí (Google Play CSAE, Apple 1.2).
|
||||
static const String childSafetyUrl = 'https://sepot.cz/bezpecnost-deti';
|
||||
|
||||
/// Apple Guideline 5.1.1(i) žádá odkaz na zásady soukromí i UVNITŘ aplikace,
|
||||
/// nejen v metadatech obchodu.
|
||||
static const String privacyUrl = 'https://sepot.cz/ochrana-soukromi';
|
||||
static const String termsUrl = 'https://sepot.cz/podminky';
|
||||
static const String deletionUrl = 'https://sepot.cz/smazani-dat';
|
||||
|
||||
static const String supportEmail = 'podpora@sepot.cz';
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/// Šifrování příloh (model Signal).
|
||||
///
|
||||
/// Každá příloha má vlastní náhodný klíč: soubor se zašifruje
|
||||
/// AES-256-GCM, na relay jde UŽ ŠIFROVANÝ blob a klíč + nonce + digest
|
||||
/// putují k příjemci uvnitř E2E zprávy (relay je nikdy nevidí).
|
||||
///
|
||||
/// Wire formát blobu: `ciphertext || GCM tag (16 B)`.
|
||||
/// Digest = SHA-256 celého blobu — váže zprávu na konkrétní bajty na
|
||||
/// relayi (relay nemůže blob vyměnit) a příjemce jím ověří stažená data
|
||||
/// ještě PŘED dešifrováním.
|
||||
///
|
||||
/// Šifrování i dešifrování běží v samostatném isolate — soubory mají
|
||||
/// megabajty a čistě dartový AES by jinak blokoval UI.
|
||||
library;
|
||||
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
|
||||
/// Blob nesedí (digest mismatch / GCM tag fail) — poškozená nebo
|
||||
/// podvržená příloha. Nikdy nepředávat obsah dál.
|
||||
class AttachmentIntegrityException implements Exception {
|
||||
const AttachmentIntegrityException(this.reason);
|
||||
|
||||
final String reason;
|
||||
|
||||
@override
|
||||
String toString() => 'AttachmentIntegrityException: $reason';
|
||||
}
|
||||
|
||||
/// Výsledek zašifrování přílohy — vše, co potřebuje odesílatel:
|
||||
/// [blob] se nahraje na relay, zbytek jde do E2E zprávy.
|
||||
class EncryptedAttachment {
|
||||
const EncryptedAttachment({
|
||||
required this.key,
|
||||
required this.nonce,
|
||||
required this.blob,
|
||||
required this.digest,
|
||||
required this.plaintextSize,
|
||||
});
|
||||
|
||||
final Uint8List key; // 32 B, per-file
|
||||
final Uint8List nonce; // 12 B
|
||||
final Uint8List blob; // ciphertext || tag
|
||||
final Uint8List digest; // SHA-256(blob)
|
||||
final int plaintextSize;
|
||||
}
|
||||
|
||||
class AttachmentCipher {
|
||||
AttachmentCipher._();
|
||||
|
||||
static const int keyLength = 32;
|
||||
static const int nonceLength = 12;
|
||||
static const int tagLength = 16;
|
||||
|
||||
static Future<EncryptedAttachment> encrypt(Uint8List plaintext) async {
|
||||
if (plaintext.isEmpty) {
|
||||
throw ArgumentError('empty attachment');
|
||||
}
|
||||
final key = _randomBytes(keyLength);
|
||||
final nonce = _randomBytes(nonceLength);
|
||||
|
||||
final blob = await Isolate.run(() async {
|
||||
final aes = AesGcm.with256bits();
|
||||
final box = await aes.encrypt(
|
||||
plaintext,
|
||||
secretKey: SecretKey(key),
|
||||
nonce: nonce,
|
||||
);
|
||||
final out = Uint8List(box.cipherText.length + box.mac.bytes.length);
|
||||
out.setAll(0, box.cipherText);
|
||||
out.setAll(box.cipherText.length, box.mac.bytes);
|
||||
return out;
|
||||
});
|
||||
|
||||
return EncryptedAttachment(
|
||||
key: key,
|
||||
nonce: nonce,
|
||||
blob: blob,
|
||||
digest: await sha256(blob),
|
||||
plaintextSize: plaintext.length,
|
||||
);
|
||||
}
|
||||
|
||||
/// Digest se ověřuje PŘED dešifrováním (stažený blob nemusí být ten,
|
||||
/// na který zpráva ukazuje); GCM tag pak autentizuje obsah klíčem.
|
||||
static Future<Uint8List> decrypt({
|
||||
required Uint8List blob,
|
||||
required Uint8List key,
|
||||
required Uint8List nonce,
|
||||
required Uint8List expectedDigest,
|
||||
}) async {
|
||||
if (blob.length <= tagLength) {
|
||||
throw const AttachmentIntegrityException('blob too short');
|
||||
}
|
||||
final actualDigest = await sha256(blob);
|
||||
if (!_bytesEqual(actualDigest, expectedDigest)) {
|
||||
throw const AttachmentIntegrityException('digest mismatch');
|
||||
}
|
||||
|
||||
try {
|
||||
final plaintext = await Isolate.run(() async {
|
||||
final aes = AesGcm.with256bits();
|
||||
final cipherText = blob.sublist(0, blob.length - tagLength);
|
||||
final mac = Mac(blob.sublist(blob.length - tagLength));
|
||||
final out = await aes.decrypt(
|
||||
SecretBox(cipherText, nonce: nonce, mac: mac),
|
||||
secretKey: SecretKey(key),
|
||||
);
|
||||
return Uint8List.fromList(out);
|
||||
});
|
||||
return plaintext;
|
||||
} on SecretBoxAuthenticationError {
|
||||
throw const AttachmentIntegrityException('GCM tag mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Uint8List> sha256(Uint8List data) async {
|
||||
final hash = await Sha256().hash(data);
|
||||
return Uint8List.fromList(hash.bytes);
|
||||
}
|
||||
|
||||
static Uint8List _randomBytes(int length) {
|
||||
final rng = Random.secure();
|
||||
return Uint8List.fromList(List.generate(length, (_) => rng.nextInt(256)));
|
||||
}
|
||||
|
||||
static bool _bytesEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
var diff = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/// Argon2id wrap databázového klíče (DEK) za PIN.
|
||||
///
|
||||
/// Schéma:
|
||||
/// unlockKey = Argon2id(PIN, salt)
|
||||
/// wrappedDek = AES-256-GCM(dek, unlockKey)
|
||||
///
|
||||
/// PIN se NIKDE neukládá ani nehashuje na porovnání — „ověření PINu" je
|
||||
/// úspěšné rozbalení DEK (GCM auth tag). Špatný PIN = nečitelná DB.
|
||||
///
|
||||
/// Argon2id parametry jsou součástí blobu, takže budoucí rekalibrace
|
||||
/// nerozbije stará data. Při wrap i unwrap se vynucuje dolní mez
|
||||
/// (OWASP floor): m ≥ 19 MiB, t ≥ 2 — slabší
|
||||
/// parametry se odmítnou (žádný tichý downgrade).
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:cryptography/dart.dart';
|
||||
|
||||
/// GCM auth tag nesedí — špatný PIN, nebo poškozený/podvržený blob.
|
||||
/// (AES-GCM tyhle dva případy principiálně nerozliší.)
|
||||
class WrongPinException implements Exception {
|
||||
const WrongPinException();
|
||||
|
||||
@override
|
||||
String toString() => 'WrongPinException: wrong PIN or corrupted blob';
|
||||
}
|
||||
|
||||
class DekWrap {
|
||||
DekWrap._();
|
||||
|
||||
/// Výchozí parametry (m=64MiB, t=3, p=1).
|
||||
/// Řádově ~2–3 s čistým Dartem na středně výkonném zařízení.
|
||||
static const int defaultMemoryKiB = 64 * 1024;
|
||||
static const int defaultIterations = 3;
|
||||
static const int defaultParallelism = 1;
|
||||
|
||||
/// Dolní bezpečnostní mez (OWASP) — pod ni kalibrace NIKDY nesmí.
|
||||
static const int floorMemoryKiB = 19 * 1024;
|
||||
static const int floorIterations = 2;
|
||||
|
||||
static const String _version = 'sepot-dek-v1';
|
||||
|
||||
/// Zabal 32B DEK za PIN. Výstup je samopopisný string pro keystore:
|
||||
/// `sepot-dek-v1:<mKiB>:<t>:<p>:<b64 salt>:<b64 nonce>:<b64 ct>:<b64 tag>`
|
||||
static Future<String> wrap({
|
||||
required Uint8List dek,
|
||||
required String pin,
|
||||
int memoryKiB = defaultMemoryKiB,
|
||||
int iterations = defaultIterations,
|
||||
int parallelism = defaultParallelism,
|
||||
}) async {
|
||||
if (dek.length != 32) {
|
||||
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
||||
}
|
||||
_enforceFloor(memoryKiB, iterations);
|
||||
|
||||
final salt = _randomBytes(16);
|
||||
final unlockKey = await _deriveUnlockKey(
|
||||
pin: pin,
|
||||
salt: salt,
|
||||
memoryKiB: memoryKiB,
|
||||
iterations: iterations,
|
||||
parallelism: parallelism,
|
||||
);
|
||||
|
||||
final aes = AesGcm.with256bits();
|
||||
final box = await aes.encrypt(
|
||||
dek,
|
||||
secretKey: SecretKey(unlockKey),
|
||||
nonce: _randomBytes(12),
|
||||
);
|
||||
|
||||
return [
|
||||
_version,
|
||||
'$memoryKiB',
|
||||
'$iterations',
|
||||
'$parallelism',
|
||||
base64Encode(salt),
|
||||
base64Encode(box.nonce),
|
||||
base64Encode(box.cipherText),
|
||||
base64Encode(box.mac.bytes),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
|
||||
/// [WrongPinException]; na neplatném formátu [FormatException].
|
||||
static Future<Uint8List> unwrap({
|
||||
required String wrapped,
|
||||
required String pin,
|
||||
}) async {
|
||||
final parts = wrapped.split(':');
|
||||
if (parts.length != 8 || parts[0] != _version) {
|
||||
throw FormatException('Unknown wrapped DEK format', wrapped);
|
||||
}
|
||||
final memoryKiB = int.parse(parts[1]);
|
||||
final iterations = int.parse(parts[2]);
|
||||
final parallelism = int.parse(parts[3]);
|
||||
// Floor PŘED odvozením — podvržený blob se slabými parametry
|
||||
// nesmí projít, ani kdyby měl platný tag
|
||||
_enforceFloor(memoryKiB, iterations);
|
||||
|
||||
final salt = base64Decode(parts[4]);
|
||||
final nonce = base64Decode(parts[5]);
|
||||
final cipherText = base64Decode(parts[6]);
|
||||
final mac = base64Decode(parts[7]);
|
||||
|
||||
final unlockKey = await _deriveUnlockKey(
|
||||
pin: pin,
|
||||
salt: salt,
|
||||
memoryKiB: memoryKiB,
|
||||
iterations: iterations,
|
||||
parallelism: parallelism,
|
||||
);
|
||||
|
||||
final aes = AesGcm.with256bits();
|
||||
try {
|
||||
final dek = await aes.decrypt(
|
||||
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
|
||||
secretKey: SecretKey(unlockKey),
|
||||
);
|
||||
return Uint8List.fromList(dek);
|
||||
} on SecretBoxAuthenticationError {
|
||||
throw const WrongPinException();
|
||||
}
|
||||
}
|
||||
|
||||
static void _enforceFloor(int memoryKiB, int iterations) {
|
||||
if (memoryKiB < floorMemoryKiB || iterations < floorIterations) {
|
||||
throw StateError(
|
||||
'Argon2id parameters below security floor '
|
||||
'(m=${memoryKiB}KiB t=$iterations, floor m=${floorMemoryKiB}KiB '
|
||||
't=$floorIterations)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Argon2id běží v samostatném isolate — 64 MiB memory-hard výpočet
|
||||
/// by jinak na sekundy zmrazil UI.
|
||||
static Future<Uint8List> _deriveUnlockKey({
|
||||
required String pin,
|
||||
required Uint8List salt,
|
||||
required int memoryKiB,
|
||||
required int iterations,
|
||||
required int parallelism,
|
||||
}) {
|
||||
return Isolate.run(() async {
|
||||
final algo = DartArgon2id(
|
||||
memory: memoryKiB,
|
||||
iterations: iterations,
|
||||
parallelism: parallelism,
|
||||
hashLength: 32,
|
||||
);
|
||||
final key = await algo.deriveKey(
|
||||
secretKey: SecretKey(utf8.encode(pin)),
|
||||
nonce: salt,
|
||||
);
|
||||
return Uint8List.fromList(await key.extractBytes());
|
||||
});
|
||||
}
|
||||
|
||||
static Uint8List _randomBytes(int length) {
|
||||
final rng = Random.secure();
|
||||
return Uint8List.fromList(
|
||||
List.generate(length, (_) => rng.nextInt(256)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/// Obnova identity přes mnemonic — BIP39 wordlist jako REVERZIBILNÍ
|
||||
/// ENKÓDOVÁNÍ 32B privátního identity klíče.
|
||||
///
|
||||
/// Žádné KDF, žádný seed: 24 slov je přímý zápis bajtů klíče + checksum.
|
||||
/// `decode(encode(klic)) == klic` platí bit po bitu — hlídají to KAT testy.
|
||||
///
|
||||
/// DŮLEŽITÉ: enkóduje se SERIALIZOVANÝ privátní klíč (výstup fasády
|
||||
/// SepotCrypto, už clampnutý pro Curve25519). Díky tomu je
|
||||
/// deserialize→serialize stabilní a mnemonic vždy obnoví identický klíč.
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip39/bip39.dart' as bip39;
|
||||
import 'package:convert/convert.dart' show hex;
|
||||
import 'package:sepot/core/errors.dart';
|
||||
|
||||
class SepotMnemonic {
|
||||
SepotMnemonic._();
|
||||
|
||||
/// 32 B entropie = 24 slov BIP39 (256 bitů + 8bit checksum).
|
||||
static const int wordCount = 24;
|
||||
static const int keyLength = 32;
|
||||
|
||||
/// Serializovaný privátní klíč (32 B) → 24 slov.
|
||||
static List<String> encode(Uint8List privateKey) {
|
||||
if (privateKey.length != keyLength) {
|
||||
throw ArgumentError.value(
|
||||
privateKey.length,
|
||||
'privateKey',
|
||||
'Expected $keyLength bytes of serialized private key',
|
||||
);
|
||||
}
|
||||
return bip39.entropyToMnemonic(hex.encode(privateKey)).split(' ');
|
||||
}
|
||||
|
||||
/// 24 slov → 32 B privátního klíče.
|
||||
///
|
||||
/// Vyhodí [MnemonicWordCountError] při špatném počtu slov a
|
||||
/// [MnemonicInvalidError] při neznámém slovu nebo nesedícím checksumu
|
||||
/// (překlep uživatele) — UI je překládá přes describeError.
|
||||
static Uint8List decode(List<String> words) {
|
||||
final normalized = _normalize(words);
|
||||
if (normalized.length != wordCount) {
|
||||
throw MnemonicWordCountError(normalized.length, wordCount);
|
||||
}
|
||||
final String entropyHex;
|
||||
try {
|
||||
entropyHex = bip39.mnemonicToEntropy(normalized.join(' '));
|
||||
} on ArgumentError catch (e) {
|
||||
// bip39 hlásí neznámé slovo / špatný checksum přes ArgumentError a
|
||||
// StateError — pro UI sjednocujeme na typovanou chybu
|
||||
throw MnemonicInvalidError('${e.message}');
|
||||
} on StateError catch (e) {
|
||||
throw MnemonicInvalidError(e.message);
|
||||
}
|
||||
return Uint8List.fromList(hex.decode(entropyHex));
|
||||
}
|
||||
|
||||
/// Rychlá validace pro UI (živá kontrola při zadávání).
|
||||
static bool isValid(List<String> words) {
|
||||
final normalized = _normalize(words);
|
||||
if (normalized.length != wordCount) return false;
|
||||
return bip39.validateMnemonic(normalized.join(' '));
|
||||
}
|
||||
|
||||
static List<String> _normalize(List<String> words) => words
|
||||
.expand((w) => w.trim().toLowerCase().split(RegExp(r'\s+')))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Safety number — vizuální ověření kontaktu.
|
||||
///
|
||||
/// Používá libsignal `Fingerprint` (NumericFingerprintGenerator), žádný
|
||||
/// vlastní výpočet: iterovaný SHA-512 (5200 iterací jako Signal) nad
|
||||
/// (verze ‖ identifikátor ‖ identity klíč) pro obě strany, zobrazení
|
||||
/// 60 dekadických číslic (12 bloků po 5). Menší polovina jde první,
|
||||
/// takže OBĚ strany vidí stejné číslo.
|
||||
///
|
||||
/// Stabilní identifikátor strany = BLAKE2b-256 fingerprint jejího
|
||||
/// identity klíče (deterministický z klíče — žádný telefon, e-mail ani
|
||||
/// server handle, přesně v duchu „identita = klíč").
|
||||
///
|
||||
/// QR ověření: [scannable] bajty do QR; sken protistrany se porovná přes
|
||||
/// [SafetyNumber.compare] (libsignal kontroluje křížem local↔remote).
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:libsignal/libsignal.dart' as signal;
|
||||
import 'package:sepot/models/identity.dart';
|
||||
|
||||
class SafetyNumber {
|
||||
SafetyNumber._(this.display, this.scannable);
|
||||
|
||||
/// Iterace hashe jako Signal (bezpečnostní parametr — NEměnit,
|
||||
/// změna = všem se změní safety numbers).
|
||||
static const int iterations = 5200;
|
||||
|
||||
/// Verze scannable formátu (libsignal proto).
|
||||
static const int version = 2;
|
||||
|
||||
/// 60 číslic bez mezer.
|
||||
final String display;
|
||||
|
||||
/// Binární encoding pro QR + [compare].
|
||||
final Uint8List scannable;
|
||||
|
||||
/// Spočítá safety number páru identity klíčů (33 B serializované).
|
||||
factory SafetyNumber.compute({
|
||||
required Uint8List localIdentityKey,
|
||||
required Uint8List remoteIdentityKey,
|
||||
}) {
|
||||
final fp = signal.Fingerprint(
|
||||
iterations: iterations,
|
||||
version: version,
|
||||
localIdentifier: SepotFingerprint.ofPublicKey(localIdentityKey).bytes,
|
||||
localPublicKey: localIdentityKey,
|
||||
remoteIdentifier: SepotFingerprint.ofPublicKey(remoteIdentityKey).bytes,
|
||||
remotePublicKey: remoteIdentityKey,
|
||||
);
|
||||
return SafetyNumber._(fp.displayString(), fp.scannableEncoding());
|
||||
}
|
||||
|
||||
/// 12 bloků po 5 číslicích pro zobrazení.
|
||||
List<String> get groups => [
|
||||
for (var i = 0; i < display.length; i += 5)
|
||||
display.substring(i, i + 5),
|
||||
];
|
||||
|
||||
/// Porovná můj scannable s naskenovaným od protistrany. True = klíče
|
||||
/// si křížem odpovídají (žádný MITM), false = NESHODA.
|
||||
static bool compare(Uint8List mine, Uint8List scanned) {
|
||||
try {
|
||||
return signal.fingerprintCompare(fingerprint1: mine, fingerprint2: scanned);
|
||||
} on Object {
|
||||
// vadný/cizí QR formát = neshoda, ne pád
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/// Fasáda SepotCrypto — port/adapter nad krypto backendem (libsignal).
|
||||
///
|
||||
/// Smysl: veškerý kód Šepotu mluví JEN s touhle fasádou,
|
||||
/// nikdy přímo s balíčkem `libsignal`. Backend tak zůstává vyměnitelný
|
||||
/// (libsignal FFI ↔ libsignal_protocol_dart ↔ vlastní FRB fork) bez zásahu
|
||||
/// do zbytku aplikace.
|
||||
///
|
||||
/// Stores jsou zatím in-memory; později je nahradí
|
||||
/// perzistence nad SQLCipher.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:libsignal/libsignal.dart';
|
||||
|
||||
/// Pár serializovaných identity klíčů — jediný tvar, ve kterém identity
|
||||
/// klíče opouštějí fasádu (žádné backend-specifické typy ven).
|
||||
class SepotIdentityKeys {
|
||||
SepotIdentityKeys({required this.privateKey, required this.publicKey});
|
||||
|
||||
/// 32 B, serializovaný (clampnutý) privátní klíč. Přesně tyhle bajty
|
||||
/// se enkódují do mnemonic — díky clampingu při serializaci je
|
||||
/// deserialize→serialize bitově stabilní roundtrip.
|
||||
final Uint8List privateKey;
|
||||
|
||||
/// 33 B, serializovaný veřejný klíč (libsignal type byte + 32 B).
|
||||
/// Z něj se počítá fingerprint.
|
||||
final Uint8List publicKey;
|
||||
}
|
||||
|
||||
/// Serializovatelný obsah prekey bundle — přesně to, co klient nahraje
|
||||
/// na relay a co si druhá strana stáhne. Jen bajty a čísla, žádné
|
||||
/// backend-specifické typy (musí projít protobufem beze změny).
|
||||
class SepotPreKeyBundle {
|
||||
SepotPreKeyBundle({
|
||||
required this.registrationId,
|
||||
required this.deviceId,
|
||||
required this.preKeyId,
|
||||
required this.preKeyPublic,
|
||||
required this.signedPreKeyId,
|
||||
required this.signedPreKeyPublic,
|
||||
required this.signedPreKeySignature,
|
||||
required this.identityKey,
|
||||
required this.kyberPreKeyId,
|
||||
required this.kyberPreKeyPublic,
|
||||
required this.kyberPreKeySignature,
|
||||
});
|
||||
|
||||
final int registrationId;
|
||||
final int deviceId;
|
||||
final int? preKeyId;
|
||||
final Uint8List? preKeyPublic;
|
||||
final int signedPreKeyId;
|
||||
final Uint8List signedPreKeyPublic;
|
||||
final Uint8List signedPreKeySignature;
|
||||
final Uint8List identityKey;
|
||||
final int kyberPreKeyId;
|
||||
final Uint8List kyberPreKeyPublic;
|
||||
final Uint8List kyberPreKeySignature;
|
||||
}
|
||||
|
||||
/// Výsledek dešifrování sealed-sender obálky.
|
||||
class SepotSealedMessage {
|
||||
SepotSealedMessage({
|
||||
required this.plaintext,
|
||||
required this.senderName,
|
||||
required this.senderDeviceId,
|
||||
required this.senderIdentityKey,
|
||||
});
|
||||
|
||||
final Uint8List plaintext;
|
||||
|
||||
/// accountId odesílatele (hex) ze SenderCertificate — zná ho JEN příjemce.
|
||||
final String senderName;
|
||||
final int senderDeviceId;
|
||||
|
||||
/// Serializovaný identity klíč odesílatele (33 B) — kryptograficky
|
||||
/// ověřený certifikátem. Fingerprint z něj mapuje zprávu na kontakt.
|
||||
final Uint8List senderIdentityKey;
|
||||
}
|
||||
|
||||
/// Jeden one-time prekey k publikaci na relay.
|
||||
class SepotOneTimePreKey {
|
||||
SepotOneTimePreKey({required this.id, required this.publicKey});
|
||||
|
||||
final int id;
|
||||
final Uint8List publicKey;
|
||||
}
|
||||
|
||||
/// Sada stores, nad kterými krypto instance pracuje. Produkce dodává
|
||||
/// perzistentní (SQLCipher, `persistent_stores.dart`), testy in-memory.
|
||||
class SepotStores {
|
||||
SepotStores({
|
||||
required this.session,
|
||||
required this.identity,
|
||||
required this.preKey,
|
||||
required this.signedPreKey,
|
||||
required this.kyberPreKey,
|
||||
});
|
||||
|
||||
/// In-memory sada (spike, testy) — po zabití procesu je pryč.
|
||||
factory SepotStores.inMemory(IdentityKeyPair identity, int registrationId) {
|
||||
return SepotStores(
|
||||
session: InMemorySessionStore(),
|
||||
identity: InMemoryIdentityKeyStore(identity, registrationId),
|
||||
preKey: InMemoryPreKeyStore(),
|
||||
signedPreKey: InMemorySignedPreKeyStore(),
|
||||
kyberPreKey: InMemoryKyberPreKeyStore(),
|
||||
);
|
||||
}
|
||||
|
||||
final SessionStore session;
|
||||
final IdentityKeyStore identity;
|
||||
final PreKeyStore preKey;
|
||||
final SignedPreKeyStore signedPreKey;
|
||||
final KyberPreKeyStore kyberPreKey;
|
||||
}
|
||||
|
||||
/// Jedna krypto identita (účet na jednom zařízení) se všemi stores.
|
||||
class SepotCrypto {
|
||||
SepotCrypto._({
|
||||
required this.address,
|
||||
required IdentityKeyPair identity,
|
||||
required int registrationId,
|
||||
SepotStores? stores,
|
||||
}) : _identity = identity,
|
||||
_registrationId = registrationId,
|
||||
_stores = stores ?? SepotStores.inMemory(identity, registrationId);
|
||||
|
||||
/// Inicializace nativní knihovny — volat jednou per proces, před
|
||||
/// jakoukoli jinou operací fasády (v `main()` před `runApp`).
|
||||
///
|
||||
/// iOS: libsignal doručuje `libsignal_frb` výhradně přes Flutter native
|
||||
/// assets a jeho podspec framework nelinkuje. Výchozí FRB loader proto
|
||||
/// dlopen-uje bez `@rpath` prefixu, dyld hledá v kořeni `.app` místo ve
|
||||
/// `Frameworks/` a selže → němá bílá obrazovka. Předaná cesta se přesně
|
||||
/// shoduje s install name, které frameworku nastaví flutter_tools, a
|
||||
/// Runner má `@executable_path/Frameworks` v LD_RUNPATH_SEARCH_PATHS.
|
||||
static Future<void> init() {
|
||||
if (Platform.isIOS) {
|
||||
return LibSignal.init(
|
||||
libraryPath: '@rpath/libsignal_frb.framework/libsignal_frb',
|
||||
);
|
||||
}
|
||||
return LibSignal.init();
|
||||
}
|
||||
|
||||
/// Vytvoří novou identitu (keypair + registration ID).
|
||||
factory SepotCrypto.generate({
|
||||
required String accountId,
|
||||
int deviceId = 1,
|
||||
required int registrationId,
|
||||
SepotStores? stores,
|
||||
}) {
|
||||
return SepotCrypto._(
|
||||
address: ProtocolAddress(name: accountId, deviceId: deviceId),
|
||||
identity: IdentityKeyPair.generate(),
|
||||
registrationId: registrationId,
|
||||
stores: stores,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obnoví krypto instanci z existujícího privátního identity klíče
|
||||
/// (onboarding po recovery z mnemonic, běžný start aplikace).
|
||||
///
|
||||
/// [storesFactory] dostává rekonstruovaný keypair — produkce z něj
|
||||
/// postaví perzistentní stores (DbIdentityKeyStore keypair potřebuje).
|
||||
factory SepotCrypto.fromPrivateKey({
|
||||
required Uint8List privateKey,
|
||||
required String accountId,
|
||||
int deviceId = 1,
|
||||
required int registrationId,
|
||||
SepotStores Function(IdentityKeyPair identity)? storesFactory,
|
||||
}) {
|
||||
final private = PrivateKey.deserialize(bytes: privateKey.toList());
|
||||
final identity = IdentityKeyPair.fromKeys(
|
||||
privateKey: private,
|
||||
publicKey: private.getPublicKey(),
|
||||
);
|
||||
return SepotCrypto._(
|
||||
address: ProtocolAddress(name: accountId, deviceId: deviceId),
|
||||
identity: identity,
|
||||
registrationId: registrationId,
|
||||
stores: storesFactory?.call(identity),
|
||||
);
|
||||
}
|
||||
|
||||
/// Vygeneruje nový identity keypair a vrátí ho serializovaný.
|
||||
/// Používá onboarding; privátní část jde do secure storage
|
||||
/// a do mnemonic, nic z toho nedrží backend typy.
|
||||
static SepotIdentityKeys generateIdentityKeys() {
|
||||
final pair = IdentityKeyPair.generate();
|
||||
return SepotIdentityKeys(
|
||||
privateKey: Uint8List.fromList(pair.privateKey.toList()),
|
||||
publicKey: Uint8List.fromList(pair.publicKey.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Odvodí veřejný klíč (33 B serialized) z privátního (32 B).
|
||||
/// Deterministické — základ recovery z mnemonic.
|
||||
static Uint8List derivePublicKey(Uint8List privateKey) {
|
||||
final private = PrivateKey.deserialize(bytes: privateKey.toList());
|
||||
return Uint8List.fromList(private.getPublicKey().serialize().toList());
|
||||
}
|
||||
|
||||
/// Lokální adresa. Po registraci na relay se přepíná na hex(account_id)
|
||||
/// přes [bindAccount] — musí sedět se sender_uuid v SenderCertificate.
|
||||
ProtocolAddress address;
|
||||
final IdentityKeyPair _identity;
|
||||
final int _registrationId;
|
||||
final SepotStores _stores;
|
||||
|
||||
Uint8List get identityPublicKey => Uint8List.fromList(_identity.publicKey);
|
||||
|
||||
int get registrationId => _registrationId;
|
||||
|
||||
/// Po registraci: server přidělil account_id → lokální adresa se váže
|
||||
/// na hex(account_id), aby seděla se sender_uuid v SenderCertificate.
|
||||
void bindAccount(String accountHex) {
|
||||
address = ProtocolAddress(name: accountHex, deviceId: address.deviceId());
|
||||
}
|
||||
|
||||
/// XEdDSA podpis identity klíčem (AUTH výzva, REST auth hlavička).
|
||||
Uint8List sign(List<int> message) {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
return Uint8List.fromList(identityPrivate.sign(message: message).toList());
|
||||
}
|
||||
|
||||
/// Vygeneruje sadu prekeys (one-time + signed + Kyber), uloží je do
|
||||
/// vlastních stores a vrátí bundle k publikaci na relay.
|
||||
Future<SepotPreKeyBundle> generatePreKeyBundle({
|
||||
required int preKeyId,
|
||||
required int signedPreKeyId,
|
||||
required int kyberPreKeyId,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final identityPrivate = PrivateKey.deserialize(
|
||||
bytes: _identity.privateKey.toList(),
|
||||
);
|
||||
final timestamp = BigInt.from(timestampMs);
|
||||
|
||||
final preKeyPrivate = PrivateKey.generate();
|
||||
final preKeyPublic = preKeyPrivate.getPublicKey();
|
||||
await _stores.preKey.storePreKey(
|
||||
preKeyId,
|
||||
PreKeyRecord(
|
||||
id: preKeyId,
|
||||
publicKey: preKeyPublic,
|
||||
privateKey: preKeyPrivate,
|
||||
),
|
||||
);
|
||||
|
||||
final signedPreKeyPrivate = PrivateKey.generate();
|
||||
final signedPreKeyPublic = signedPreKeyPrivate.getPublicKey();
|
||||
final signedPreKeySignature = identityPrivate.sign(
|
||||
message: signedPreKeyPublic.serialize().toList(),
|
||||
);
|
||||
await _stores.signedPreKey.storeSignedPreKey(
|
||||
signedPreKeyId,
|
||||
SignedPreKeyRecord(
|
||||
id: signedPreKeyId,
|
||||
publicKey: signedPreKeyPublic,
|
||||
privateKey: signedPreKeyPrivate,
|
||||
signature: signedPreKeySignature.toList(),
|
||||
timestamp: timestamp,
|
||||
),
|
||||
);
|
||||
|
||||
final kyberKeyPair = KyberKeyPair.generate();
|
||||
final kyberPublic = kyberKeyPair.getPublicKey();
|
||||
final kyberSignature = identityPrivate.sign(
|
||||
message: kyberPublic.serialize().toList(),
|
||||
);
|
||||
await _stores.kyberPreKey.storeKyberPreKey(
|
||||
kyberPreKeyId,
|
||||
KyberPreKeyRecord.create(
|
||||
id: kyberPreKeyId,
|
||||
keyPair: kyberKeyPair,
|
||||
signature: kyberSignature.toList(),
|
||||
timestamp: timestamp,
|
||||
),
|
||||
);
|
||||
|
||||
return SepotPreKeyBundle(
|
||||
registrationId: _registrationId,
|
||||
deviceId: address.deviceId(),
|
||||
preKeyId: preKeyId,
|
||||
preKeyPublic: preKeyPublic.serialize(),
|
||||
signedPreKeyId: signedPreKeyId,
|
||||
signedPreKeyPublic: Uint8List.fromList(
|
||||
signedPreKeyPublic.serialize().toList(),
|
||||
),
|
||||
signedPreKeySignature: Uint8List.fromList(signedPreKeySignature.toList()),
|
||||
identityKey: Uint8List.fromList(_identity.publicKey.toList()),
|
||||
kyberPreKeyId: kyberPreKeyId,
|
||||
kyberPreKeyPublic: Uint8List.fromList(kyberPublic.serialize().toList()),
|
||||
kyberPreKeySignature: Uint8List.fromList(kyberSignature.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Existuje už ratchet session s protistranou?
|
||||
Future<bool> hasSession(ProtocolAddress remote) =>
|
||||
_stores.session.containsSession(remote);
|
||||
|
||||
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
|
||||
Future<void> processRemoteBundle(
|
||||
ProtocolAddress remote,
|
||||
SepotPreKeyBundle bundle,
|
||||
) async {
|
||||
final preKeyBundle = PreKeyBundle(
|
||||
registrationId: bundle.registrationId,
|
||||
deviceId: bundle.deviceId,
|
||||
preKeyId: bundle.preKeyId,
|
||||
preKeyPublic: bundle.preKeyPublic,
|
||||
signedPreKeyId: bundle.signedPreKeyId,
|
||||
signedPreKeyPublic: bundle.signedPreKeyPublic.toList(),
|
||||
signedPreKeySignature: bundle.signedPreKeySignature.toList(),
|
||||
identityKey: bundle.identityKey.toList(),
|
||||
kyberPreKeyId: bundle.kyberPreKeyId,
|
||||
kyberPreKeyPublic: bundle.kyberPreKeyPublic.toList(),
|
||||
kyberPreKeySignature: bundle.kyberPreKeySignature.toList(),
|
||||
);
|
||||
final builder = SessionBuilder(
|
||||
localAddress: address,
|
||||
sessionStore: _stores.session,
|
||||
identityKeyStore: _stores.identity,
|
||||
);
|
||||
await builder.processPreKeyBundle(remote, preKeyBundle);
|
||||
}
|
||||
|
||||
SessionCipher get _cipher => SessionCipher(
|
||||
localAddress: address,
|
||||
sessionStore: _stores.session,
|
||||
identityKeyStore: _stores.identity,
|
||||
preKeyStore: _stores.preKey,
|
||||
signedPreKeyStore: _stores.signedPreKey,
|
||||
kyberPreKeyStore: _stores.kyberPreKey,
|
||||
);
|
||||
|
||||
/// Double Ratchet šifrování 1:1.
|
||||
Future<CiphertextMessage> encryptTo(
|
||||
ProtocolAddress remote,
|
||||
List<int> plaintext,
|
||||
) => _cipher.encrypt(remote, Uint8List.fromList(plaintext));
|
||||
|
||||
/// Double Ratchet dešifrování 1:1.
|
||||
Future<Uint8List> decryptFrom(
|
||||
ProtocolAddress remote,
|
||||
CiphertextMessage message,
|
||||
) => _cipher.decrypt(remote, message);
|
||||
|
||||
SealedSenderCipher get _sealedCipher => SealedSenderCipher(
|
||||
localAddress: address,
|
||||
sessionStore: _stores.session,
|
||||
identityKeyStore: _stores.identity,
|
||||
preKeyStore: _stores.preKey,
|
||||
signedPreKeyStore: _stores.signedPreKey,
|
||||
kyberPreKeyStore: _stores.kyberPreKey,
|
||||
);
|
||||
|
||||
/// Sealed sender: zašifruje tak, že relay nezjistí odesílatele.
|
||||
/// [senderCertificate] vydává relay (server-signed).
|
||||
Future<Uint8List> sealedEncrypt({
|
||||
required ProtocolAddress recipient,
|
||||
required List<int> plaintext,
|
||||
required Uint8List senderCertificate,
|
||||
}) => _sealedCipher.encrypt(
|
||||
recipientAddress: recipient,
|
||||
plaintext: Uint8List.fromList(plaintext),
|
||||
senderCertificate: senderCertificate,
|
||||
);
|
||||
|
||||
/// Sealed sender: dešifruje obálku a ověří sender certificate proti
|
||||
/// pinnutému trust rootu relaye.
|
||||
Future<SepotSealedMessage> sealedDecrypt({
|
||||
required Uint8List ciphertext,
|
||||
required Uint8List trustRoot,
|
||||
required int timestampMs,
|
||||
}) async {
|
||||
final result = await _sealedCipher.decrypt(
|
||||
ciphertext: ciphertext,
|
||||
trustRoot: trustRoot,
|
||||
timestamp: timestampMs,
|
||||
);
|
||||
return SepotSealedMessage(
|
||||
plaintext: result.plaintext,
|
||||
senderName: result.senderAddress.name(),
|
||||
senderDeviceId: result.senderAddress.deviceId(),
|
||||
senderIdentityKey: Uint8List.fromList(
|
||||
result.senderIdentityKey.serialize().toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Vygeneruje dávku one-time prekeys (uloží privátní části do store,
|
||||
/// vrátí veřejné k publikaci). Zásoba 100 ks, doplnění pod
|
||||
/// prahem.
|
||||
Future<List<SepotOneTimePreKey>> generateOneTimePreKeys({
|
||||
required int startId,
|
||||
required int count,
|
||||
}) async {
|
||||
final result = <SepotOneTimePreKey>[];
|
||||
for (var i = 0; i < count; i++) {
|
||||
final id = startId + i;
|
||||
final private = PrivateKey.generate();
|
||||
final public = private.getPublicKey();
|
||||
await _stores.preKey.storePreKey(
|
||||
id,
|
||||
PreKeyRecord(id: id, publicKey: public, privateKey: private),
|
||||
);
|
||||
result.add(
|
||||
SepotOneTimePreKey(
|
||||
id: id,
|
||||
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/// Typované chyby, které se zobrazují uživateli.
|
||||
///
|
||||
/// Service vrstva nemá přístup k lokalizaci (žádný BuildContext), proto
|
||||
/// nesmí formulovat texty pro uživatele — vyhazuje tyhle typy a UI je
|
||||
/// překládá přes `describeError` (core/l10n/error_messages.dart).
|
||||
/// `toString()` je anglický technický popis pro logy, ne pro UI.
|
||||
library;
|
||||
|
||||
sealed class SepotUserError implements Exception {
|
||||
const SepotUserError();
|
||||
}
|
||||
|
||||
/// Trust root relaye nesedí s pinovaným — server nelze ověřit.
|
||||
class TrustRootChangedError extends SepotUserError {
|
||||
const TrustRootChangedError();
|
||||
|
||||
@override
|
||||
String toString() => 'relay trust root changed — refusing to communicate';
|
||||
}
|
||||
|
||||
/// Relay nedostupný (typicky fetch sender certificate).
|
||||
class RelayUnavailableError extends SepotUserError {
|
||||
const RelayUnavailableError();
|
||||
|
||||
@override
|
||||
String toString() => 'relay unavailable';
|
||||
}
|
||||
|
||||
/// Příloha přesahuje serverový limit.
|
||||
class AttachmentTooLargeError extends SepotUserError {
|
||||
const AttachmentTooLargeError(this.maxBytes);
|
||||
|
||||
final int maxBytes;
|
||||
|
||||
int get maxMb => (maxBytes / (1024 * 1024)).round();
|
||||
|
||||
@override
|
||||
String toString() => 'attachment too large (max $maxBytes bytes)';
|
||||
}
|
||||
|
||||
/// Skupina bez jediného dalšího člena nedává smysl.
|
||||
class GroupNeedsMemberError extends SepotUserError {
|
||||
const GroupNeedsMemberError();
|
||||
|
||||
@override
|
||||
String toString() => 'group needs at least one member';
|
||||
}
|
||||
|
||||
/// Překročen strop počtu členů skupiny.
|
||||
class GroupTooLargeError extends SepotUserError {
|
||||
const GroupTooLargeError(this.maxMembers);
|
||||
|
||||
final int maxMembers;
|
||||
|
||||
@override
|
||||
String toString() => 'group exceeds $maxMembers members';
|
||||
}
|
||||
|
||||
/// Člen skupiny nemá doručovací adresu (starý odkaz bez routingu).
|
||||
class MemberNoDeliveryAddressError extends SepotUserError {
|
||||
const MemberNoDeliveryAddressError(this.displayName);
|
||||
|
||||
final String displayName;
|
||||
|
||||
@override
|
||||
String toString() => 'member "$displayName" has no delivery address';
|
||||
}
|
||||
|
||||
/// Kontakt nemá doručovací adresu (starý odkaz bez routingu).
|
||||
class ContactNoDeliveryAddressError extends SepotUserError {
|
||||
const ContactNoDeliveryAddressError();
|
||||
|
||||
@override
|
||||
String toString() => 'contact has no delivery address';
|
||||
}
|
||||
|
||||
/// Identity klíč z relaye nesedí s uloženým kontaktem — možný MITM.
|
||||
class IdentityKeyMismatchError extends SepotUserError {
|
||||
const IdentityKeyMismatchError();
|
||||
|
||||
@override
|
||||
String toString() => 'identity key mismatch — possible MITM, message not sent';
|
||||
}
|
||||
|
||||
// === Obnova účtu (mnemonic) ===
|
||||
|
||||
/// Fráze má špatný počet slov.
|
||||
class MnemonicWordCountError extends SepotUserError {
|
||||
const MnemonicWordCountError(this.count, this.expected);
|
||||
|
||||
final int count;
|
||||
final int expected;
|
||||
|
||||
@override
|
||||
String toString() => 'mnemonic has $count words, expected $expected';
|
||||
}
|
||||
|
||||
/// Fráze neprošla validací BIP39 (neznámé slovo, checksum…).
|
||||
class MnemonicInvalidError extends SepotUserError {
|
||||
const MnemonicInvalidError(this.detail);
|
||||
|
||||
final String detail;
|
||||
|
||||
@override
|
||||
String toString() => 'invalid mnemonic: $detail';
|
||||
}
|
||||
|
||||
// === Zálohy historie (backup_service) ===
|
||||
|
||||
/// Soubor zálohy je poškozený (nejde přečíst/dešifrovat strukturu).
|
||||
class BackupCorruptedError extends SepotUserError {
|
||||
const BackupCorruptedError();
|
||||
|
||||
@override
|
||||
String toString() => 'corrupted backup file';
|
||||
}
|
||||
|
||||
/// Soubor není záloha Šepotu.
|
||||
class BackupUnknownFormatError extends SepotUserError {
|
||||
const BackupUnknownFormatError();
|
||||
|
||||
@override
|
||||
String toString() => 'unknown backup format';
|
||||
}
|
||||
|
||||
/// Záloha je šifrovaná klíčem jiné identity.
|
||||
class BackupWrongIdentityError extends SepotUserError {
|
||||
const BackupWrongIdentityError();
|
||||
|
||||
@override
|
||||
String toString() => 'backup belongs to a different identity';
|
||||
}
|
||||
|
||||
// === Kontakty ===
|
||||
|
||||
/// Kontakt s tímto fingerprintem už existuje.
|
||||
class ContactAlreadyExistsError extends SepotUserError {
|
||||
const ContactAlreadyExistsError();
|
||||
|
||||
@override
|
||||
String toString() => 'contact already exists';
|
||||
}
|
||||
|
||||
// === Nahlášení zneužití (report_service) ===
|
||||
|
||||
/// Hlášení bez popisu nemá co řešit.
|
||||
class ReportEmptyDescriptionError extends SepotUserError {
|
||||
const ReportEmptyDescriptionError();
|
||||
|
||||
@override
|
||||
String toString() => 'report has empty description';
|
||||
}
|
||||
|
||||
/// Server hlášení je nedosažitelný.
|
||||
class ReportConnectionError extends SepotUserError {
|
||||
const ReportConnectionError();
|
||||
|
||||
@override
|
||||
String toString() => 'report endpoint unreachable';
|
||||
}
|
||||
|
||||
/// Rate limit hlášení (429).
|
||||
class ReportRateLimitedError extends SepotUserError {
|
||||
const ReportRateLimitedError();
|
||||
|
||||
@override
|
||||
String toString() => 'report rate limited';
|
||||
}
|
||||
|
||||
/// Server hlášení vrátil chybový kód.
|
||||
class ReportServerError extends SepotUserError {
|
||||
const ReportServerError(this.statusCode);
|
||||
|
||||
final int statusCode;
|
||||
|
||||
@override
|
||||
String toString() => 'report failed with status $statusCode';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart' show DecoyPinConflict;
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
|
||||
/// Přeloží chybu na větu pro uživatele.
|
||||
///
|
||||
/// Typované chyby (`SepotUserError`, `DecoyPinConflict`) mají vlastní
|
||||
/// lokalizované hlášky; cokoli neznámého spadne do obecné hlášky
|
||||
/// s technickým popisem — radši ošklivá pravda než němé selhání.
|
||||
String describeError(AppLocalizations l10n, Object error) => switch (error) {
|
||||
TrustRootChangedError() => l10n.errorTrustRootChanged,
|
||||
RelayUnavailableError() => l10n.errorRelayUnavailable,
|
||||
AttachmentTooLargeError(:final maxMb) =>
|
||||
l10n.errorAttachmentTooLarge(maxMb),
|
||||
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
|
||||
GroupTooLargeError(:final maxMembers) =>
|
||||
l10n.errorGroupMaxMembers(maxMembers),
|
||||
MemberNoDeliveryAddressError(:final displayName) =>
|
||||
l10n.errorMemberNoDeliveryAddress(displayName),
|
||||
ContactNoDeliveryAddressError() => l10n.errorContactNoDeliveryAddress,
|
||||
IdentityKeyMismatchError() => l10n.errorIdentityKeyMismatch,
|
||||
MnemonicWordCountError(:final count, :final expected) =>
|
||||
l10n.restoreErrorMnemonicWordCount(count, expected),
|
||||
MnemonicInvalidError(:final detail) =>
|
||||
l10n.restoreErrorInvalidMnemonic(detail),
|
||||
BackupCorruptedError() => l10n.backupErrorCorruptedFile,
|
||||
BackupUnknownFormatError() => l10n.backupErrorUnknownFormat,
|
||||
BackupWrongIdentityError() => l10n.backupErrorWrongIdentity,
|
||||
ContactAlreadyExistsError() => l10n.contactsErrorAlreadyExists,
|
||||
ReportEmptyDescriptionError() => l10n.reportErrorEmptyDescription,
|
||||
ReportConnectionError() =>
|
||||
l10n.reportErrorConnection(AppConstants.supportEmail),
|
||||
ReportRateLimitedError() =>
|
||||
l10n.reportErrorRateLimited(AppConstants.supportEmail),
|
||||
ReportServerError(:final statusCode) =>
|
||||
l10n.reportErrorServer(statusCode, AppConstants.supportEmail),
|
||||
DecoyPinConflict() => l10n.settingsDecoyPinConflict,
|
||||
_ => l10n.errorGeneric(error.toString()),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
|
||||
export 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
|
||||
/// Zkratka pro přístup k lokalizovaným textům: `context.l10n.klic`.
|
||||
extension L10nX on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||
}
|
||||
|
||||
/// Lokalizace mimo widget tree (notifikace, headless push start).
|
||||
///
|
||||
/// Preferuje ruční volbu jazyka (`override`, typicky
|
||||
/// `LocaleNotifier.readStored()`), jinak jazyk systému; nepodporovaný
|
||||
/// jazyk spadne na angličtinu — stejné pořadí jako `supportedLocales`.
|
||||
AppLocalizations l10nFor(Locale? override) {
|
||||
final locale = override ?? PlatformDispatcher.instance.locale;
|
||||
final lang = Locale(locale.languageCode);
|
||||
final supported = AppLocalizations.delegate.isSupported(lang);
|
||||
return lookupAppLocalizations(supported ? lang : const Locale('en'));
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/// Klient hloupého relaye — REST (registrace, prekey bundle, sender
|
||||
/// certificate) + WS (AUTH výzva, obálky, doručení, ACK).
|
||||
///
|
||||
/// Wire = protobuf rámce z `proto/sepot.proto` (jediný zdroj pravdy pro
|
||||
/// Dart i Rust). Klient NIKDY neposílá nic nešifrovaného — payload obálky
|
||||
/// je sealed-sender ciphertext, relay vidí jen dst_address + msg_uuid.
|
||||
///
|
||||
/// ACK sémantika: relay maže zprávu z fronty při ACK (hard delete).
|
||||
/// ACK se proto posílá až PO uložení dešifrované zprávy na disk —
|
||||
/// jinak pád mezi doručením a uložením = ztracená zpráva.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
import 'package:sepot/core/network/relay_transport.dart';
|
||||
import 'package:sepot/proto/sepot.pb.dart' as pb;
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
enum RelayConnectionState { disconnected, connecting, connected }
|
||||
|
||||
/// Výsledek registrace účtu na relayi.
|
||||
class RelayRegistration {
|
||||
RelayRegistration({required this.accountId, required this.deviceUuid});
|
||||
|
||||
final String accountId; // hex
|
||||
final String deviceUuid; // hex
|
||||
}
|
||||
|
||||
class RelayClient {
|
||||
RelayClient({
|
||||
required this.baseUrl,
|
||||
required SepotCrypto crypto,
|
||||
RelayTransport? transport,
|
||||
}) : _crypto = crypto,
|
||||
_transport = transport ?? DirectTransport();
|
||||
|
||||
final String baseUrl;
|
||||
final SepotCrypto _crypto;
|
||||
|
||||
/// Jak se otevírají spojení (přímé vs Tor). Vlastní ho tento klient —
|
||||
/// [dispose] ho zavře.
|
||||
final RelayTransport _transport;
|
||||
|
||||
String get _wsUrl => baseUrl.replaceFirst('http', 'ws');
|
||||
|
||||
String? _deviceUuid;
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _wsSub;
|
||||
Timer? _reconnectTimer;
|
||||
var _reconnectDelayS = 1;
|
||||
var _shouldRun = false;
|
||||
|
||||
/// Poslední požadovaná push registrace. Drží se, aby se dala znovu odeslat
|
||||
/// po každém (re)connectu — WS je jediná autentizovaná cesta a endpoint se
|
||||
/// u distributora může měnit.
|
||||
pb.RegisterPush? _pendingPush;
|
||||
|
||||
final _stateController =
|
||||
StreamController<RelayConnectionState>.broadcast();
|
||||
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
||||
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
||||
|
||||
var _state = RelayConnectionState.disconnected;
|
||||
|
||||
RelayConnectionState get state => _state;
|
||||
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
||||
|
||||
/// Příchozí obálky (offline fronta i online doručení). Konzument MUSÍ
|
||||
/// po uložení zavolat [ack].
|
||||
Stream<pb.Envelope> get deliveries => _deliverController.stream;
|
||||
|
||||
void _setState(RelayConnectionState s) {
|
||||
_state = s;
|
||||
_stateController.add(s);
|
||||
}
|
||||
|
||||
static Uint8List randomBytes(int n) {
|
||||
final rng = Random.secure();
|
||||
return Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
|
||||
}
|
||||
|
||||
static Uint8List hexToBytes(String hexStr) => Uint8List.fromList([
|
||||
for (var i = 0; i < hexStr.length; i += 2)
|
||||
int.parse(hexStr.substring(i, i + 2), radix: 16),
|
||||
]);
|
||||
|
||||
// ── REST ────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /v1/register — nahraje identitu + prekey bundle + dávku OTP.
|
||||
Future<RelayRegistration> register({
|
||||
required SepotPreKeyBundle bundle,
|
||||
required List<SepotOneTimePreKey> oneTimePreKeys,
|
||||
required Uint8List deliveryAddress,
|
||||
}) async {
|
||||
final resp = await _transport.httpClient().post(
|
||||
Uri.parse('$baseUrl/v1/register'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'identity_key': base64Encode(_crypto.identityPublicKey),
|
||||
'device_id': 1,
|
||||
'registration_id': _crypto.registrationId,
|
||||
'delivery_address': base64Encode(deliveryAddress),
|
||||
'signed_prekey': {
|
||||
'key_id': bundle.signedPreKeyId,
|
||||
'public_key': base64Encode(bundle.signedPreKeyPublic),
|
||||
'signature': base64Encode(bundle.signedPreKeySignature),
|
||||
},
|
||||
'kyber_prekey': {
|
||||
'key_id': bundle.kyberPreKeyId,
|
||||
'public_key': base64Encode(bundle.kyberPreKeyPublic),
|
||||
'signature': base64Encode(bundle.kyberPreKeySignature),
|
||||
'is_last_resort': true,
|
||||
},
|
||||
'one_time_prekeys': [
|
||||
for (final otp in oneTimePreKeys)
|
||||
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (resp.statusCode != 201) {
|
||||
throw RelayException('registration failed', resp.statusCode, resp.body);
|
||||
}
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final reg = RelayRegistration(
|
||||
accountId: body['account_id'] as String,
|
||||
deviceUuid: body['device_uuid'] as String,
|
||||
);
|
||||
_deviceUuid = reg.deviceUuid;
|
||||
_crypto.bindAccount(reg.accountId);
|
||||
return reg;
|
||||
}
|
||||
|
||||
/// Obnoví vazbu po startu aplikace (registrace už proběhla dřív).
|
||||
void restoreSession({required String accountId, required String deviceUuid}) {
|
||||
_deviceUuid = deviceUuid;
|
||||
_crypto.bindAccount(accountId);
|
||||
}
|
||||
|
||||
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
|
||||
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
||||
final hexAddr = deliveryAddress
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final resp =
|
||||
await _transport.httpClient().get(Uri.parse('$baseUrl/v1/keys/$hexAddr'));
|
||||
if (resp.statusCode != 200) {
|
||||
throw RelayException('prekey bundle fetch failed', resp.statusCode, resp.body);
|
||||
}
|
||||
final device = (jsonDecode(resp.body)['devices'] as List).first
|
||||
as Map<String, dynamic>;
|
||||
final spk = device['signed_prekey'] as Map<String, dynamic>;
|
||||
final kyber = device['kyber_prekey'] as Map<String, dynamic>;
|
||||
final otp = device['one_time_prekey'] as Map<String, dynamic>?;
|
||||
return SepotPreKeyBundle(
|
||||
registrationId: device['registration_id'] as int,
|
||||
deviceId: device['device_id'] as int,
|
||||
preKeyId: otp?['key_id'] as int?,
|
||||
preKeyPublic: otp == null
|
||||
? null
|
||||
: Uint8List.fromList(base64Decode(otp['public_key'] as String)),
|
||||
signedPreKeyId: spk['key_id'] as int,
|
||||
signedPreKeyPublic:
|
||||
Uint8List.fromList(base64Decode(spk['public_key'] as String)),
|
||||
signedPreKeySignature:
|
||||
Uint8List.fromList(base64Decode(spk['signature'] as String)),
|
||||
identityKey:
|
||||
Uint8List.fromList(base64Decode(device['identity_key'] as String)),
|
||||
kyberPreKeyId: kyber['key_id'] as int,
|
||||
kyberPreKeyPublic:
|
||||
Uint8List.fromList(base64Decode(kyber['public_key'] as String)),
|
||||
kyberPreKeySignature:
|
||||
Uint8List.fromList(base64Decode(kyber['signature'] as String)),
|
||||
);
|
||||
}
|
||||
|
||||
/// POST /v1/attachments — nahraje šifrovaný blob přílohy. Tělo = raw
|
||||
/// bajty (ciphertext || tag); relay z nich nic nevyčte a vazbu na
|
||||
/// zařízení si neukládá. Auth stejným podpisem jako sender certificate.
|
||||
Future<String> uploadAttachment(Uint8List blob) async {
|
||||
final deviceUuid = _requireDevice();
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final signature = _crypto.sign(utf8.encode('POST /v1/attachments\n$ts'));
|
||||
final resp = await _transport
|
||||
.httpClient()
|
||||
.post(
|
||||
Uri.parse('$baseUrl/v1/attachments'),
|
||||
headers: {
|
||||
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
|
||||
'content-type': 'application/octet-stream',
|
||||
},
|
||||
body: blob,
|
||||
)
|
||||
.timeout(const Duration(minutes: 3));
|
||||
if (resp.statusCode != 201) {
|
||||
throw RelayException('attachment upload failed', resp.statusCode, resp.body);
|
||||
}
|
||||
return (jsonDecode(resp.body) as Map<String, dynamic>)['blob_id'] as String;
|
||||
}
|
||||
|
||||
/// GET /v1/attachments/{blob_id} — stáhne šifrovaný blob. Bez auth
|
||||
/// (blob_id = bearer capability z E2E zprávy). Integrita se ověřuje
|
||||
/// až v AttachmentCipher.decrypt proti digestu ze zprávy.
|
||||
Future<Uint8List> downloadAttachment(String blobId) async {
|
||||
final resp = await _transport
|
||||
.httpClient()
|
||||
.get(Uri.parse('$baseUrl/v1/attachments/$blobId'))
|
||||
.timeout(const Duration(minutes: 3));
|
||||
if (resp.statusCode != 200) {
|
||||
throw RelayException(
|
||||
'attachment download failed', resp.statusCode, resp.body);
|
||||
}
|
||||
return resp.bodyBytes;
|
||||
}
|
||||
|
||||
/// DELETE /v1/identity — deregistrace účtu na relayi (identita,
|
||||
/// zařízení, prekeys, push token i nedoručená fronta). Auth stejným
|
||||
/// podpisem jako sender certificate — smazat účet smí jen držitel
|
||||
/// identity klíče. Volá se PŘED lokálním wipe (podpis potřebuje klíč).
|
||||
Future<void> deleteIdentity() async {
|
||||
final deviceUuid = _requireDevice();
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final signature = _crypto.sign(utf8.encode('DELETE /v1/identity\n$ts'));
|
||||
final resp = await _transport
|
||||
.httpClient()
|
||||
.delete(
|
||||
Uri.parse('$baseUrl/v1/identity'),
|
||||
headers: {
|
||||
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
|
||||
},
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (resp.statusCode != 204) {
|
||||
throw RelayException(
|
||||
'identity deletion failed', resp.statusCode, resp.body);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /v1/certificate/delivery — krátkodobý SenderCertificate
|
||||
/// + pinovaný trust root relaye.
|
||||
Future<(Uint8List cert, Uint8List trustRoot)>
|
||||
fetchSenderCertificate() async {
|
||||
final deviceUuid = _requireDevice();
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
final signature =
|
||||
_crypto.sign(utf8.encode('GET /v1/certificate/delivery\n$ts'));
|
||||
final resp = await _transport.httpClient().get(
|
||||
Uri.parse('$baseUrl/v1/certificate/delivery'),
|
||||
headers: {'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}'},
|
||||
);
|
||||
if (resp.statusCode != 200) {
|
||||
throw RelayException('sender certificate fetch failed', resp.statusCode, resp.body);
|
||||
}
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
return (
|
||||
Uint8List.fromList(base64Decode(body['sender_certificate'] as String)),
|
||||
Uint8List.fromList(base64Decode(body['trust_root'] as String)),
|
||||
);
|
||||
}
|
||||
|
||||
// ── WS ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Spustí spojení s automatickým reconnectem (exponenciální backoff).
|
||||
void start() {
|
||||
_shouldRun = true;
|
||||
_connect();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_shouldRun = false;
|
||||
_reconnectTimer?.cancel();
|
||||
await _teardownChannel();
|
||||
_setState(RelayConnectionState.disconnected);
|
||||
}
|
||||
|
||||
Future<void> _teardownChannel() async {
|
||||
await _wsSub?.cancel();
|
||||
_wsSub = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (!_shouldRun || _state != RelayConnectionState.disconnected) return;
|
||||
final deviceUuid = _deviceUuid;
|
||||
if (deviceUuid == null) return;
|
||||
|
||||
_setState(RelayConnectionState.connecting);
|
||||
try {
|
||||
final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws'));
|
||||
await channel.ready;
|
||||
_channel = channel;
|
||||
|
||||
// AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA)
|
||||
final nonceClient = randomBytes(16);
|
||||
var authenticated = false;
|
||||
|
||||
_wsSub = channel.stream.listen(
|
||||
(data) {
|
||||
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
||||
if (!authenticated) {
|
||||
if (frame.hasChallenge()) {
|
||||
final signature = _crypto.sign([
|
||||
...nonceClient,
|
||||
...frame.challenge.nonceServer,
|
||||
...utf8.encode('sepot-auth-v1'),
|
||||
]);
|
||||
_send(pb.ClientFrame(
|
||||
authProof: pb.AuthProof(signature: signature),
|
||||
));
|
||||
} else if (frame.hasAuthOk()) {
|
||||
// Server podpis OVĚŘIL — teprve teď jsme reálně připojeni
|
||||
authenticated = true;
|
||||
_reconnectDelayS = 1;
|
||||
_setState(RelayConnectionState.connected);
|
||||
// Push endpoint se re-registruje po každém (re)connectu.
|
||||
_sendPush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.hasDeliver()) {
|
||||
_deliverController.add(frame.deliver);
|
||||
} else if (frame.hasReceipt()) {
|
||||
_receiptController.add(frame.receipt);
|
||||
}
|
||||
},
|
||||
onDone: _onDisconnected,
|
||||
onError: (Object _) => _onDisconnected(),
|
||||
);
|
||||
|
||||
_send(pb.ClientFrame(
|
||||
auth: pb.Auth(
|
||||
deviceUuid: hexToBytes(deviceUuid),
|
||||
nonceClient: nonceClient,
|
||||
),
|
||||
));
|
||||
} catch (_) {
|
||||
_onDisconnected();
|
||||
}
|
||||
}
|
||||
|
||||
void _onDisconnected() {
|
||||
unawaited(_teardownChannel());
|
||||
_setState(RelayConnectionState.disconnected);
|
||||
if (!_shouldRun) return;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(Duration(seconds: _reconnectDelayS), _connect);
|
||||
_reconnectDelayS = min(_reconnectDelayS * 2, 60);
|
||||
}
|
||||
|
||||
void _send(pb.ClientFrame frame) {
|
||||
final channel = _channel;
|
||||
if (channel == null) throw StateError('WebSocket not connected');
|
||||
channel.sink.add(frame.writeToBuffer());
|
||||
}
|
||||
|
||||
/// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE /
|
||||
/// DELIVERED_ONLINE) spárovaný přes msg_uuid.
|
||||
Future<pb.Receipt> sendEnvelope({
|
||||
required Uint8List dstAddress,
|
||||
required Uint8List payload,
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final msgUuid = randomBytes(16);
|
||||
final receiptFuture = _receiptController.stream
|
||||
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
||||
.timeout(timeout);
|
||||
_send(pb.ClientFrame(
|
||||
envelope: pb.Envelope(
|
||||
dstAddress: dstAddress,
|
||||
msgUuid: msgUuid,
|
||||
contentHint: pb.ContentHint.CONTENT_HINT_DEFAULT,
|
||||
payload: payload,
|
||||
),
|
||||
));
|
||||
return receiptFuture;
|
||||
}
|
||||
|
||||
/// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO
|
||||
/// uložení zprávy na disk.
|
||||
void ack(List<int> msgUuid) {
|
||||
_send(pb.ClientFrame(ack: pb.Ack(msgUuid: msgUuid)));
|
||||
}
|
||||
|
||||
/// Nastaví/aktualizuje push registraci u relaye. Bezpečné volat kdykoliv:
|
||||
/// požadavek se zapamatuje a odešle po autentizaci (a znovu po každém
|
||||
/// reconnectu — endpoint distributora se může měnit). platform=NONE push
|
||||
/// vypne (relay token smaže → anonymní režim).
|
||||
void registerPush(pb.RegisterPush registration) {
|
||||
_pendingPush = registration;
|
||||
if (_state == RelayConnectionState.connected) {
|
||||
_sendPush();
|
||||
}
|
||||
}
|
||||
|
||||
void _sendPush() {
|
||||
final reg = _pendingPush;
|
||||
if (reg == null || _channel == null) return;
|
||||
_send(pb.ClientFrame(registerPush: reg));
|
||||
}
|
||||
|
||||
String _requireDevice() {
|
||||
final d = _deviceUuid;
|
||||
if (d == null) {
|
||||
throw StateError('Call register() or restoreSession() first');
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
static bool _bytesEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await stop();
|
||||
_transport.close();
|
||||
await _stateController.close();
|
||||
await _deliverController.close();
|
||||
await _receiptController.close();
|
||||
}
|
||||
}
|
||||
|
||||
class RelayException implements Exception {
|
||||
RelayException(this.message, this.statusCode, this.body);
|
||||
|
||||
final String message;
|
||||
final int statusCode;
|
||||
final String body;
|
||||
|
||||
@override
|
||||
String toString() => 'RelayException($statusCode): $message — $body';
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/// Transport relaye: JAK se navazují REST i WS spojení.
|
||||
///
|
||||
/// Dvě varianty se stejným rozhraním, aby [RelayClient] nevěděl, kudy
|
||||
/// data tečou:
|
||||
/// - [DirectTransport] — přímé šifrované spojení (HTTPS/WSS) na
|
||||
/// `relay.sepot.cz`. Default.
|
||||
/// - [TorTransport] — všechna spojení jdou přes lokální SOCKS5 proxy
|
||||
/// (Orbot) na `.onion` adresu relaye. IP klienta se k relayi nikdy
|
||||
/// nedostane; jméno `.onion` resolvuje Tor (klient sám DNS nedělá →
|
||||
/// žádný únik dotazu).
|
||||
///
|
||||
/// Přepínání je runtime záležitost (viz `MessagingService.setTorEnabled`):
|
||||
/// změna transportu = přestavení [RelayClient] s novou instancí.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/io_client.dart';
|
||||
import 'package:socks5_proxy/socks_client.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// Abstrakce nad tím, jak klient otevírá spojení k relayi.
|
||||
abstract class RelayTransport {
|
||||
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…).
|
||||
http.Client httpClient();
|
||||
|
||||
/// Naváže WebSocket na [url].
|
||||
WebSocketChannel connectWs(Uri url);
|
||||
|
||||
/// Uvolní podkladové klienty. Po zavření se transport už nepoužívá.
|
||||
void close();
|
||||
}
|
||||
|
||||
/// Přímé spojení bez proxy — HTTPS/WSS rovnou na relay.
|
||||
class DirectTransport implements RelayTransport {
|
||||
http.Client? _client;
|
||||
|
||||
@override
|
||||
http.Client httpClient() => _client ??= http.Client();
|
||||
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_client?.close();
|
||||
_client = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tor: REST i WS se tunelují přes SOCKS5 proxy (výchozí Orbot na
|
||||
/// `127.0.0.1:9050`). Onion adresa se do SOCKS CONNECT posílá jako
|
||||
/// doména (ATYP=0x03), takže její resolvování obstará Tor.
|
||||
///
|
||||
/// Jeden [HttpClient] slouží REST i WS — WS upgrade jede přes stejnou
|
||||
/// connectionFactory (SOCKS socket), takže se tuneluje stejně.
|
||||
class TorTransport implements RelayTransport {
|
||||
TorTransport({this.socksHost = '127.0.0.1', this.socksPort = 9050});
|
||||
|
||||
/// Adresa SOCKS5 proxy. Orbot výchozí je `127.0.0.1:9050`.
|
||||
final String socksHost;
|
||||
final int socksPort;
|
||||
|
||||
HttpClient? _httpClient;
|
||||
IOClient? _ioClient;
|
||||
|
||||
HttpClient _sharedHttpClient() {
|
||||
final client = _httpClient;
|
||||
if (client != null) return client;
|
||||
final built = HttpClient();
|
||||
// Nastaví connectionFactory tak, aby každé spojení šlo přes SOCKS5.
|
||||
// Onion je http:// — Tor obálka šifruje, TLS uvnitř netřeba.
|
||||
SocksTCPClient.assignToHttpClient(built, [
|
||||
ProxySettings(InternetAddress(socksHost), socksPort),
|
||||
]);
|
||||
return _httpClient = built;
|
||||
}
|
||||
|
||||
@override
|
||||
http.Client httpClient() => _ioClient ??= IOClient(_sharedHttpClient());
|
||||
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) =>
|
||||
IOWebSocketChannel.connect(_withExplicitPort(url),
|
||||
customClient: _sharedHttpClient());
|
||||
|
||||
/// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS
|
||||
/// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
|
||||
/// (ws→80, wss→443). U přímého transportu to řeší WS klient sám, tady
|
||||
/// port protéká až do connectionFactory, tak ho musíme dodat.
|
||||
static Uri _withExplicitPort(Uri url) {
|
||||
if (url.hasPort) return url;
|
||||
final port = url.scheme == 'wss' ? 443 : 80;
|
||||
return url.replace(port: port);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
// IOClient.close() zavře i sdílený HttpClient; force pro jistotu
|
||||
// strhne i případné živé WS spojení.
|
||||
_ioClient?.close();
|
||||
_ioClient = null;
|
||||
_httpClient?.close(force: true);
|
||||
_httpClient = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/// Nativní APNs most pro iOS (bez Firebase).
|
||||
///
|
||||
/// Komunikuje s AppDelegate.swift přes MethodChannel `cz.sepot.sepot/apns`:
|
||||
/// Dart → native: register (registerForRemoteNotifications), getToken
|
||||
/// native → Dart: onToken (APNs device token hex), onWake (tichý budíček)
|
||||
///
|
||||
/// APNs token se pošle relayi jako PLATFORM_APNS; relay pak posílá tichý
|
||||
/// background budíček přímo přes Apple, žádný Google v cestě.
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class ApnsBridge {
|
||||
ApnsBridge._();
|
||||
|
||||
static const _channel = MethodChannel('cz.sepot.sepot/apns');
|
||||
static bool _handlerSet = false;
|
||||
|
||||
/// Callback při vydání/změně APNs tokenu (hex string).
|
||||
static void Function(String token)? onToken;
|
||||
|
||||
/// Callback při tichém budíčku (probudit WS a vylít frontu).
|
||||
static void Function()? onWake;
|
||||
|
||||
static bool get isSupported => Platform.isIOS;
|
||||
|
||||
/// Zaregistruje příjem native → Dart callbacků. Idempotentní; volat
|
||||
/// při startu (i killed-state, aby onWake fungoval po probuzení pushem).
|
||||
static void ensureHandler() {
|
||||
if (_handlerSet || !isSupported) return;
|
||||
_handlerSet = true;
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'onToken':
|
||||
final token = call.arguments as String?;
|
||||
if (token != null) onToken?.call(token);
|
||||
case 'onWake':
|
||||
onWake?.call();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Spustí nativní APNs registraci (systém se případně zeptá na povolení
|
||||
/// notifikací). Token dorazí asynchronně přes [onToken].
|
||||
static Future<void> register() async {
|
||||
if (!isSupported) return;
|
||||
ensureHandler();
|
||||
await _channel.invokeMethod<void>('register');
|
||||
}
|
||||
|
||||
/// Už vydaný token (null, pokud APNs ještě neodpověděl).
|
||||
static Future<String?> currentToken() async {
|
||||
if (!isSupported) return null;
|
||||
return _channel.invokeMethod<String>('getToken');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/// Vstupní vrstva UnifiedPush — registruje se z main() PŘED runApp,
|
||||
/// protože Android budí zabitou appku s argumentem `--unifiedpush-bg`
|
||||
/// a bez UI: callbacky pak musí existovat dřív, než (ne)vznikne widget
|
||||
/// tree.
|
||||
///
|
||||
/// Dvě situace při příchodu budíčku:
|
||||
/// - appka běží (messaging nastartován) → wake: připoj WS, vylej frontu
|
||||
/// - killed/zamčeno → DB je za PINem, nic nejde dešifrovat →
|
||||
/// jen generická lokální notifikace (PushNotifier); zprávy se
|
||||
/// stáhnou, až uživatel appku odemkne
|
||||
///
|
||||
/// Nový endpoint doručený na pozadí se NEDÁ poslat relayi (vyžaduje
|
||||
/// autentizovaný WS + krypto z DB) → uloží se do OS keystore a odešle
|
||||
/// při příštím plném startu (PushService.init ho vyzvedne).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:sepot/core/push/apns_ios.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
import 'package:sepot/core/push/push_service.dart';
|
||||
import 'package:unifiedpush/unifiedpush.dart';
|
||||
|
||||
/// FCM budíček se zabitou appkou — plugin spouští handler ve vlastním
|
||||
/// background isolate. Stejný model jako UnifiedPush killed-state:
|
||||
/// DB je zamčená, nic nejde dešifrovat → jen generická notifikace.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> sepotFcmBackgroundWake(RemoteMessage message) async {
|
||||
await Firebase.initializeApp();
|
||||
await PushNotifier.showWakeNotification();
|
||||
}
|
||||
|
||||
class PushBootstrap {
|
||||
PushBootstrap._();
|
||||
|
||||
/// True = proces nastartoval Android kvůli pushi (`--unifiedpush-bg`),
|
||||
/// bez UI. main() pak NEvolá runApp.
|
||||
static bool backgroundStart = false;
|
||||
|
||||
static PushService? _service;
|
||||
|
||||
static const _pendingEndpointKey = 'pending_push_endpoint';
|
||||
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
|
||||
static const _store = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
/// Režim pushů žije v OS keystore (NE v SQLCipher DB) — musí být
|
||||
/// čitelný před unlockem, aby main() věděl, zda registrovat FCM.
|
||||
static Future<String> readMode() async =>
|
||||
await _store.read(key: _modeKey) ?? 'off';
|
||||
|
||||
static Future<void> writeMode(String mode) =>
|
||||
_store.write(key: _modeKey, value: mode);
|
||||
|
||||
static Future<void> init(List<String> args) async {
|
||||
backgroundStart = args.contains('--unifiedpush-bg');
|
||||
|
||||
// iOS: nativní APNs most. onWake musí fungovat i v killed-state
|
||||
// (iOS probudí engine tichým pushem), proto handler registrujeme tady,
|
||||
// před runApp. Messaging se napojí přes attach()/PushService.init.
|
||||
if (Platform.isIOS) {
|
||||
ApnsBridge.onWake = _wake;
|
||||
ApnsBridge.ensureHandler();
|
||||
} else {
|
||||
await UnifiedPush.initialize(
|
||||
onNewEndpoint: _onNewEndpoint,
|
||||
onRegistrationFailed: (_, _) {},
|
||||
onUnregistered: _onUnregistered,
|
||||
onMessage: _onMessage,
|
||||
);
|
||||
}
|
||||
|
||||
// Firebase se dotkne Googlu (installations FID) → inicializovat
|
||||
// VÝHRADNĚ když si uživatel FCM režim sám zapnul.
|
||||
if (!Platform.isLinux && await readMode() == 'fcm') {
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
FirebaseMessaging.onBackgroundMessage(sepotFcmBackgroundWake);
|
||||
} catch (_) {
|
||||
// bez google-services konfigurace / na desktopu — FCM nedostupné
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Napojí plnou appku (messaging vrstvu) na už zaregistrované callbacky.
|
||||
static void attach(PushService service) {
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// Sdílené probuzení (UnifiedPush i APNs): appka běží → připoj WS a
|
||||
/// vylej frontu; killed/zamčeno → generická notifikace bez obsahu.
|
||||
static void _wake() {
|
||||
final s = _service;
|
||||
if (s != null && s.canWake) {
|
||||
s.wake();
|
||||
} else {
|
||||
unawaited(PushNotifier.showWakeNotification());
|
||||
}
|
||||
}
|
||||
|
||||
static void _onMessage(PushMessage message, String instance) {
|
||||
// Obsah budíčku se ZÁMĚRNĚ ignoruje (konstantní ťuk {"v":1}).
|
||||
final s = _service;
|
||||
if (s != null && s.canWake) {
|
||||
s.wake();
|
||||
} else {
|
||||
unawaited(PushNotifier.showWakeNotification());
|
||||
}
|
||||
}
|
||||
|
||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||
final s = _service;
|
||||
if (s != null) {
|
||||
s.handleNewEndpoint(endpoint.url);
|
||||
} else {
|
||||
unawaited(_store.write(key: _pendingEndpointKey, value: endpoint.url));
|
||||
}
|
||||
}
|
||||
|
||||
static void _onUnregistered(String instance) {
|
||||
_service?.handleUnregistered();
|
||||
}
|
||||
|
||||
/// Vyzvedne (a smaže) endpoint uložený během background startu.
|
||||
static Future<String?> takePendingEndpoint() async {
|
||||
final value = await _store.read(key: _pendingEndpointKey);
|
||||
if (value != null) {
|
||||
await _store.delete(key: _pendingEndpointKey);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/// Lokální notifikace pro push budíčky.
|
||||
///
|
||||
/// Zásada: notifikace NIKDY nenese obsah, odesílatele ani počet zpráv —
|
||||
/// při killed-state budíčku je DB zamčená (PIN → Argon2id), takže ani
|
||||
/// technicky NELZE nic dešifrovat. Generické „máš nové zprávy" je tudíž
|
||||
/// jediná poctivá (a metadata-čistá) možnost — stejný model jako
|
||||
/// zamčený Threema/Signal.
|
||||
library;
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/providers/locale_provider.dart';
|
||||
|
||||
class PushNotifier {
|
||||
PushNotifier._();
|
||||
|
||||
static final _plugin = FlutterLocalNotificationsPlugin();
|
||||
static bool _ready = false;
|
||||
|
||||
/// Jedno pevné id — opakované budíčky aktualizují touž notifikaci,
|
||||
/// žádné hromadění (počet by sám o sobě byl metadata leak).
|
||||
static const int _notificationId = 1;
|
||||
|
||||
static Future<void> _ensureInit() async {
|
||||
if (_ready) return;
|
||||
final l10n = l10nFor(await LocaleNotifier.readStored());
|
||||
await _plugin.initialize(
|
||||
InitializationSettings(
|
||||
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
linux: LinuxInitializationSettings(
|
||||
defaultActionName: l10n.pushActionOpen,
|
||||
),
|
||||
),
|
||||
);
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
/// Sdílené detaily notifikace — channel ID 'zpravy' je NEMĚNNÉ
|
||||
/// (Android by při změně vytvořil nový kanál), name/description se
|
||||
/// lokalizují (systém je čte při vytvoření kanálu).
|
||||
static NotificationDetails _details(AppLocalizations l10n) {
|
||||
return NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'zpravy',
|
||||
l10n.pushChannelMessagesName,
|
||||
channelDescription: l10n.pushChannelMessagesDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.message,
|
||||
),
|
||||
linux: const LinuxNotificationDetails(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Android 13+ runtime permission — volat při zapnutí pushe v nastavení.
|
||||
static Future<void> requestPermission() async {
|
||||
await _ensureInit();
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
}
|
||||
|
||||
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
|
||||
/// Jen jméno odesílatele — obsah zprávy do notifikace NIKDY (šifrovaný
|
||||
/// obsah nepatří do notification logu OS ani na zamčenou obrazovku).
|
||||
static Future<void> showMessageNotification(String senderName) async {
|
||||
await _ensureInit();
|
||||
final l10n = l10nFor(await LocaleNotifier.readStored());
|
||||
await _plugin.show(
|
||||
_notificationId,
|
||||
l10n.commonAppName,
|
||||
l10n.pushNewMessageFrom(senderName),
|
||||
_details(l10n),
|
||||
);
|
||||
}
|
||||
|
||||
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
|
||||
/// WS připojení → fronta se vyleje normální cestou).
|
||||
static Future<void> showWakeNotification() async {
|
||||
await _ensureInit();
|
||||
final l10n = l10nFor(await LocaleNotifier.readStored());
|
||||
await _plugin.show(
|
||||
_notificationId,
|
||||
l10n.commonAppName,
|
||||
l10n.pushNewMessages,
|
||||
_details(l10n),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/// Napojení UnifiedPush distributora na relay budíčky.
|
||||
///
|
||||
/// Tok:
|
||||
/// distributor (ntfy) → onNewEndpoint → relay.registerPush(UNIFIEDPUSH, url)
|
||||
/// příchozí budíček → onMessage → messaging.onPushWake()
|
||||
/// (připoj se na WS → vylej offline frontu)
|
||||
///
|
||||
/// Obsah budíčku se ZÁMĚRNĚ ignoruje — je to konstantní „ťuk" `{"v":1}`,
|
||||
/// žádný obsah ani odesílatel jím neprochází. Stačí i nešifrovaný
|
||||
/// (`decrypted == false`) — potřebujeme jen fakt „něco přišlo, připoj se".
|
||||
///
|
||||
/// Callbacky u UnifiedPush registruje [PushBootstrap] z main() (kvůli
|
||||
/// killed-state startu `--unifiedpush-bg` bez UI); tahle třída je
|
||||
/// most bootstrap ↔ messaging + API pro nastavení.
|
||||
///
|
||||
/// FCM „fast mode" (opt-in) je druhá cesta (viz [setModeFcm]); default je
|
||||
/// degooglovaný UnifiedPush.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:sepot/core/push/apns_ios.dart';
|
||||
import 'package:sepot/core/push/push_bootstrap.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/proto/sepot.pb.dart' as pb;
|
||||
import 'package:unifiedpush/unifiedpush.dart';
|
||||
|
||||
class PushService {
|
||||
PushService(this._messaging);
|
||||
|
||||
final MessagingService _messaging;
|
||||
StreamSubscription<String>? _fcmTokenSub;
|
||||
StreamSubscription<RemoteMessage>? _fcmMessageSub;
|
||||
|
||||
pb.RegisterPush _registration(pb.RegisterPush_Platform platform,
|
||||
[Uint8List? endpoint]) =>
|
||||
pb.RegisterPush(
|
||||
platform: platform,
|
||||
endpoint: endpoint ?? Uint8List(0),
|
||||
);
|
||||
|
||||
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
||||
bool get canWake => _messaging.isStarted;
|
||||
|
||||
void wake() => _messaging.onPushWake();
|
||||
|
||||
void handleNewEndpoint(String url) {
|
||||
_messaging.registerPush(_registration(
|
||||
pb.RegisterPush_Platform.PLATFORM_UNIFIEDPUSH,
|
||||
Uint8List.fromList(utf8.encode(url)),
|
||||
));
|
||||
}
|
||||
|
||||
void handleUnregistered() {
|
||||
// Distributor nás odregistroval → relay ať token smaže (anonymní režim).
|
||||
_messaging
|
||||
.registerPush(_registration(pb.RegisterPush_Platform.PLATFORM_NONE));
|
||||
}
|
||||
|
||||
/// Napojí messaging na push callbacky a doručí relayi endpoint, který
|
||||
/// případně dorazil během killed-state startu. Idempotentní — volat po
|
||||
/// startu messaging vrstvy.
|
||||
Future<void> init() async {
|
||||
PushBootstrap.attach(this);
|
||||
final pending = await PushBootstrap.takePendingEndpoint();
|
||||
if (pending != null) {
|
||||
handleNewEndpoint(pending);
|
||||
}
|
||||
// iOS: APNs token → relay jako PLATFORM_APNS; tichý budíček → wake.
|
||||
if (ApnsBridge.isSupported) {
|
||||
ApnsBridge.onToken = _registerApnsToken;
|
||||
ApnsBridge.onWake = wake;
|
||||
ApnsBridge.ensureHandler();
|
||||
}
|
||||
}
|
||||
|
||||
void _registerApnsToken(String token) {
|
||||
_messaging.registerPush(_registration(
|
||||
pb.RegisterPush_Platform.PLATFORM_APNS,
|
||||
Uint8List.fromList(utf8.encode(token)),
|
||||
));
|
||||
}
|
||||
|
||||
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
||||
/// asynchronně a sám se pošle relayi. Bez Googlu.
|
||||
Future<void> enableApns() async {
|
||||
await init();
|
||||
await PushNotifier.requestPermission();
|
||||
await ApnsBridge.register();
|
||||
final existing = await ApnsBridge.currentToken();
|
||||
if (existing != null) _registerApnsToken(existing);
|
||||
await PushBootstrap.writeMode('apns');
|
||||
}
|
||||
|
||||
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
|
||||
/// Vrací `false`, když žádný distributor není (uživatel musí nainstalovat
|
||||
/// např. ntfy nebo vybrat přes [distributors]/[useDistributor]).
|
||||
Future<bool> enable() async {
|
||||
await init();
|
||||
// Android 13+: bez POST_NOTIFICATIONS by killed-state budíček zapadl
|
||||
await PushNotifier.requestPermission();
|
||||
final ok = await UnifiedPush.tryUseCurrentOrDefaultDistributor();
|
||||
if (!ok) return false;
|
||||
await UnifiedPush.register();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
|
||||
Future<List<String>> distributors() => UnifiedPush.getDistributors();
|
||||
|
||||
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
|
||||
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
|
||||
|
||||
/// Vybere konkrétního distributora a zaregistruje se u něj.
|
||||
Future<void> useDistributor(String distributor) async {
|
||||
await init();
|
||||
await PushNotifier.requestPermission();
|
||||
await UnifiedPush.saveDistributor(distributor);
|
||||
await UnifiedPush.register();
|
||||
}
|
||||
|
||||
/// Vypne push: odregistruje u distributora a řekne relayi smazat token.
|
||||
Future<void> disable() async {
|
||||
await UnifiedPush.unregister();
|
||||
handleUnregistered();
|
||||
}
|
||||
|
||||
Future<bool> get isEnabled async =>
|
||||
(await UnifiedPush.getDistributor()) != null;
|
||||
|
||||
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
|
||||
|
||||
/// Aktuální režim; 'up' zpětně dovodí z vybraného distributora
|
||||
/// (instalace před zavedením režimů).
|
||||
Future<String> currentMode() async {
|
||||
final mode = await PushBootstrap.readMode();
|
||||
if (mode != 'off') return mode;
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
Future<void> setModeOff() async {
|
||||
await _teardownFcm(deleteToken: true);
|
||||
try {
|
||||
await UnifiedPush.unregister();
|
||||
} catch (_) {}
|
||||
handleUnregistered();
|
||||
await PushBootstrap.writeMode('off');
|
||||
}
|
||||
|
||||
/// UnifiedPush režim (default, degooglovaný). Vrací false, když chybí
|
||||
/// distributor — UI pak nabídne výběr/instalaci.
|
||||
Future<bool> setModeUnifiedPush() async {
|
||||
await _teardownFcm(deleteToken: true);
|
||||
final ok = await enable();
|
||||
if (ok) await PushBootstrap.writeMode('up');
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// FCM fast mode — OPT-IN: registrační token zařízení jde Googlu
|
||||
/// (obsah budíčků nikdy, ťuk je konstantní `{"v":1}`).
|
||||
Future<void> setModeFcm() async {
|
||||
await init();
|
||||
try {
|
||||
await UnifiedPush.unregister();
|
||||
} catch (_) {}
|
||||
await PushNotifier.requestPermission();
|
||||
await Firebase.initializeApp();
|
||||
FirebaseMessaging.onBackgroundMessage(sepotFcmBackgroundWake);
|
||||
|
||||
final fm = FirebaseMessaging.instance;
|
||||
final token = await fm.getToken();
|
||||
if (token == null) {
|
||||
throw StateError('failed to obtain FCM token');
|
||||
}
|
||||
_registerFcmToken(token);
|
||||
_fcmTokenSub ??= fm.onTokenRefresh.listen(_registerFcmToken);
|
||||
// popředí: ťuk → připojit WS (paralela k UnifiedPush onMessage)
|
||||
_fcmMessageSub ??= FirebaseMessaging.onMessage.listen((_) => wake());
|
||||
await PushBootstrap.writeMode('fcm');
|
||||
}
|
||||
|
||||
void _registerFcmToken(String token) {
|
||||
_messaging.registerPush(_registration(
|
||||
pb.RegisterPush_Platform.PLATFORM_FCM,
|
||||
Uint8List.fromList(utf8.encode(token)),
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _teardownFcm({required bool deleteToken}) async {
|
||||
await _fcmTokenSub?.cancel();
|
||||
_fcmTokenSub = null;
|
||||
await _fcmMessageSub?.cancel();
|
||||
_fcmMessageSub = null;
|
||||
if (deleteToken) {
|
||||
try {
|
||||
await FirebaseMessaging.instance.deleteToken();
|
||||
} catch (_) {
|
||||
// Firebase neinicializovaný / bez sítě — token vyprší sám
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Obnova režimu po startu appky (volá pushInitProvider).
|
||||
Future<void> restoreMode() async {
|
||||
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
|
||||
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await enableApns();
|
||||
} catch (_) {
|
||||
await init();
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (await currentMode()) {
|
||||
case 'up':
|
||||
await enable();
|
||||
case 'fcm':
|
||||
try {
|
||||
await setModeFcm();
|
||||
} catch (_) {
|
||||
// bez sítě — token registrace proběhne při příštím startu
|
||||
}
|
||||
default:
|
||||
await init(); // aspoň attach (killed-state notifikace)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/// Nahlášení zneužití — odesílá hlášení mimo relay, na web sepot.cz.
|
||||
///
|
||||
/// Proč ne přes relay: relay vidí jen opaque obálky a nemá čitelný kanál
|
||||
/// k obsahu. Kdyby přijímal hlášení, získal by úložiště čitelných textů
|
||||
/// vázaných na účty — přesně to, čemu se návrh vyhýbá.
|
||||
///
|
||||
/// Obsah zpráv se NIKDY neodesílá — ani automaticky, ani na vyžádání.
|
||||
///
|
||||
/// 🔴 Není to jen ohled na soukromí. Report systém, který provozovateli
|
||||
/// doručí obsah nahlášené zprávy, znamená u dětské pornografie držení
|
||||
/// materiálu = dokonaný § 192 trestního zákoníku, a to bez výjimky pro
|
||||
/// „ověřování". UI proto uživatele výslovně od vkládání obsahu odrazuje
|
||||
/// a u kategorie ohrožení dítěte ho posílá na STOPonline.cz / Policii ČR.
|
||||
/// Hlášení nese jen metadata a slovní popis situace.
|
||||
///
|
||||
/// Splňuje: Google Play UGC policy (mechanismus nahlášení), Child Safety
|
||||
/// Standards / CSAE (in-app nahlášení obav), Apple Guideline 1.2.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
|
||||
/// Kategorie hlášení. `wire` hodnoty jsou součástí API — musí zůstat
|
||||
/// v souladu s webovým endpointem, který hlášení přijímá. Popisky a
|
||||
/// nápovědy kategorií skládá report_screen z lokalizace.
|
||||
enum ReportCategory {
|
||||
childSafety('ohrozeni_ditete'),
|
||||
harassment('obtezovani'),
|
||||
spam('spam'),
|
||||
fraud('podvod'),
|
||||
other('jine');
|
||||
|
||||
const ReportCategory(this.wire);
|
||||
|
||||
final String wire;
|
||||
|
||||
/// CSAE hlášení mají u nás přednost a jiný proces (viz sepot.cz/bezpecnost-deti).
|
||||
bool get isChildSafety => this == ReportCategory.childSafety;
|
||||
}
|
||||
|
||||
class ReportService {
|
||||
const ReportService({this.endpoint = AppConstants.reportEndpoint});
|
||||
|
||||
final String endpoint;
|
||||
|
||||
static const Duration _timeout = Duration(seconds: 20);
|
||||
|
||||
/// Odešle hlášení. Když se nepovede, vyhodí typovanou chybu
|
||||
/// ([ReportEmptyDescriptionError], [ReportConnectionError],
|
||||
/// [ReportRateLimitedError], [ReportServerError]) — UI ji překládá
|
||||
/// přes describeError.
|
||||
///
|
||||
/// [reportedFingerprint] a [replyTo] jsou dobrovolné — bez nich je hlášení
|
||||
/// anonymní (server přesto uvidí IP adresu, na to upozorňuje UI).
|
||||
Future<void> submit({
|
||||
required ReportCategory category,
|
||||
required String description,
|
||||
String? reportedFingerprint,
|
||||
String? replyTo,
|
||||
}) async {
|
||||
final popis = description.trim();
|
||||
if (popis.isEmpty) {
|
||||
throw const ReportEmptyDescriptionError();
|
||||
}
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'kategorie': category.wire,
|
||||
'popis': popis,
|
||||
'zdroj': 'app',
|
||||
'platforma': Platform.operatingSystem,
|
||||
'verze': AppConstants.appVersion,
|
||||
if (reportedFingerprint != null && reportedFingerprint.isNotEmpty)
|
||||
'nahlasenyOtisk': reportedFingerprint,
|
||||
if (replyTo != null && replyTo.trim().isNotEmpty)
|
||||
'kontakt': replyTo.trim(),
|
||||
};
|
||||
|
||||
http.Response res;
|
||||
try {
|
||||
res = await http
|
||||
.post(
|
||||
Uri.parse(endpoint),
|
||||
headers: const {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'X-Sepot-Client': 'app',
|
||||
},
|
||||
body: jsonEncode(body),
|
||||
)
|
||||
.timeout(_timeout);
|
||||
} on Object {
|
||||
throw const ReportConnectionError();
|
||||
}
|
||||
|
||||
if (res.statusCode == 200) return;
|
||||
if (res.statusCode == 429) {
|
||||
throw const ReportRateLimitedError();
|
||||
}
|
||||
throw ReportServerError(res.statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/// Příprava obrázků před odesláním.
|
||||
///
|
||||
/// Každá fotka se před šifrováním RE-ENKÓDUJE (resize na max 2048 px,
|
||||
/// JPEG q80) — tím se zahodí VŠECHNA metadata včetně EXIF/GPS (privacy:
|
||||
/// poloha focení nesmí odjet ani E2E kanálem, protistrana ji nemá co
|
||||
/// vidět). PNG (screenshoty, průhlednost) zůstává PNG, GIF se posílá
|
||||
/// beze změny (animace; EXIF u něj nehrozí).
|
||||
///
|
||||
/// Náhled (JPEG ≤ 48 KiB) jede přímo uvnitř E2E plaintextu zprávy —
|
||||
/// příjemce vidí obrázek okamžitě, bez čekání na download blobu.
|
||||
library;
|
||||
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
class ProcessedImage {
|
||||
const ProcessedImage({
|
||||
required this.bytes,
|
||||
required this.mime,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.thumbnailJpeg,
|
||||
});
|
||||
|
||||
final Uint8List bytes;
|
||||
final String mime;
|
||||
final int width;
|
||||
final int height;
|
||||
final Uint8List thumbnailJpeg;
|
||||
}
|
||||
|
||||
class ImageProcessor {
|
||||
ImageProcessor._();
|
||||
|
||||
static const int maxDimension = 2048;
|
||||
static const int jpegQuality = 80;
|
||||
static const int thumbDimension = 320;
|
||||
|
||||
/// Náhled musí bezpečně projet obálkou (limit 256 KiB na CELOU
|
||||
/// sealed-sender obálku) — držet výrazně pod tím.
|
||||
static const int thumbMaxBytes = 48 * 1024;
|
||||
|
||||
/// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI).
|
||||
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
|
||||
/// pak pošle jako dokument.
|
||||
static Future<ProcessedImage?> process(
|
||||
Uint8List original, {
|
||||
String? mimeHint,
|
||||
}) {
|
||||
return Isolate.run(() {
|
||||
final decoded = img.decodeImage(original);
|
||||
if (decoded == null) return null;
|
||||
|
||||
// GIF beze změny — re-encode by zabil animaci
|
||||
if (mimeHint == 'image/gif') {
|
||||
return ProcessedImage(
|
||||
bytes: original,
|
||||
mime: 'image/gif',
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
thumbnailJpeg: _thumbnail(decoded),
|
||||
);
|
||||
}
|
||||
|
||||
var image = img.bakeOrientation(decoded);
|
||||
if (max(image.width, image.height) > maxDimension) {
|
||||
image = image.width >= image.height
|
||||
? img.copyResize(image, width: maxDimension)
|
||||
: img.copyResize(image, height: maxDimension);
|
||||
}
|
||||
|
||||
// PNG s průhledností zůstává PNG (JPEG alfa kanál neumí, a re-encode
|
||||
// PNG→PNG metadata stejně zahodí); vše ostatní JPEG q80.
|
||||
final keepPng = mimeHint == 'image/png' && decoded.numChannels == 4;
|
||||
final bytes = keepPng
|
||||
? Uint8List.fromList(img.encodePng(image))
|
||||
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
||||
|
||||
return ProcessedImage(
|
||||
bytes: bytes,
|
||||
mime: keepPng ? 'image/png' : 'image/jpeg',
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
thumbnailJpeg: _thumbnail(image),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static Uint8List _thumbnail(img.Image source) {
|
||||
var thumb = max(source.width, source.height) > thumbDimension
|
||||
? (source.width >= source.height
|
||||
? img.copyResize(source, width: thumbDimension)
|
||||
: img.copyResize(source, height: thumbDimension))
|
||||
: source;
|
||||
// JPEG náhled nemá alfu — průhlednost složit na tmavé pozadí
|
||||
if (thumb.numChannels == 4) {
|
||||
final flat = img.Image(width: thumb.width, height: thumb.height);
|
||||
img.fill(flat, color: img.ColorRgb8(20, 20, 31));
|
||||
img.compositeImage(flat, thumb);
|
||||
thumb = flat;
|
||||
}
|
||||
var quality = 70;
|
||||
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
||||
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
|
||||
// rozumně (min 40), radši menší rozměr než rozsypaný obraz.
|
||||
while (encoded.length > thumbMaxBytes && quality > 40) {
|
||||
quality -= 10;
|
||||
encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
/// Lokální úložiště blobů příloh.
|
||||
///
|
||||
/// Na disku leží VŽDY jen šifrovaný blob (ciphertext || tag) pod názvem
|
||||
/// blob_id — stejné bajty jako na relayi. At-rest ochrana = AES-256-GCM
|
||||
/// per-file klíčem, který žije výhradně v SQLCipher DB (metadata zprávy).
|
||||
/// Dešifruje se až při zobrazení, do paměti; plaintext se na disk dostane
|
||||
/// jen krátkodobě při sdílení do jiné aplikace (viz UI vrstva).
|
||||
///
|
||||
/// blob_id přichází i od protistrany (remote input) — před použitím jako
|
||||
/// název souboru se validuje tvar (32 hex znaků), žádný path traversal.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class AttachmentStore {
|
||||
AttachmentStore({String? dirOverride}) : _dirOverride = dirOverride;
|
||||
|
||||
final String? _dirOverride;
|
||||
|
||||
static final _blobIdPattern = RegExp(r'^[0-9a-f]{32}$');
|
||||
|
||||
static bool isValidBlobId(String blobId) => _blobIdPattern.hasMatch(blobId);
|
||||
|
||||
Future<Directory> _dir() async {
|
||||
final base = _dirOverride ??
|
||||
'${(await getApplicationSupportDirectory()).path}/attachments';
|
||||
return Directory(base).create(recursive: true);
|
||||
}
|
||||
|
||||
Future<File> _file(String blobId) async {
|
||||
if (!isValidBlobId(blobId)) {
|
||||
throw ArgumentError.value(blobId, 'blobId', 'invalid blob_id format');
|
||||
}
|
||||
return File('${(await _dir()).path}/$blobId');
|
||||
}
|
||||
|
||||
Future<void> save(String blobId, Uint8List blob) async {
|
||||
final file = await _file(blobId);
|
||||
// přes temp + rename, ať pád zápisu nenechá poloviční blob
|
||||
final tmp = File('${file.path}.part');
|
||||
await tmp.writeAsBytes(blob, flush: true);
|
||||
await tmp.rename(file.path);
|
||||
}
|
||||
|
||||
Future<bool> exists(String blobId) async => (await _file(blobId)).exists();
|
||||
|
||||
/// Vrátí šifrovaný blob, nebo null, když (ještě) není stažený.
|
||||
Future<Uint8List?> load(String blobId) async {
|
||||
final file = await _file(blobId);
|
||||
if (!await file.exists()) return null;
|
||||
return file.readAsBytes();
|
||||
}
|
||||
|
||||
Future<void> delete(String blobId) async {
|
||||
final file = await _file(blobId);
|
||||
if (await file.exists()) await file.delete();
|
||||
}
|
||||
|
||||
/// Factory reset — smaže celý adresář příloh.
|
||||
Future<void> deleteAll() async {
|
||||
final dir = await _dir();
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/// Šifrovaná lokální DB (SQLCipher) — krypto stavy i aplikační data.
|
||||
///
|
||||
/// Drží ratchet sessiony, prekeys, identity protistran a dále i kontakty,
|
||||
/// konverzace se zprávami a nastavení (dřív plain Hive — nepřijatelné pro
|
||||
/// privacy app). VLASTNÍ identity privátní klíč tu NENÍ — žije v OS keystore
|
||||
/// (flutter_secure_storage), stejně jako DEK téhle databáze (obrana do
|
||||
/// hloubky).
|
||||
///
|
||||
/// Nativní knihovnu (libsqlcipher) dodává build hook balíčku `sqlite3`
|
||||
/// (pubspec: `hooks.user_defines.sqlite3.source: sqlcipher`) — jednotně
|
||||
/// pro Android, iOS, desktop i testy na Linuxu.
|
||||
///
|
||||
/// Zápisy ratchet stavů jsou atomické (SQLite transakce + WAL) — neatomický
|
||||
/// zápis session recordu = „bad MAC" a rozbitá konverzace.
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
|
||||
class CryptoDb {
|
||||
CryptoDb._(this._db);
|
||||
|
||||
final Database _db;
|
||||
|
||||
/// Otevře (a případně založí) šifrovanou DB.
|
||||
///
|
||||
/// [dek] je 32B klíč z OS keystore. Špatný klíč = [SqliteException]
|
||||
/// hned při první query (SQLCipher nerozliší „špatný klíč" od
|
||||
/// „poškozený soubor").
|
||||
static CryptoDb open({required String path, required Uint8List dek}) {
|
||||
if (dek.length != 32) {
|
||||
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
||||
}
|
||||
final db = sqlite3.open(path);
|
||||
try {
|
||||
// Pojistka: kdyby build přilinkl plain sqlite3 místo SQLCipher,
|
||||
// DB by se TIŠE založila nešifrovaná. To se nikdy nesmí stát.
|
||||
final cipherVersion = db.select('PRAGMA cipher_version;');
|
||||
if (cipherVersion.isEmpty) {
|
||||
throw StateError(
|
||||
'Native library is not SQLCipher — at-rest encryption would not run. '
|
||||
'Check hooks.user_defines.sqlite3.source in pubspec.yaml.',
|
||||
);
|
||||
}
|
||||
final hexKey =
|
||||
dek.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
// PRAGMA key s raw klíčem (x'..' formát) — žádné KDF ze stringu
|
||||
db.execute("PRAGMA key = \"x'$hexKey'\";");
|
||||
db.execute('PRAGMA journal_mode = WAL;');
|
||||
db.execute('PRAGMA foreign_keys = ON;');
|
||||
// První dotaz ověří klíč — na špatném klíči vyhodí NOTADB
|
||||
db.execute(_schema);
|
||||
return CryptoDb._(db);
|
||||
} catch (_) {
|
||||
db.close();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static const _schema = '''
|
||||
CREATE TABLE IF NOT EXISTS kv (
|
||||
k TEXT PRIMARY KEY,
|
||||
v BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
name TEXT NOT NULL,
|
||||
device_id INTEGER NOT NULL,
|
||||
record BLOB NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (name, device_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS remote_identities (
|
||||
name TEXT PRIMARY KEY,
|
||||
identity BLOB NOT NULL,
|
||||
first_seen INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS prekeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
record BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS signed_prekeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
record BLOB NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS kyber_prekeys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
record BLOB NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
contact_id TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
k TEXT PRIMARY KEY,
|
||||
v TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL
|
||||
);
|
||||
''';
|
||||
|
||||
Database get raw => _db;
|
||||
|
||||
/// Atomický blok — ratchet stavy se smí zapisovat jen takhle.
|
||||
T transaction<T>(T Function() body) {
|
||||
_db.execute('BEGIN IMMEDIATE;');
|
||||
try {
|
||||
final result = body();
|
||||
_db.execute('COMMIT;');
|
||||
return result;
|
||||
} catch (_) {
|
||||
_db.execute('ROLLBACK;');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List? kvGet(String key) {
|
||||
final rows = _db.select('SELECT v FROM kv WHERE k = ?', [key]);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['v'] as Uint8List;
|
||||
}
|
||||
|
||||
void kvPut(String key, Uint8List value) {
|
||||
_db.execute(
|
||||
'INSERT INTO kv (k, v) VALUES (?, ?) '
|
||||
'ON CONFLICT(k) DO UPDATE SET v = excluded.v',
|
||||
[key, value],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Aplikační data — modely jako JSON, (de)serializuje volající ──
|
||||
|
||||
List<String> contactsJsonAll() {
|
||||
final rows = _db.select('SELECT json FROM contacts');
|
||||
return [for (final r in rows) r['json'] as String];
|
||||
}
|
||||
|
||||
void contactPut(String id, String json) {
|
||||
_db.execute(
|
||||
'INSERT INTO contacts (id, json) VALUES (?, ?) '
|
||||
'ON CONFLICT(id) DO UPDATE SET json = excluded.json',
|
||||
[id, json],
|
||||
);
|
||||
}
|
||||
|
||||
/// Smaže kontakt VČETNĚ jeho konverzace (atomicky).
|
||||
void contactDelete(String id) {
|
||||
transaction(() {
|
||||
_db.execute('DELETE FROM contacts WHERE id = ?', [id]);
|
||||
_db.execute('DELETE FROM conversations WHERE contact_id = ?', [id]);
|
||||
});
|
||||
}
|
||||
|
||||
String? conversationJson(String contactId) {
|
||||
final rows = _db.select(
|
||||
'SELECT json FROM conversations WHERE contact_id = ?',
|
||||
[contactId],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['json'] as String;
|
||||
}
|
||||
|
||||
List<String> conversationsJsonAll() {
|
||||
final rows = _db.select('SELECT json FROM conversations');
|
||||
return [for (final r in rows) r['json'] as String];
|
||||
}
|
||||
|
||||
void conversationPut(String contactId, String json) {
|
||||
_db.execute(
|
||||
'INSERT INTO conversations (contact_id, json) VALUES (?, ?) '
|
||||
'ON CONFLICT(contact_id) DO UPDATE SET json = excluded.json',
|
||||
[contactId, json],
|
||||
);
|
||||
}
|
||||
|
||||
List<String> groupsJsonAll() {
|
||||
final rows = _db.select('SELECT json FROM groups');
|
||||
return [for (final r in rows) r['json'] as String];
|
||||
}
|
||||
|
||||
String? groupJson(String id) {
|
||||
final rows = _db.select('SELECT json FROM groups WHERE id = ?', [id]);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['json'] as String;
|
||||
}
|
||||
|
||||
void groupPut(String id, String json) {
|
||||
_db.execute(
|
||||
'INSERT INTO groups (id, json) VALUES (?, ?) '
|
||||
'ON CONFLICT(id) DO UPDATE SET json = excluded.json',
|
||||
[id, json],
|
||||
);
|
||||
}
|
||||
|
||||
/// Smaže skupinu VČETNĚ její konverzace (atomicky).
|
||||
void groupDelete(String id) {
|
||||
transaction(() {
|
||||
_db.execute('DELETE FROM groups WHERE id = ?', [id]);
|
||||
_db.execute('DELETE FROM conversations WHERE contact_id = ?', [id]);
|
||||
});
|
||||
}
|
||||
|
||||
String? appSettingGet(String key) {
|
||||
final rows = _db.select('SELECT v FROM app_settings WHERE k = ?', [key]);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['v'] as String;
|
||||
}
|
||||
|
||||
void appSettingPut(String key, String value) {
|
||||
_db.execute(
|
||||
'INSERT INTO app_settings (k, v) VALUES (?, ?) '
|
||||
'ON CONFLICT(k) DO UPDATE SET v = excluded.v',
|
||||
[key, value],
|
||||
);
|
||||
}
|
||||
|
||||
void close() => _db.close();
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/// Perzistentní libsignal stores nad šifrovanou DB (SQLCipher).
|
||||
///
|
||||
/// Ratchet stavy, prekeys a identity protistran přežívají restart aplikace.
|
||||
/// Vlastní identity keypair sem NEPATŘÍ — dostává se zvenčí (OS keystore),
|
||||
/// store drží jen odkaz v paměti.
|
||||
///
|
||||
/// Trust model identity protistran: TOFU per účet (name), stejná sémantika
|
||||
/// jako InMemoryIdentityKeyStore z balíčku, ale porovnává se SERIALIZOVANÁ
|
||||
/// podoba klíče (opaque FFI handle nemá spolehlivé ==).
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:libsignal/libsignal.dart';
|
||||
import 'package:sepot/core/storage/crypto_db.dart';
|
||||
|
||||
bool _bytesEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class DbSessionStore implements SessionStore {
|
||||
DbSessionStore(this._db);
|
||||
|
||||
final CryptoDb _db;
|
||||
|
||||
@override
|
||||
Future<SessionRecord?> loadSession(ProtocolAddress address) async {
|
||||
final rows = _db.raw.select(
|
||||
'SELECT record FROM sessions WHERE name = ? AND device_id = ?',
|
||||
[address.name(), address.deviceId()],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return SessionRecord.deserialize(
|
||||
bytes: (rows.first['record'] as Uint8List).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> storeSession(
|
||||
ProtocolAddress address,
|
||||
SessionRecord record,
|
||||
) async {
|
||||
// Atomicky — částečně zapsaný ratchet stav = „bad MAC"
|
||||
_db.transaction(() {
|
||||
_db.raw.execute(
|
||||
'INSERT INTO sessions (name, device_id, record, updated_at) '
|
||||
'VALUES (?, ?, ?, ?) '
|
||||
'ON CONFLICT(name, device_id) DO UPDATE '
|
||||
'SET record = excluded.record, updated_at = excluded.updated_at',
|
||||
[
|
||||
address.name(),
|
||||
address.deviceId(),
|
||||
record.serialize(),
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> containsSession(ProtocolAddress address) async {
|
||||
final rows = _db.raw.select(
|
||||
'SELECT 1 FROM sessions WHERE name = ? AND device_id = ?',
|
||||
[address.name(), address.deviceId()],
|
||||
);
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSession(ProtocolAddress address) async {
|
||||
_db.raw.execute(
|
||||
'DELETE FROM sessions WHERE name = ? AND device_id = ?',
|
||||
[address.name(), address.deviceId()],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteAllSessions(String name) async {
|
||||
_db.raw.execute('DELETE FROM sessions WHERE name = ?', [name]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> getSubDeviceSessions(String name) async {
|
||||
final rows = _db.raw.select(
|
||||
'SELECT device_id FROM sessions WHERE name = ?',
|
||||
[name],
|
||||
);
|
||||
return [for (final r in rows) r['device_id'] as int];
|
||||
}
|
||||
}
|
||||
|
||||
class DbIdentityKeyStore implements IdentityKeyStore {
|
||||
DbIdentityKeyStore(
|
||||
this._db, {
|
||||
required IdentityKeyPair identityKeyPair,
|
||||
required int registrationId,
|
||||
}) : _identityKeyPair = identityKeyPair,
|
||||
_registrationId = registrationId;
|
||||
|
||||
final CryptoDb _db;
|
||||
final IdentityKeyPair _identityKeyPair;
|
||||
final int _registrationId;
|
||||
|
||||
@override
|
||||
Future<IdentityKeyPair> getIdentityKeyPair() async => _identityKeyPair;
|
||||
|
||||
@override
|
||||
Future<int> getLocalRegistrationId() async => _registrationId;
|
||||
|
||||
Uint8List? _stored(String name) {
|
||||
final rows = _db.raw.select(
|
||||
'SELECT identity FROM remote_identities WHERE name = ?',
|
||||
[name],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['identity'] as Uint8List;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> saveIdentity(
|
||||
ProtocolAddress address,
|
||||
PublicKey identityKey,
|
||||
) async {
|
||||
final name = address.name();
|
||||
final incoming = identityKey.serialize();
|
||||
final existing = _stored(name);
|
||||
if (existing != null && _bytesEqual(existing, incoming)) {
|
||||
return false; // beze změny
|
||||
}
|
||||
_db.raw.execute(
|
||||
'INSERT INTO remote_identities (name, identity, first_seen) '
|
||||
'VALUES (?, ?, ?) '
|
||||
'ON CONFLICT(name) DO UPDATE SET identity = excluded.identity',
|
||||
[name, incoming, DateTime.now().millisecondsSinceEpoch],
|
||||
);
|
||||
return true; // nová nebo ZMĚNĚNÁ identita (UI ukáže safety-number varování)
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PublicKey?> getIdentity(ProtocolAddress address) async {
|
||||
final stored = _stored(address.name());
|
||||
if (stored == null) return null;
|
||||
return PublicKey.deserialize(bytes: stored.toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isTrustedIdentity(
|
||||
ProtocolAddress address,
|
||||
PublicKey identityKey,
|
||||
Direction direction,
|
||||
) async {
|
||||
final stored = _stored(address.name());
|
||||
if (stored == null) return true; // TOFU
|
||||
return _bytesEqual(stored, identityKey.serialize());
|
||||
}
|
||||
}
|
||||
|
||||
class DbPreKeyStore implements PreKeyStore {
|
||||
DbPreKeyStore(this._db);
|
||||
|
||||
final CryptoDb _db;
|
||||
|
||||
@override
|
||||
Future<PreKeyRecord?> loadPreKey(int preKeyId) async {
|
||||
final rows = _db.raw.select(
|
||||
'SELECT record FROM prekeys WHERE id = ?',
|
||||
[preKeyId],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return PreKeyRecord.deserialize(
|
||||
bytes: (rows.first['record'] as Uint8List).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> storePreKey(int preKeyId, PreKeyRecord record) async {
|
||||
_db.raw.execute(
|
||||
'INSERT INTO prekeys (id, record) VALUES (?, ?) '
|
||||
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
|
||||
[preKeyId, record.serialize()],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> containsPreKey(int preKeyId) async {
|
||||
return _db.raw
|
||||
.select('SELECT 1 FROM prekeys WHERE id = ?', [preKeyId]).isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removePreKey(int preKeyId) async {
|
||||
// One-time prekey se po použití MAŽE (forward secrecy první zprávy)
|
||||
_db.raw.execute('DELETE FROM prekeys WHERE id = ?', [preKeyId]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> getAllPreKeyIds() async {
|
||||
final rows = _db.raw.select('SELECT id FROM prekeys ORDER BY id');
|
||||
return [for (final r in rows) r['id'] as int];
|
||||
}
|
||||
}
|
||||
|
||||
class DbSignedPreKeyStore implements SignedPreKeyStore {
|
||||
DbSignedPreKeyStore(this._db);
|
||||
|
||||
final CryptoDb _db;
|
||||
|
||||
@override
|
||||
Future<SignedPreKeyRecord?> loadSignedPreKey(int signedPreKeyId) async {
|
||||
final rows = _db.raw.select(
|
||||
'SELECT record FROM signed_prekeys WHERE id = ?',
|
||||
[signedPreKeyId],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return SignedPreKeyRecord.deserialize(
|
||||
bytes: (rows.first['record'] as Uint8List).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> storeSignedPreKey(
|
||||
int signedPreKeyId,
|
||||
SignedPreKeyRecord record,
|
||||
) async {
|
||||
_db.raw.execute(
|
||||
'INSERT INTO signed_prekeys (id, record) VALUES (?, ?) '
|
||||
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
|
||||
[signedPreKeyId, record.serialize()],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> containsSignedPreKey(int signedPreKeyId) async {
|
||||
return _db.raw.select(
|
||||
'SELECT 1 FROM signed_prekeys WHERE id = ?',
|
||||
[signedPreKeyId],
|
||||
).isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeSignedPreKey(int signedPreKeyId) async {
|
||||
_db.raw.execute(
|
||||
'DELETE FROM signed_prekeys WHERE id = ?',
|
||||
[signedPreKeyId],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> getAllSignedPreKeyIds() async {
|
||||
final rows = _db.raw.select('SELECT id FROM signed_prekeys ORDER BY id');
|
||||
return [for (final r in rows) r['id'] as int];
|
||||
}
|
||||
}
|
||||
|
||||
class DbKyberPreKeyStore implements KyberPreKeyStore {
|
||||
DbKyberPreKeyStore(this._db);
|
||||
|
||||
final CryptoDb _db;
|
||||
|
||||
@override
|
||||
Future<KyberPreKeyRecord?> loadKyberPreKey(int kyberPreKeyId) async {
|
||||
final rows = _db.raw.select(
|
||||
'SELECT record FROM kyber_prekeys WHERE id = ?',
|
||||
[kyberPreKeyId],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return KyberPreKeyRecord.deserialize(
|
||||
bytes: (rows.first['record'] as Uint8List).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> storeKyberPreKey(
|
||||
int kyberPreKeyId,
|
||||
KyberPreKeyRecord record,
|
||||
) async {
|
||||
_db.raw.execute(
|
||||
'INSERT INTO kyber_prekeys (id, record) VALUES (?, ?) '
|
||||
'ON CONFLICT(id) DO UPDATE SET record = excluded.record',
|
||||
[kyberPreKeyId, record.serialize()],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> containsKyberPreKey(int kyberPreKeyId) async {
|
||||
return _db.raw.select(
|
||||
'SELECT 1 FROM kyber_prekeys WHERE id = ?',
|
||||
[kyberPreKeyId],
|
||||
).isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markKyberPreKeyUsed(int kyberPreKeyId) async {
|
||||
// Last-resort Kyber prekey se NEMAŽE (nahrazuje chybějící OTP,
|
||||
// ochrana proti replay útoku) — jen se značí použití
|
||||
_db.raw.execute(
|
||||
'UPDATE kyber_prekeys SET used = 1 WHERE id = ?',
|
||||
[kyberPreKeyId],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeKyberPreKey(int kyberPreKeyId) async {
|
||||
_db.raw.execute('DELETE FROM kyber_prekeys WHERE id = ?', [kyberPreKeyId]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> getAllKyberPreKeyIds() async {
|
||||
final rows = _db.raw.select('SELECT id FROM kyber_prekeys ORDER BY id');
|
||||
return [for (final r in rows) r['id'] as int];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||
import 'package:sepot/core/storage/attachment_store.dart';
|
||||
import 'package:sepot/core/storage/crypto_db.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Lokální úložiště — OS keystore (klíče) + SQLCipher DB (všechna data).
|
||||
///
|
||||
/// Klíčový model:
|
||||
/// - DEK (32 B, náhodný) šifruje jedinou SQLCipher DB, kde žije VŠECHNO:
|
||||
/// krypto stavy, kontakty, konverzace se zprávami i nastavení.
|
||||
/// - Bez PINu: DEK leží raw v OS keystore (baseline ochrana OS).
|
||||
/// - S PINem: DEK je zabalený Argon2id(PIN) + AES-256-GCM ([DekWrap]);
|
||||
/// raw kopie se z keystore MAŽE. Ověření PINu = úspěšný unwrap,
|
||||
/// žádný hash na porovnání neexistuje.
|
||||
///
|
||||
/// Lifecycle: appka startuje ZAMČENÁ ([isUnlocked] == false). Data jsou
|
||||
/// dostupná až po [unlockWithPin] / [unlockWithoutPin] (nebo [enablePin]
|
||||
/// při onboardingu, který DB rovnou otevře).
|
||||
|
||||
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
|
||||
class DecoyPinConflict implements Exception {
|
||||
const DecoyPinConflict();
|
||||
@override
|
||||
String toString() => 'decoy PIN must differ from the regular PIN';
|
||||
}
|
||||
|
||||
class SecureStorageService {
|
||||
static const _identityKey = 'local_identity';
|
||||
static const _seedPhraseKey = 'seed_phrase';
|
||||
static const _biometricsEnabledKey = 'biometrics_enabled';
|
||||
static const _onboardingCompleteKey = 'onboarding_complete';
|
||||
static const _rawDekKey = 'crypto_db_dek';
|
||||
static const _wrappedDekKey = 'wrapped_dek';
|
||||
// Biometrické odemčení: kopie DEK v OS keystore (TEE/StrongBox).
|
||||
// VĚDOMÝ tradeoff — s PINem jinak DEK existuje JEN za Argon2id(PIN);
|
||||
// biometrie ho vrací do keystore výměnou za pohodlí. Opt-in.
|
||||
static const _biometricDekKey = 'biometric_dek';
|
||||
// Legacy — PBKDF2 hash PINu, jen se uklízí
|
||||
static const _legacyPinHashKey = 'pin_hash';
|
||||
|
||||
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
|
||||
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final String? _dbPathOverride;
|
||||
|
||||
CryptoDb? _db;
|
||||
Uint8List? _dek;
|
||||
bool _decoyMode = false;
|
||||
|
||||
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
|
||||
bool get isDecoyMode => _decoyMode;
|
||||
|
||||
SecureStorageService({String? dbPath})
|
||||
: _secureStorage = const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
),
|
||||
_dbPathOverride = dbPath;
|
||||
|
||||
// === Zámek a DB lifecycle ===
|
||||
|
||||
bool get isUnlocked => _db != null;
|
||||
|
||||
/// Otevřená šifrovaná DB — sdílí ji storage i messaging vrstva.
|
||||
CryptoDb get db =>
|
||||
_db ?? (throw StateError('Storage is locked — unlock first'));
|
||||
|
||||
/// Odemčení bez PINu — DEK leží raw v OS keystore.
|
||||
Future<void> unlockWithoutPin() async {
|
||||
if (isUnlocked) return;
|
||||
if (await hasPin()) {
|
||||
throw StateError('PIN is set — use unlockWithPin');
|
||||
}
|
||||
final dek = await _getOrCreateRawDek();
|
||||
await _openDb(dek);
|
||||
}
|
||||
|
||||
/// Odemčení PINem — Argon2id unwrap DEK. Špatný PIN = [WrongPinException].
|
||||
///
|
||||
/// Nejdřív zkusí skutečný PIN. Když nesedí a je nastavený nátlakový
|
||||
/// (decoy) PIN, zkusí ten → otevře PRÁZDNOU decoy DB (plausible
|
||||
/// deniability: útočník vidí nastavený, ale „nepoužívaný" messenger).
|
||||
Future<void> unlockWithPin(String pin) async {
|
||||
if (isUnlocked) return;
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) {
|
||||
throw StateError('No PIN set — use unlockWithoutPin');
|
||||
}
|
||||
try {
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _openDb(dek);
|
||||
} on WrongPinException {
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
if (decoy == null) rethrow;
|
||||
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
|
||||
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
|
||||
await _openDb(dek, decoy: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
|
||||
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
||||
Future<void> enablePin(String pin) async {
|
||||
final dek = _dek ?? await _getOrCreateRawDek();
|
||||
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
||||
await _secureStorage.delete(key: _rawDekKey);
|
||||
if (!isUnlocked) {
|
||||
await _openDb(dek);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
||||
Future<void> disablePin() async {
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||
await _secureStorage.delete(key: _wrappedDekKey);
|
||||
}
|
||||
|
||||
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
||||
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit.
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) return false;
|
||||
try {
|
||||
await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
return true;
|
||||
} on WrongPinException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Změna PINu — starý PIN se ověří unwrapem (špatný = [WrongPinException]).
|
||||
///
|
||||
/// Nový PIN se nesmí trefit do nátlakového: jinak by běžné odemčení
|
||||
/// otevřelo decoy DB (unlockWithPin zkouší real→decoy) a uživatel by
|
||||
/// přišel o přístup ke svým datům.
|
||||
Future<void> changePin({
|
||||
required String oldPin,
|
||||
required String newPin,
|
||||
}) async {
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) throw StateError('No PIN set');
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
||||
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
if (decoy != null) {
|
||||
try {
|
||||
await DekWrap.unwrap(wrapped: decoy, pin: newPin);
|
||||
throw const DecoyPinConflict();
|
||||
} on WrongPinException {
|
||||
// OK — nový PIN se liší od nátlakového
|
||||
}
|
||||
}
|
||||
|
||||
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
|
||||
}
|
||||
|
||||
Future<bool> hasPin() async {
|
||||
return await _secureStorage.read(key: _wrappedDekKey) != null;
|
||||
}
|
||||
|
||||
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
|
||||
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
|
||||
Future<void> enableDecoyPin(String decoyPin) async {
|
||||
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (realWrapped == null) {
|
||||
throw StateError('Set a regular PIN first');
|
||||
}
|
||||
// Decoy PIN se musí lišit od skutečného (jinak by se decoy nikdy nespustil)
|
||||
try {
|
||||
await DekWrap.unwrap(wrapped: realWrapped, pin: decoyPin);
|
||||
throw const DecoyPinConflict();
|
||||
} on WrongPinException {
|
||||
// OK — decoy se liší od skutečného PINu
|
||||
}
|
||||
final rng = Random.secure();
|
||||
final decoyDek =
|
||||
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
|
||||
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
|
||||
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
|
||||
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
|
||||
}
|
||||
|
||||
Future<bool> hasDecoyPin() async {
|
||||
return await _secureStorage.read(key: _decoyWrappedDekKey) != null;
|
||||
}
|
||||
|
||||
/// Zruší nátlakový PIN a smaže decoy DB.
|
||||
Future<void> disableDecoyPin() async {
|
||||
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
||||
try {
|
||||
final f = File(await _dbPath(decoy: true));
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
||||
/// (a nechceme) ověřit, uživatel si PIN nastaví znovu. Číst PŘED
|
||||
/// unlockem ([_cleanupLegacy] marker maže).
|
||||
Future<bool> hasLegacyPin() async {
|
||||
return await _secureStorage.read(key: _legacyPinHashKey) != null;
|
||||
}
|
||||
|
||||
/// Zamkne úložiště — zavře DB a zahodí DEK z paměti.
|
||||
void lock() {
|
||||
_db?.close();
|
||||
_db = null;
|
||||
_dek = null;
|
||||
}
|
||||
|
||||
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
||||
final path = await _dbPath(decoy: decoy);
|
||||
_db = CryptoDb.open(path: path, dek: dek);
|
||||
_dek = dek;
|
||||
_decoyMode = decoy;
|
||||
if (!decoy) await _cleanupLegacy();
|
||||
}
|
||||
|
||||
Future<String> _dbPath({bool decoy = false}) async {
|
||||
if (_dbPathOverride != null) {
|
||||
return decoy ? '$_dbPathOverride.decoy' : _dbPathOverride;
|
||||
}
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return '${dir.path}/${decoy ? 'sepot_crypto_decoy.db' : 'sepot_crypto.db'}';
|
||||
}
|
||||
|
||||
Future<Uint8List> _getOrCreateRawDek() async {
|
||||
final existing = await _secureStorage.read(key: _rawDekKey);
|
||||
if (existing != null) {
|
||||
return base64Decode(existing);
|
||||
}
|
||||
final rng = Random.secure();
|
||||
final dek = Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
|
||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||
return dek;
|
||||
}
|
||||
|
||||
/// Úklid po starších instalacích: PBKDF2 pin_hash z keystore a hlavně
|
||||
/// PLAINTEXT Hive soubory z disku (obsahovaly zprávy nešifrovaně).
|
||||
/// Bez datové migrace — žádná produkční data neexistují.
|
||||
Future<void> _cleanupLegacy() async {
|
||||
try {
|
||||
await _secureStorage.delete(key: _legacyPinHashKey);
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
for (final box in [
|
||||
AppConstants.identityBox,
|
||||
AppConstants.contactsBox,
|
||||
AppConstants.conversationsBox,
|
||||
AppConstants.messagesBox,
|
||||
AppConstants.settingsBox,
|
||||
]) {
|
||||
for (final suffix in ['hive', 'lock']) {
|
||||
final f = File('${docs.path}/$box.$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// best-effort — nesmí shodit unlock
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: podstrč otevřenou DB bez OS keystore (Linux VM testy).
|
||||
@visibleForTesting
|
||||
void debugAttachDb(CryptoDb db) {
|
||||
_db = db;
|
||||
}
|
||||
|
||||
// === Identita ===
|
||||
|
||||
/// Ulož lokální identitu (šifrovaně v Keychain/Keystore)
|
||||
Future<void> saveIdentity(LocalIdentity identity) async {
|
||||
await _secureStorage.write(
|
||||
key: _identityKey,
|
||||
value: jsonEncode(identity.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Načti lokální identitu
|
||||
Future<LocalIdentity?> loadIdentity() async {
|
||||
final json = await _secureStorage.read(key: _identityKey);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
return LocalIdentity.fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||
} on FormatException {
|
||||
// Identita ve starém (před-libsignal) formátu — bez migrace,
|
||||
// uživatel projde onboardingem znovu (žádná produkční data neexistují)
|
||||
await _secureStorage.delete(key: _identityKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Existuje identita?
|
||||
Future<bool> hasIdentity() async {
|
||||
return await _secureStorage.read(key: _identityKey) != null;
|
||||
}
|
||||
|
||||
// === Seed Phrase ===
|
||||
|
||||
Future<void> saveSeedPhrase(List<String> words) async {
|
||||
await _secureStorage.write(
|
||||
key: _seedPhraseKey,
|
||||
value: jsonEncode(words),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>?> loadSeedPhrase() async {
|
||||
final json = await _secureStorage.read(key: _seedPhraseKey);
|
||||
if (json == null) return null;
|
||||
return (jsonDecode(json) as List).cast<String>();
|
||||
}
|
||||
|
||||
// === Biometrie ===
|
||||
|
||||
/// Zapne biometrické odemčení — uloží kopii DEK do OS keystore.
|
||||
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
|
||||
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
||||
Future<void> enableBiometrics() async {
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(
|
||||
key: _biometricDekKey,
|
||||
value: base64Encode(dek),
|
||||
);
|
||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'true');
|
||||
}
|
||||
|
||||
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
||||
/// PIN-only model: DEK existuje jen za Argon2id).
|
||||
Future<void> disableBiometrics() async {
|
||||
await _secureStorage.delete(key: _biometricDekKey);
|
||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
||||
}
|
||||
|
||||
/// Odemčení po úspěšné biometrické výzvě (local_auth v UI).
|
||||
Future<void> unlockWithBiometrics() async {
|
||||
if (isUnlocked) return;
|
||||
final stored = await _secureStorage.read(key: _biometricDekKey);
|
||||
if (stored == null) {
|
||||
throw StateError('Biometric unlock is not set up');
|
||||
}
|
||||
await _openDb(base64Decode(stored));
|
||||
}
|
||||
|
||||
Future<bool> isBiometricsEnabled() async {
|
||||
final value = await _secureStorage.read(key: _biometricsEnabledKey);
|
||||
return value == 'true' &&
|
||||
await _secureStorage.read(key: _biometricDekKey) != null;
|
||||
}
|
||||
|
||||
// === Onboarding ===
|
||||
|
||||
Future<void> setOnboardingComplete(bool complete) async {
|
||||
await _secureStorage.write(
|
||||
key: _onboardingCompleteKey,
|
||||
value: complete.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> isOnboardingComplete() async {
|
||||
final value = await _secureStorage.read(key: _onboardingCompleteKey);
|
||||
return value == 'true';
|
||||
}
|
||||
|
||||
// === Kontakty (SQLCipher) ===
|
||||
|
||||
Future<List<Contact>> loadContacts() async {
|
||||
return [
|
||||
for (final json in db.contactsJsonAll())
|
||||
Contact.fromJson(jsonDecode(json) as Map<String, dynamic>),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> saveContact(Contact contact) async {
|
||||
db.contactPut(contact.id, jsonEncode(contact.toJson()));
|
||||
}
|
||||
|
||||
Future<void> deleteContact(String contactId) async {
|
||||
db.contactDelete(contactId);
|
||||
}
|
||||
|
||||
// === Skupiny (SQLCipher) ===
|
||||
|
||||
Future<List<Group>> loadGroups() async {
|
||||
return [
|
||||
for (final json in db.groupsJsonAll())
|
||||
Group.fromJson(jsonDecode(json) as Map<String, dynamic>),
|
||||
];
|
||||
}
|
||||
|
||||
Future<Group?> loadGroup(String id) async {
|
||||
final json = db.groupJson(id);
|
||||
if (json == null) return null;
|
||||
return Group.fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> saveGroup(Group group) async {
|
||||
db.groupPut(group.id, jsonEncode(group.toJson()));
|
||||
}
|
||||
|
||||
/// Smaže skupinu včetně konverzace (historie pryč — používat po
|
||||
/// opuštění, když už uživatel historii nechce).
|
||||
Future<void> deleteGroup(String id) async {
|
||||
db.groupDelete(id);
|
||||
}
|
||||
|
||||
// === Konverzace (SQLCipher) ===
|
||||
|
||||
Future<List<Conversation>> loadConversations() async {
|
||||
final conversations = [
|
||||
for (final json in db.conversationsJsonAll())
|
||||
Conversation.fromJson(jsonDecode(json) as Map<String, dynamic>),
|
||||
];
|
||||
conversations.sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
|
||||
return conversations;
|
||||
}
|
||||
|
||||
Future<Conversation?> loadConversation(String contactId) async {
|
||||
final json = db.conversationJson(contactId);
|
||||
if (json == null) return null;
|
||||
return Conversation.fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> saveConversation(Conversation conversation) async {
|
||||
db.conversationPut(
|
||||
conversation.contactId,
|
||||
jsonEncode(conversation.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addMessage(String contactId, Message message) async {
|
||||
var conv = await loadConversation(contactId);
|
||||
conv ??= Conversation(contactId: contactId);
|
||||
conv.addMessage(message);
|
||||
await saveConversation(conv);
|
||||
}
|
||||
|
||||
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
|
||||
/// v chat listu vidět i před první zprávou).
|
||||
Future<void> ensureConversation(String contactId) async {
|
||||
if (await loadConversation(contactId) != null) return;
|
||||
await saveConversation(Conversation(contactId: contactId));
|
||||
}
|
||||
|
||||
/// Nahradí existující zprávu (dle id) — stav přílohy, doručenka…
|
||||
Future<void> updateMessage(String contactId, Message message) async {
|
||||
final conv = await loadConversation(contactId);
|
||||
if (conv == null) return;
|
||||
conv.updateMessage(message);
|
||||
await saveConversation(conv);
|
||||
}
|
||||
|
||||
/// Smaže expirované mizící zprávy (v1 zjednodušení:
|
||||
/// TTL běží od času zprávy — symetrické pro obě strany bez read
|
||||
/// receiptů) včetně lokálních blobů příloh. Vrací true = něco smazáno.
|
||||
Future<bool> purgeExpiredMessages() async {
|
||||
if (!isUnlocked) return false;
|
||||
var changed = false;
|
||||
final now = DateTime.now();
|
||||
for (final conv in await loadConversations()) {
|
||||
final expired = conv.messages
|
||||
.where((m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now))
|
||||
.toList();
|
||||
if (expired.isEmpty) continue;
|
||||
changed = true;
|
||||
for (final m in expired) {
|
||||
final blobId = m.attachment?.blobId;
|
||||
if (blobId != null) {
|
||||
await AttachmentStore().delete(blobId);
|
||||
}
|
||||
conv.messages.remove(m);
|
||||
}
|
||||
await saveConversation(conv);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// === Nastavení (SQLCipher) ===
|
||||
|
||||
Future<void> setSetting(String key, dynamic value) async {
|
||||
db.appSettingPut(key, jsonEncode(value));
|
||||
}
|
||||
|
||||
Future<T?> getSetting<T>(String key) async {
|
||||
final json = db.appSettingGet(key);
|
||||
if (json == null) return null;
|
||||
return jsonDecode(json) as T?;
|
||||
}
|
||||
|
||||
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
||||
/// i lokální bloby příloh.
|
||||
Future<void> deleteAll() async {
|
||||
final path = await _dbPath();
|
||||
lock();
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
}
|
||||
await AttachmentStore().deleteAll();
|
||||
await _secureStorage.deleteAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Písma se dodávají v balíčku (assets/fonts, viz pubspec) — appka nikdy
|
||||
/// nestahuje fonty za běhu, aby nevznikl síťový požadavek na cizí CDN.
|
||||
const String kFontSans = 'Inter';
|
||||
const String kFontMono = 'JetBrains Mono';
|
||||
|
||||
/// Barevná paleta Šepot — „Nokturno".
|
||||
/// Noc + žhavý korál (ember) + chladný šum (mist). Dramaturgie: teplé konce,
|
||||
/// studený střed — lidé jsou teplí (ember), síť je chladná (mist).
|
||||
class SepotColors {
|
||||
SepotColors._();
|
||||
|
||||
// Pozadí — noc
|
||||
static const Color background = Color(0xFF0B0C12);
|
||||
static const Color surface = Color(0xFF12141D);
|
||||
static const Color surfaceVariant = Color(0xFF191C28);
|
||||
|
||||
// Text
|
||||
static const Color textPrimary = Color(0xFFF0EEE8); // mléko
|
||||
static const Color textSecondary = Color(0xFF9AA0B5); // mlha
|
||||
static const Color textMuted = Color(0xFF626880);
|
||||
|
||||
// Akcentní barvy
|
||||
static const Color ember = Color(0xFFF0795A); // žhavý korál — lidé, CTA
|
||||
static const Color emberInk = Color(0xFF2B120A); // text na ember ploše
|
||||
static const Color mist = Color(0xFF7D8BA6); // chladný šum — šifra, síť
|
||||
static const Color sage = Color(0xFF7FAE8C); // šalvěj — ověřeno/doručeno
|
||||
|
||||
// Funkční barvy
|
||||
static const Color error = Color(0xFFCF6B6B); // cihla
|
||||
static const Color warning = Color(0xFFD9A441);
|
||||
static const Color success = Color(0xFF7FAE8C);
|
||||
|
||||
// Okraje a oddělovače
|
||||
static const Color border = Color(0xFF262A3A);
|
||||
static const Color divider = Color(0xFF171A26);
|
||||
|
||||
// Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch)
|
||||
static const Color bubbleSent = Color(0xFFF0795A);
|
||||
static const Color bubbleReceived = Color(0xFF191C28);
|
||||
}
|
||||
|
||||
/// Témata aplikace
|
||||
class SepotTheme {
|
||||
SepotTheme._();
|
||||
|
||||
static ThemeData get dark {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: SepotColors.background,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: SepotColors.ember,
|
||||
secondary: SepotColors.mist,
|
||||
tertiary: SepotColors.sage,
|
||||
surface: SepotColors.surface,
|
||||
error: SepotColors.error,
|
||||
onPrimary: SepotColors.emberInk,
|
||||
onSecondary: SepotColors.emberInk,
|
||||
onSurface: SepotColors.textPrimary,
|
||||
onError: Colors.white,
|
||||
),
|
||||
textTheme: ThemeData.dark().textTheme.apply(
|
||||
fontFamily: kFontSans,
|
||||
bodyColor: SepotColors.textPrimary,
|
||||
displayColor: SepotColors.textPrimary,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: SepotColors.background,
|
||||
foregroundColor: SepotColors.textPrimary,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: SepotColors.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: SepotColors.border, width: 1),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: SepotColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: SepotColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: SepotColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: SepotColors.ember, width: 2),
|
||||
),
|
||||
hintStyle: const TextStyle(color: SepotColors.textMuted),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: SepotColors.ember,
|
||||
foregroundColor: SepotColors.emberInk,
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: SepotColors.ember,
|
||||
side: const BorderSide(color: SepotColors.ember),
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: SepotColors.ember,
|
||||
foregroundColor: SepotColors.emberInk,
|
||||
elevation: 4,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: SepotColors.divider,
|
||||
thickness: 1,
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: SepotColors.surface,
|
||||
selectedItemColor: SepotColors.ember,
|
||||
unselectedItemColor: SepotColors.textMuted,
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
side: const BorderSide(color: SepotColors.border),
|
||||
),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: SepotColors.surfaceVariant,
|
||||
contentTextStyle: const TextStyle(color: SepotColors.textPrimary),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Styl pro kryptografické texty (monospace)
|
||||
static const TextStyle cryptoText = TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
color: SepotColors.mist,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
);
|
||||
|
||||
/// Styl pro Šepot ID
|
||||
static const TextStyle idText = TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
color: SepotColors.ember,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/// Změna PINu ve třech krocích (starý → nový → potvrzení).
|
||||
///
|
||||
/// PIN se neověřuje proti hashi, ale unwrapem DEK (Argon2id). Každý pokus
|
||||
/// proto stojí ~2 s, což je zároveň jediná potřebná ochrana proti hádání.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/pin_keypad.dart';
|
||||
|
||||
enum _Krok { stary, novy, potvrzeni }
|
||||
|
||||
class ChangePinScreen extends ConsumerStatefulWidget {
|
||||
const ChangePinScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChangePinScreen> createState() => _ChangePinScreenState();
|
||||
}
|
||||
|
||||
class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
|
||||
_Krok _krok = _Krok.stary;
|
||||
String _pin = '';
|
||||
String? _staryPin;
|
||||
String? _novyPin;
|
||||
bool _pracuje = false;
|
||||
String? _chyba;
|
||||
|
||||
(String, String) _titulky(AppLocalizations l10n) => switch (_krok) {
|
||||
_Krok.stary =>
|
||||
(l10n.pinChangeCurrentTitle, l10n.pinChangeCurrentSubtitle),
|
||||
_Krok.novy => (l10n.pinChangeNewTitle, l10n.pinChangeNewSubtitle),
|
||||
_Krok.potvrzeni =>
|
||||
(l10n.pinChangeConfirmTitle, l10n.pinChangeConfirmSubtitle),
|
||||
};
|
||||
|
||||
void _reset(String chyba) {
|
||||
setState(() {
|
||||
_chyba = chyba;
|
||||
_pin = '';
|
||||
_staryPin = null;
|
||||
_novyPin = null;
|
||||
_krok = _Krok.stary;
|
||||
_pracuje = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _klavesa(String k) {
|
||||
if (_pracuje) return;
|
||||
setState(() {
|
||||
_chyba = null;
|
||||
if (k == 'DEL') {
|
||||
if (_pin.isNotEmpty) _pin = _pin.substring(0, _pin.length - 1);
|
||||
return;
|
||||
}
|
||||
if (_pin.length < 6) _pin += k;
|
||||
});
|
||||
if (_pin.length == 6) _hotovoSest();
|
||||
}
|
||||
|
||||
Future<void> _hotovoSest() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
|
||||
switch (_krok) {
|
||||
case _Krok.stary:
|
||||
setState(() => _pracuje = true);
|
||||
final ok = await storage.verifyPin(_pin);
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
_reset(context.l10n.pinWrong);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_staryPin = _pin;
|
||||
_pin = '';
|
||||
_krok = _Krok.novy;
|
||||
_pracuje = false;
|
||||
});
|
||||
|
||||
case _Krok.novy:
|
||||
if (_pin == _staryPin) {
|
||||
setState(() {
|
||||
_chyba = context.l10n.pinChangeMustDiffer;
|
||||
_pin = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_novyPin = _pin;
|
||||
_pin = '';
|
||||
_krok = _Krok.potvrzeni;
|
||||
});
|
||||
|
||||
case _Krok.potvrzeni:
|
||||
if (_pin != _novyPin) {
|
||||
setState(() {
|
||||
_chyba = context.l10n.pinMismatch;
|
||||
_pin = '';
|
||||
_novyPin = null;
|
||||
_krok = _Krok.novy;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() => _pracuje = true);
|
||||
try {
|
||||
await storage.changePin(oldPin: _staryPin!, newPin: _novyPin!);
|
||||
} on DecoyPinConflict catch (e) {
|
||||
if (!mounted) return;
|
||||
_reset(describeError(context.l10n, e));
|
||||
return;
|
||||
} on WrongPinException {
|
||||
if (!mounted) return;
|
||||
_reset(context.l10n.pinWrong);
|
||||
return;
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
_reset(context.l10n.pinChangeFailed);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.pinChanged)),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (titulek, popis) = _titulky(context.l10n);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.settingsChangePinTitle)),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
titulek,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
popis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
PinDots(filled: _pin.length),
|
||||
if (_chyba != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_chyba!,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
const TextStyle(color: SepotColors.error, fontSize: 14),
|
||||
),
|
||||
],
|
||||
if (_pracuje) ...[
|
||||
const SizedBox(height: 16),
|
||||
// Argon2id (64 MiB, t=3) — pár sekund je záměr, ne prodleva.
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: SepotColors.ember),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 40),
|
||||
PinKeypad(onKey: _klavesa, enabled: !_pracuje),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/sepot_logo.dart';
|
||||
|
||||
/// Odemykací obrazovka s PINem (+ volitelná biometrie)
|
||||
class PinLockScreen extends ConsumerStatefulWidget {
|
||||
const PinLockScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PinLockScreen> createState() => _PinLockScreenState();
|
||||
}
|
||||
|
||||
class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
String _pin = '';
|
||||
String? _error;
|
||||
bool _isVerifying = false;
|
||||
bool _biometricsAvailable = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Biometrie zapnutá → rovnou nabídnout systémovou výzvu
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _tryBiometrics());
|
||||
}
|
||||
|
||||
Future<void> _tryBiometrics() async {
|
||||
final unlockReason = context.l10n.pinUnlockTitle;
|
||||
final storage = ref.read(storageProvider);
|
||||
if (!await storage.isBiometricsEnabled()) return;
|
||||
if (mounted) setState(() => _biometricsAvailable = true);
|
||||
|
||||
try {
|
||||
final ok = await LocalAuthentication().authenticate(
|
||||
localizedReason: unlockReason,
|
||||
options: const AuthenticationOptions(stickyAuth: true),
|
||||
);
|
||||
if (!ok) return; // uživatel zrušil → zůstává PIN
|
||||
await storage.unlockWithBiometrics();
|
||||
if (mounted) context.go('/chats');
|
||||
} catch (_) {
|
||||
// biometrie nedostupná/selhala — PIN je vždy fallback
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SepotLogo(size: 60, showText: false),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.pinUnlockTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.pinEnterPin,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
// PIN tečky
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(6, (i) {
|
||||
return Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: i < _pin.length
|
||||
? SepotColors.ember
|
||||
: SepotColors.border,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: SepotColors.error, fontSize: 14),
|
||||
),
|
||||
],
|
||||
if (_isVerifying) ...[
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 48),
|
||||
_buildNumpad(),
|
||||
if (_biometricsAvailable) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: _isVerifying ? null : _tryBiometrics,
|
||||
icon: const Icon(Icons.fingerprint,
|
||||
color: SepotColors.ember),
|
||||
label: Text(
|
||||
context.l10n.pinUnlockWithBiometrics,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumpad() {
|
||||
return Column(
|
||||
children: [
|
||||
for (final row in [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9'],
|
||||
['', '0', 'DEL'],
|
||||
])
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: row.map((key) {
|
||||
if (key.isEmpty) return const SizedBox(width: 80, height: 64);
|
||||
return _buildKey(key);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKey(String key) {
|
||||
final isDelete = key == 'DEL';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _onKeyPress(key),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isDelete ? Colors.transparent : SepotColors.surfaceVariant,
|
||||
border: isDelete ? null : Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Center(
|
||||
child: isDelete
|
||||
? const Icon(
|
||||
Icons.backspace_outlined,
|
||||
color: SepotColors.textSecondary,
|
||||
size: 24,
|
||||
)
|
||||
: Text(
|
||||
key,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKeyPress(String key) {
|
||||
if (_isVerifying) return;
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
setState(() {
|
||||
_error = null;
|
||||
if (key == 'DEL') {
|
||||
if (_pin.isNotEmpty) {
|
||||
_pin = _pin.substring(0, _pin.length - 1);
|
||||
}
|
||||
} else if (_pin.length < 6) {
|
||||
_pin += key;
|
||||
if (_pin.length == 6) {
|
||||
_verifyPin();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _verifyPin() async {
|
||||
setState(() => _isVerifying = true);
|
||||
|
||||
try {
|
||||
// „Ověření" PINu = Argon2id unwrap DEK + otevření SQLCipher DB.
|
||||
// Špatný PIN = GCM tag nesedí = DB zůstává nečitelná.
|
||||
await ref.read(storageProvider).unlockWithPin(_pin);
|
||||
if (mounted) context.go('/chats');
|
||||
} on WrongPinException {
|
||||
setState(() {
|
||||
_error = context.l10n.pinWrong2;
|
||||
_pin = '';
|
||||
_isVerifying = false;
|
||||
});
|
||||
HapticFeedback.heavyImpact();
|
||||
} on StateError {
|
||||
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
|
||||
if (mounted) context.go('/onboarding');
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = context.l10n.pinUnlockError;
|
||||
_pin = '';
|
||||
_isVerifying = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Nastavení PINu (6 číslic)
|
||||
class PinSetupScreen extends ConsumerStatefulWidget {
|
||||
const PinSetupScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PinSetupScreen> createState() => _PinSetupScreenState();
|
||||
}
|
||||
|
||||
class _PinSetupScreenState extends ConsumerState<PinSetupScreen> {
|
||||
String _pin = '';
|
||||
String? _firstPin;
|
||||
bool _isConfirming = false;
|
||||
bool _isSaving = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.ember.withValues(alpha: 0.1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.pin_rounded,
|
||||
size: 32,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_isConfirming
|
||||
? context.l10n.pinConfirmTitle
|
||||
: context.l10n.pinSetupTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_isConfirming
|
||||
? context.l10n.pinConfirmSubtitle
|
||||
: context.l10n.pinSetupSubtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
// PIN tečky
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(6, (i) {
|
||||
return Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: i < _pin.length
|
||||
? SepotColors.ember
|
||||
: SepotColors.border,
|
||||
border: Border.all(
|
||||
color: i < _pin.length
|
||||
? SepotColors.ember
|
||||
: SepotColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
style: const TextStyle(
|
||||
color: SepotColors.error,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_isSaving) ...[
|
||||
const SizedBox(height: 16),
|
||||
// Argon2id wrap DEK běží (~sekundy, memory-hard záměrně)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 48),
|
||||
// Numerická klávesnice
|
||||
_buildNumpad(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumpad() {
|
||||
return Column(
|
||||
children: [
|
||||
for (final row in [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9'],
|
||||
['', '0', 'DEL'],
|
||||
])
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: row.map((key) {
|
||||
if (key.isEmpty) {
|
||||
return const SizedBox(width: 80, height: 64);
|
||||
}
|
||||
return _buildKey(key);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKey(String key) {
|
||||
final isDelete = key == 'DEL';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _onKeyPress(key),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isDelete
|
||||
? Colors.transparent
|
||||
: SepotColors.surfaceVariant,
|
||||
border: isDelete
|
||||
? null
|
||||
: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Center(
|
||||
child: isDelete
|
||||
? const Icon(
|
||||
Icons.backspace_outlined,
|
||||
color: SepotColors.textSecondary,
|
||||
size: 24,
|
||||
)
|
||||
: Text(
|
||||
key,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKeyPress(String key) {
|
||||
if (_isSaving) return;
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
setState(() {
|
||||
_error = null;
|
||||
|
||||
if (key == 'DEL') {
|
||||
if (_pin.isNotEmpty) {
|
||||
_pin = _pin.substring(0, _pin.length - 1);
|
||||
}
|
||||
} else if (_pin.length < 6) {
|
||||
_pin += key;
|
||||
|
||||
if (_pin.length == 6) {
|
||||
_onPinComplete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onPinComplete() async {
|
||||
if (_firstPin == null) {
|
||||
// První zadání PINu
|
||||
setState(() {
|
||||
_firstPin = _pin;
|
||||
_pin = '';
|
||||
_isConfirming = true;
|
||||
});
|
||||
} else {
|
||||
// Potvrzení PINu
|
||||
if (_pin == _firstPin) {
|
||||
// PINy se shodují — zabal DEK za Argon2id(PIN); od téhle chvíle
|
||||
// je PIN klíčem k databázi, ne vizuálním zámkem
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
await ref.read(storageProvider).enablePin(_pin);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = context.l10n.pinSetupFailed;
|
||||
_pin = '';
|
||||
_firstPin = null;
|
||||
_isConfirming = false;
|
||||
_isSaving = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
context.go('/chats');
|
||||
}
|
||||
} else {
|
||||
// PINy se neshodují
|
||||
setState(() {
|
||||
_error = context.l10n.pinMismatchRetry;
|
||||
_pin = '';
|
||||
_firstPin = null;
|
||||
_isConfirming = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/chat_list/chat_list_tile.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
|
||||
/// Archivované konverzace — stejné dlaždice, long-press nabídne odarchivovat.
|
||||
class ArchivedChatsScreen extends ConsumerWidget {
|
||||
const ArchivedChatsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final groupsAsync = ref.watch(groupsProvider);
|
||||
final myId = ref.watch(identityProvider).valueOrNull?.id ?? '';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.archiveTitle)),
|
||||
body: conversationsAsync.when(
|
||||
data: (conversations) {
|
||||
final archived = conversations.where((c) => c.archived).toList();
|
||||
if (archived.isEmpty) {
|
||||
return Center(
|
||||
child: Text(context.l10n.archiveEmpty,
|
||||
style: const TextStyle(color: SepotColors.textSecondary)),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: archived.length,
|
||||
itemBuilder: (context, index) {
|
||||
final conv = archived[index];
|
||||
final group = groupsAsync.valueOrNull
|
||||
?.where((g) => g.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final contact = contactsAsync.valueOrNull
|
||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final name = group?.name ??
|
||||
contact?.displayName ??
|
||||
context.l10n.commonUnknownContact;
|
||||
return ChatListTile(
|
||||
conversation: conv,
|
||||
contactName: name,
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||
onLongPress: () => _actions(context, ref, conv),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
error: (e, _) =>
|
||||
Center(child: Text(describeError(context.l10n, e))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _actions(BuildContext context, WidgetRef ref, Conversation conv) {
|
||||
final notifier = ref.read(conversationsProvider.notifier);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.unarchive_outlined),
|
||||
title: Text(context.l10n.archiveUnarchive),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setArchived(conv.contactId, false);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/chat_list/chat_list_tile.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/widgets/e2e_badge.dart';
|
||||
|
||||
/// Hlavní obrazovka — seznam konverzací
|
||||
class ChatListScreen extends ConsumerWidget {
|
||||
const ChatListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Nastartuje krypto + spojení s relayem (idempotentní)
|
||||
ref.watch(messagingStartProvider);
|
||||
// Zaregistruje push callbacky (po startu messagingu) — obnoví registraci,
|
||||
// pokud už distributor byl dřív vybraný. Push zapíná uživatel v nastavení.
|
||||
ref.watch(pushInitProvider);
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final groupsAsync = ref.watch(groupsProvider);
|
||||
final identityAsync = ref.watch(identityProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.commonAppName,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
onPressed: () {
|
||||
// TODO: Implementovat vyhledávání
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => context.push('/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: conversationsAsync.when(
|
||||
data: (conversations) {
|
||||
final active = conversations.where((c) => !c.archived).toList();
|
||||
final archived = conversations.where((c) => c.archived).toList();
|
||||
if (active.isEmpty && archived.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
final hasArchive = archived.isNotEmpty;
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: active.length + (hasArchive ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (hasArchive && index == 0) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.archive_outlined,
|
||||
color: SepotColors.textMuted),
|
||||
title: Text(context.l10n.chatListArchivedCount(archived.length),
|
||||
style: const TextStyle(color: SepotColors.textSecondary)),
|
||||
onTap: () => context.push('/archived'),
|
||||
);
|
||||
}
|
||||
final conv = active[index - (hasArchive ? 1 : 0)];
|
||||
final group = groupsAsync.valueOrNull
|
||||
?.where((g) => g.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final contact = contactsAsync.valueOrNull
|
||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final myId = identityAsync.valueOrNull?.id ?? '';
|
||||
final name = group?.name ??
|
||||
contact?.displayName ??
|
||||
context.l10n.commonUnknownContact;
|
||||
|
||||
return ChatListTile(
|
||||
conversation: conv,
|
||||
contactName: name,
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||
onLongPress: () => _showChatActions(context, ref, conv, name),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
error: (e, _) =>
|
||||
Center(child: Text(describeError(context.l10n, e))),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/contacts'),
|
||||
child: const Icon(Icons.chat_bubble_outline_rounded),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.ember.withValues(alpha: 0.1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 40,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.chatListEmptyTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chatListEmptySubtitle,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const E2EBadge(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Akce nad konverzací (long-press): ztlumit / archivovat.
|
||||
void _showChatActions(
|
||||
BuildContext context, WidgetRef ref, Conversation conv, String name) {
|
||||
final notifier = ref.read(conversationsProvider.notifier);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(conv.isMuted
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.notifications_off_outlined),
|
||||
title: Text(conv.isMuted
|
||||
? context.l10n.chatListUnmute
|
||||
: context.l10n.chatListMute),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setMuted(conv.contactId, !conv.isMuted);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.archive_outlined),
|
||||
title: Text(context.l10n.chatListArchive),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setArchived(conv.contactId, true);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Dlaždice konverzace v seznamu chatů. Sdílená hlavním i archivovaným
|
||||
/// seznamem, aby se logika (fajfky, náhled, ztlumení) nerozešla.
|
||||
class ChatListTile extends StatelessWidget {
|
||||
final Conversation conversation;
|
||||
final String contactName;
|
||||
final String myId;
|
||||
final bool isGroup;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
const ChatListTile({
|
||||
super.key,
|
||||
required this.conversation,
|
||||
required this.contactName,
|
||||
required this.myId,
|
||||
this.isGroup = false,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lastMsg = conversation.lastMessage;
|
||||
final unread = conversation.unreadCount(myId);
|
||||
final muted = conversation.isMuted;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
isGroup
|
||||
? Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.surfaceVariant,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: const Icon(Icons.group_rounded,
|
||||
color: SepotColors.ember, size: 24),
|
||||
)
|
||||
: SepotAvatar(name: contactName),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
contactName,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
unread > 0 ? FontWeight.w700 : FontWeight.w500,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (muted)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 6),
|
||||
child: Icon(Icons.notifications_off,
|
||||
size: 15, color: SepotColors.textMuted),
|
||||
),
|
||||
if (lastMsg != null)
|
||||
Text(
|
||||
_formatTime(context, lastMsg.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: unread > 0 && !muted
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (lastMsg != null && lastMsg.isMine(myId))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(
|
||||
switch (lastMsg.status) {
|
||||
MessageStatus.sending => Icons.schedule,
|
||||
MessageStatus.sent => Icons.done,
|
||||
MessageStatus.delivered => Icons.done_all,
|
||||
MessageStatus.read => Icons.done_all,
|
||||
MessageStatus.failed => Icons.error_outline,
|
||||
},
|
||||
size: 16,
|
||||
color: lastMsg.status == MessageStatus.failed
|
||||
? SepotColors.error
|
||||
: lastMsg.status == MessageStatus.read
|
||||
? SepotColors.mist
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getLastMessageText(context.l10n, lastMsg),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: unread > 0
|
||||
? SepotColors.textPrimary
|
||||
: SepotColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
if (unread > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
// Ztlumené = šedý badge (počet ano, důraz ne)
|
||||
color: muted
|
||||
? SepotColors.textMuted
|
||||
: SepotColors.ember,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
unread.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _getLastMessageText(AppLocalizations l10n, Message? msg) {
|
||||
if (msg == null) return l10n.chatListNewConversation;
|
||||
if (msg.isDeleted) return l10n.chatMessageDeleted;
|
||||
if (msg.attachment?.isAudio == true) return l10n.chatVoiceMessagePreview;
|
||||
switch (msg.type) {
|
||||
case MessageType.text:
|
||||
return msg.content ?? '';
|
||||
case MessageType.image:
|
||||
return msg.content?.isNotEmpty == true
|
||||
? l10n.chatListPhotoWithCaptionPreview(msg.content!)
|
||||
: l10n.chatPhotoPreview;
|
||||
case MessageType.file:
|
||||
return l10n.chatListFilePreview(msg.fileName ?? l10n.commonFile);
|
||||
case MessageType.readReceipt:
|
||||
return l10n.chatListReadReceiptPreview;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static String _formatTime(BuildContext context, DateTime time) {
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(time);
|
||||
if (diff.inMinutes < 1) return context.l10n.chatListTimeNow;
|
||||
if (diff.inHours < 1) return context.l10n.chatListTimeMinutes(diff.inMinutes);
|
||||
if (diff.inDays < 1) return DateFormat.Hm(locale).format(time);
|
||||
if (diff.inDays < 7) return DateFormat.E(locale).format(time);
|
||||
return DateFormat.Md(locale).format(time);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/// Ověření kontaktu — safety number.
|
||||
///
|
||||
/// Obě strany vidí STEJNÝCH 60 číslic (libsignal Fingerprint, menší
|
||||
/// polovina první). Ověření: naskenovat QR protistrany (libsignal
|
||||
/// porovná klíče křížem) nebo přečíst číslice jiným kanálem a potvrdit
|
||||
/// ručně. Shoda → kontakt „ověřen"; neshoda → varování před MITM.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:sepot/core/crypto/safety_number.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Prefix QR obsahu — odlišuje ověřovací QR od kontaktního `sepot://add/`.
|
||||
const _verifyQrPrefix = 'sepot-verify:';
|
||||
|
||||
class ContactVerifyScreen extends ConsumerStatefulWidget {
|
||||
const ContactVerifyScreen({super.key, required this.contactId});
|
||||
|
||||
final String contactId;
|
||||
|
||||
@override
|
||||
ConsumerState<ContactVerifyScreen> createState() =>
|
||||
_ContactVerifyScreenState();
|
||||
}
|
||||
|
||||
class _ContactVerifyScreenState extends ConsumerState<ContactVerifyScreen> {
|
||||
SafetyNumber? _number;
|
||||
String? _numberForKey; // cache per identity klíč kontaktu
|
||||
|
||||
SafetyNumber? _safetyNumber(Contact contact, Uint8List localKey) {
|
||||
final keyId = contact.publicIdentity.fingerprint.display;
|
||||
if (_number == null || _numberForKey != keyId) {
|
||||
_number = SafetyNumber.compute(
|
||||
localIdentityKey: localKey,
|
||||
remoteIdentityKey: contact.publicIdentity.identityKey,
|
||||
);
|
||||
_numberForKey = keyId;
|
||||
}
|
||||
return _number;
|
||||
}
|
||||
|
||||
Future<void> _setVerified(Contact contact, bool verified) async {
|
||||
contact.verified = verified;
|
||||
await ref.read(storageProvider).saveContact(contact);
|
||||
ref.invalidate(contactsProvider);
|
||||
}
|
||||
|
||||
Future<void> _scanAndCompare(Contact contact, SafetyNumber mine) async {
|
||||
final scanned = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(builder: (_) => const _VerifyScanScreen()),
|
||||
);
|
||||
if (scanned == null || !mounted) return;
|
||||
|
||||
var match = false;
|
||||
if (scanned.startsWith(_verifyQrPrefix)) {
|
||||
try {
|
||||
final theirs = Uint8List.fromList(
|
||||
base64Decode(scanned.substring(_verifyQrPrefix.length)));
|
||||
match = SafetyNumber.compare(mine.scannable, theirs);
|
||||
} on FormatException {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
await _setVerified(contact, true);
|
||||
}
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
icon: Icon(
|
||||
match ? Icons.verified : Icons.gpp_bad_outlined,
|
||||
color: match ? SepotColors.sage : SepotColors.error,
|
||||
size: 40,
|
||||
),
|
||||
title: Text(
|
||||
match ? ctx.l10n.verifyVerified : ctx.l10n.verifyMismatchTitle),
|
||||
content: Text(
|
||||
match
|
||||
? ctx.l10n.verifyMatchBody(contact.displayName)
|
||||
: ctx.l10n.verifyMismatchBody,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(ctx.l10n.commonUnderstood),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmManual(Contact contact) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.verifyManualConfirmTitle),
|
||||
content: Text(ctx.l10n.verifyManualConfirmBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(ctx.l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(ctx.l10n.verifyManualConfirmAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) await _setVerified(contact, true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final contact = ref
|
||||
.watch(contactsProvider)
|
||||
.valueOrNull
|
||||
?.where((c) => c.id == widget.contactId)
|
||||
.firstOrNull;
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
|
||||
if (contact == null || identity == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.verifyTitle)),
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final number = _safetyNumber(contact, identity.identityPublicKey)!;
|
||||
final qrData = '$_verifyQrPrefix${base64Encode(number.scannable)}';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.verifyTitle)),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (contact.verified)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.sage.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.verified,
|
||||
color: SepotColors.sage, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.verifyContactVerifiedBanner,
|
||||
style: const TextStyle(color: SepotColors.sage)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.verifyIntro(contact.displayName),
|
||||
style: const TextStyle(
|
||||
color: SepotColors.textSecondary, height: 1.4),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: qrData,
|
||||
size: 180,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 60 číslic ve 12 blocích po 5 (4 řádky × 3 sloupce)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var row = 0; row < 4; row++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: row < 3 ? 10 : 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
for (var col = 0; col < 3; col++)
|
||||
Text(
|
||||
number.groups[row * 3 + col],
|
||||
style: SepotTheme.cryptoText.copyWith(
|
||||
fontSize: 17,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _scanAndCompare(contact, number),
|
||||
icon: const Icon(Icons.qr_code_scanner_rounded),
|
||||
label: Text(context.l10n.verifyScanButton),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _confirmManual(contact),
|
||||
icon: const Icon(Icons.checklist_rounded),
|
||||
label: Text(context.l10n.verifyManualButton),
|
||||
),
|
||||
if (contact.verified) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => _setVerified(contact, false),
|
||||
child: Text(
|
||||
context.l10n.verifyUnverifyButton,
|
||||
style: const TextStyle(color: SepotColors.textMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimalistický skener jen pro ověřovací QR — vrací syrový obsah kódu.
|
||||
class _VerifyScanScreen extends StatefulWidget {
|
||||
const _VerifyScanScreen();
|
||||
|
||||
@override
|
||||
State<_VerifyScanScreen> createState() => _VerifyScanScreenState();
|
||||
}
|
||||
|
||||
class _VerifyScanScreenState extends State<_VerifyScanScreen> {
|
||||
final _controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.normal,
|
||||
facing: CameraFacing.back,
|
||||
);
|
||||
bool _done = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_done) return;
|
||||
final raw = capture.barcodes.firstOrNull?.rawValue;
|
||||
if (raw == null) return;
|
||||
_done = true;
|
||||
Navigator.of(context).pop(raw);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.verifyScanScreenTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.flash_on),
|
||||
onPressed: () => _controller.toggleTorch(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: MobileScanner(controller: _controller, onDetect: _onDetect),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Obrazovka kontaktů
|
||||
class ContactsScreen extends ConsumerStatefulWidget {
|
||||
const ContactsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ContactsScreen> createState() => _ContactsScreenState();
|
||||
}
|
||||
|
||||
class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.contactsTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.group_add_outlined),
|
||||
onPressed: () => context.push('/create-group'),
|
||||
tooltip: context.l10n.groupsNewGroupTitle,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner_rounded),
|
||||
onPressed: () => context.push('/qr-scan'),
|
||||
tooltip: context.l10n.qrScanTitle,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_add_outlined),
|
||||
onPressed: () => _showAddContactDialog(context),
|
||||
tooltip: context.l10n.contactsAddContact,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Vyhledávání
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.contactsSearchHint,
|
||||
prefixIcon: const Icon(Icons.search, color: SepotColors.textMuted),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: SepotColors.border),
|
||||
),
|
||||
),
|
||||
onChanged: (v) => setState(() => _searchQuery = v.toLowerCase()),
|
||||
),
|
||||
),
|
||||
// Sdílet vlastní QR
|
||||
_buildShareCard(context),
|
||||
const Divider(height: 1),
|
||||
// Seznam kontaktů
|
||||
Expanded(
|
||||
child: contactsAsync.when(
|
||||
data: (contacts) {
|
||||
final filtered = contacts.where((c) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
return c.displayName.toLowerCase().contains(_searchQuery) ||
|
||||
c.sepotId.toLowerCase().contains(_searchQuery);
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.people_outline,
|
||||
size: 48,
|
||||
color: SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_searchQuery.isEmpty
|
||||
? context.l10n.contactsEmpty
|
||||
: context.l10n.contactsNoResults,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 16,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filtered[index];
|
||||
final displayName = contact.displayName.isEmpty
|
||||
? context.l10n.commonUnknownContact
|
||||
: contact.displayName;
|
||||
return ListTile(
|
||||
leading: SepotAvatar(name: displayName),
|
||||
title: Text(
|
||||
displayName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Text(
|
||||
contact.sepotId,
|
||||
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
|
||||
),
|
||||
if (contact.verified) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.verified,
|
||||
size: 14,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
color: SepotColors.textMuted,
|
||||
),
|
||||
onTap: () {
|
||||
context.push('/conversation/${contact.publicIdentity.id}');
|
||||
},
|
||||
onLongPress: () =>
|
||||
_showContactOptions(context, contact.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
error: (e, _) =>
|
||||
Center(child: Text(describeError(context.l10n, e))),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShareCard(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => context.push('/qr-share'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: SepotColors.surfaceVariant,
|
||||
child: Icon(
|
||||
Icons.qr_code_rounded,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.contactsShareCardTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.contactsShareCardSubtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: SepotColors.textMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddContactDialog(BuildContext context) {
|
||||
final idController = TextEditingController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.contactsAddContact),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.contactsAddDialogPrompt,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: idController,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.contactsShareStringHint,
|
||||
prefixIcon: const Icon(Icons.link),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final shareString = idController.text.trim();
|
||||
if (shareString.isEmpty) return;
|
||||
|
||||
final identity = PublicIdentity.fromShareString(shareString);
|
||||
if (identity == null) {
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text(context.l10n.contactsInvalidShareString)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(contactsProvider.notifier)
|
||||
.addContact(identity);
|
||||
if (ctx.mounted) Navigator.of(ctx).pop();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.contactsAdded(
|
||||
identity.displayName.isEmpty
|
||||
? context.l10n.commonUnknownContact
|
||||
: identity.displayName)),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(describeError(context.l10n, e))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.commonAdd),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContactOptions(BuildContext context, String contactId) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.verified_outlined, color: SepotColors.ember),
|
||||
title: Text(context.l10n.chatMenuVerifyContact),
|
||||
subtitle: Text(context.l10n.verifyContactSubtitle),
|
||||
onTap: () {
|
||||
Navigator.of(ctx).pop();
|
||||
// Skutečné ověření = safety number, ne slepé přepnutí flagu
|
||||
context.push('/verify/$contactId');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: SepotColors.error),
|
||||
title: Text(context.l10n.contactsDeleteContact,
|
||||
style: const TextStyle(color: SepotColors.error)),
|
||||
onTap: () {
|
||||
ref.read(contactsProvider.notifier).removeContact(contactId);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
/// Fullscreen prohlížeč obrázkové přílohy.
|
||||
///
|
||||
/// Plné rozlišení se dešifruje až tady, do PAMĚTI (na disk nikdy).
|
||||
/// FLAG_SECURE platí globálně, takže screenshot/škrcení obrazovky
|
||||
/// nehrozí ani ve vieweru. Sdílení jde přes systémový share sheet
|
||||
/// (XFile.fromData — plaintext jen v cache sdílení).
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class ImageViewerScreen extends ConsumerStatefulWidget {
|
||||
const ImageViewerScreen({super.key, required this.message});
|
||||
|
||||
final Message message;
|
||||
|
||||
@override
|
||||
ConsumerState<ImageViewerScreen> createState() => _ImageViewerScreenState();
|
||||
}
|
||||
|
||||
class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
|
||||
late final Future<Uint8List?> _decrypted;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_decrypted = ref
|
||||
.read(messagingServiceProvider)
|
||||
.loadDecryptedAttachment(widget.message);
|
||||
}
|
||||
|
||||
Future<void> _share() async {
|
||||
final meta = widget.message.attachment!;
|
||||
final ext = meta.mime == 'image/png'
|
||||
? 'png'
|
||||
: meta.mime == 'image/gif'
|
||||
? 'gif'
|
||||
: 'jpg';
|
||||
final name = context.l10n.attachmentSharedImageFilename(ext);
|
||||
final bytes = await _decrypted;
|
||||
if (bytes == null) return;
|
||||
await Share.shareXFiles([
|
||||
XFile.fromData(bytes, name: name, mimeType: meta.mime),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
onPressed: _share,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<Uint8List?>(
|
||||
future: _decrypted,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
);
|
||||
}
|
||||
final bytes = snapshot.data;
|
||||
if (snapshot.hasError || bytes == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
context.l10n.attachmentDecryptFailed,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
);
|
||||
}
|
||||
return InteractiveViewer(
|
||||
maxScale: 6,
|
||||
child: Center(
|
||||
child: Image.memory(bytes, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
/// Obsah bublin s přílohou — obrázek (thumbnail + stav) a dokument
|
||||
/// (karta s názvem/velikostí). Plné rozlišení se dešifruje až ve vieweru;
|
||||
/// v chatu stačí inline náhled, který dorazil UVNITŘ E2E zprávy.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/conversation/image_viewer_screen.dart';
|
||||
import 'package:sepot/models/attachment.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
String formatBytes(AppLocalizations l10n, int bytes) {
|
||||
if (bytes < 1024) return l10n.attachmentSizeBytes(bytes);
|
||||
if (bytes < 1024 * 1024) {
|
||||
return l10n.attachmentSizeKb((bytes / 1024).toStringAsFixed(0));
|
||||
}
|
||||
return l10n.attachmentSizeMb((bytes / (1024 * 1024)).toStringAsFixed(1));
|
||||
}
|
||||
|
||||
/// Cache dešifrovaných plných obrázků (blob_id → bajty), ať se nedešifruje
|
||||
/// při každém překreslení seznamu. Prostý strop, nejstarší se zahazují.
|
||||
final _fullImageCache = <String, Uint8List>{};
|
||||
const _fullImageCacheMax = 30;
|
||||
|
||||
void _cachePut(String blobId, Uint8List bytes) {
|
||||
_fullImageCache.remove(blobId);
|
||||
_fullImageCache[blobId] = bytes;
|
||||
while (_fullImageCache.length > _fullImageCacheMax) {
|
||||
_fullImageCache.remove(_fullImageCache.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obrázková příloha: náhled + overlay podle stavu přenosu.
|
||||
class ImageAttachmentContent extends ConsumerWidget {
|
||||
const ImageAttachmentContent({
|
||||
super.key,
|
||||
required this.contactId,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final String contactId;
|
||||
final Message message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final meta = message.attachment!;
|
||||
final thumb = meta.thumbnail;
|
||||
|
||||
final aspect = (meta.width != null &&
|
||||
meta.height != null &&
|
||||
meta.width! > 0 &&
|
||||
meta.height! > 0)
|
||||
? meta.width! / meta.height!
|
||||
: 4 / 3;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: switch (meta.state) {
|
||||
AttachmentTransferState.ready => () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ImageViewerScreen(message: message),
|
||||
),
|
||||
);
|
||||
},
|
||||
AttachmentTransferState.failed => () => ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.retryAttachmentDownload(contactId, message),
|
||||
_ => null,
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: aspect.clamp(0.5, 2.5),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Náhled jako podklad/placeholder (rozmazaný, ne kostičkovaný).
|
||||
// Klíč podle blob_id — jinak ListView recykluje widget a
|
||||
// s gaplessPlayback ukáže náhled PŘEDCHOZÍ fotky.
|
||||
if (thumb != null)
|
||||
Image.memory(thumb,
|
||||
key: ValueKey('thumb-${meta.blobId}'),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true)
|
||||
else
|
||||
const ColoredBox(
|
||||
color: SepotColors.surfaceVariant,
|
||||
child: Icon(Icons.image_outlined,
|
||||
size: 48, color: SepotColors.textMuted),
|
||||
),
|
||||
// Jakmile je blob stažený, překryj OSTROU plnou fotkou.
|
||||
// Klíč podle blob_id, ať se stav nedědí z jiné fotky.
|
||||
if (meta.state == AttachmentTransferState.ready)
|
||||
_FullImage(
|
||||
key: ValueKey('full-${meta.blobId}'),
|
||||
contactId: contactId,
|
||||
message: message),
|
||||
if (meta.state != AttachmentTransferState.ready)
|
||||
ColoredBox(
|
||||
color: Colors.black45,
|
||||
child: Center(child: _stateIndicator(context, meta.state)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stateIndicator(BuildContext context, AttachmentTransferState state) {
|
||||
return switch (state) {
|
||||
AttachmentTransferState.downloading ||
|
||||
AttachmentTransferState.pending =>
|
||||
const SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
AttachmentTransferState.failed => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.refresh_rounded, color: Colors.white, size: 32),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.attachmentDownloadFailedTapToRetry,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
AttachmentTransferState.ready => const SizedBox.shrink(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Ostrá plná fotka do bubliny (stav ready). Dešifruje blob do paměti
|
||||
/// s cache; než je hotová, prosvítá náhled pod ní (žádné kostičky).
|
||||
class _FullImage extends ConsumerStatefulWidget {
|
||||
const _FullImage(
|
||||
{super.key, required this.contactId, required this.message});
|
||||
|
||||
final String contactId;
|
||||
final Message message;
|
||||
|
||||
@override
|
||||
ConsumerState<_FullImage> createState() => _FullImageState();
|
||||
}
|
||||
|
||||
class _FullImageState extends ConsumerState<_FullImage> {
|
||||
Uint8List? _bytes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_FullImage old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Kdyby se widget přece jen recykloval na jinou fotku (jiný blob_id),
|
||||
// zahoď staré bajty a načti čerstvě — žádné zděděné náhledy.
|
||||
if (old.message.attachment!.blobId != widget.message.attachment!.blobId) {
|
||||
_bytes = null;
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final blobId = widget.message.attachment!.blobId;
|
||||
final cached = _fullImageCache[blobId];
|
||||
if (cached != null) {
|
||||
setState(() => _bytes = cached);
|
||||
return;
|
||||
}
|
||||
final bytes = await ref
|
||||
.read(messagingServiceProvider)
|
||||
.loadDecryptedAttachment(widget.message);
|
||||
if (bytes != null) _cachePut(blobId, bytes);
|
||||
if (mounted) setState(() => _bytes = bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bytes = _bytes;
|
||||
if (bytes == null) return const SizedBox.shrink();
|
||||
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hlasová zpráva: play/pause, průběh a délka. Blob se dešifruje do paměti
|
||||
/// až při prvním přehrání (plaintext audio nikdy nejde na disk).
|
||||
class AudioAttachmentContent extends ConsumerStatefulWidget {
|
||||
const AudioAttachmentContent({
|
||||
super.key,
|
||||
required this.contactId,
|
||||
required this.message,
|
||||
required this.isMine,
|
||||
});
|
||||
|
||||
final String contactId;
|
||||
final Message message;
|
||||
final bool isMine;
|
||||
|
||||
@override
|
||||
ConsumerState<AudioAttachmentContent> createState() => _AudioState();
|
||||
}
|
||||
|
||||
class _AudioState extends ConsumerState<AudioAttachmentContent> {
|
||||
final _player = AudioPlayer();
|
||||
final _subs = <StreamSubscription<dynamic>>[];
|
||||
bool _loading = false;
|
||||
bool _playing = false;
|
||||
Duration _pos = Duration.zero;
|
||||
Duration? _total;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final dur = widget.message.attachment!.durationMs;
|
||||
_total = dur != null ? Duration(milliseconds: dur) : null;
|
||||
_subs.add(_player.onPositionChanged.listen((p) {
|
||||
if (mounted) setState(() => _pos = p);
|
||||
}));
|
||||
_subs.add(_player.onDurationChanged.listen((d) {
|
||||
if (mounted && d > Duration.zero) setState(() => _total = d);
|
||||
}));
|
||||
_subs.add(_player.onPlayerStateChanged.listen((s) {
|
||||
if (mounted) setState(() => _playing = s == PlayerState.playing);
|
||||
}));
|
||||
_subs.add(_player.onPlayerComplete.listen((_) {
|
||||
if (mounted) setState(() => _pos = Duration.zero);
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final s in _subs) {
|
||||
s.cancel();
|
||||
}
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString();
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
final meta = widget.message.attachment!;
|
||||
if (_playing) {
|
||||
await _player.pause();
|
||||
return;
|
||||
}
|
||||
if (_pos > Duration.zero && _pos < (_total ?? Duration.zero)) {
|
||||
await _player.resume();
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
final bytes = await ref
|
||||
.read(messagingServiceProvider)
|
||||
.loadDecryptedAttachment(widget.message);
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
if (bytes == null) return;
|
||||
await _player.play(BytesSource(bytes, mimeType: meta.mime));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = widget.message.attachment!;
|
||||
final ink = widget.isMine ? SepotColors.emberInk : SepotColors.textPrimary;
|
||||
final accent = widget.isMine ? SepotColors.emberInk : SepotColors.ember;
|
||||
|
||||
if (meta.state != AttachmentTransferState.ready) {
|
||||
return SizedBox(
|
||||
width: 200,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
meta.state == AttachmentTransferState.failed
|
||||
? context.l10n.attachmentDownloadFailed
|
||||
: context.l10n.attachmentDownloading,
|
||||
style: TextStyle(color: ink, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final total = _total ?? Duration.zero;
|
||||
final progress = total.inMilliseconds == 0
|
||||
? 0.0
|
||||
: (_pos.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0);
|
||||
|
||||
return SizedBox(
|
||||
width: 210,
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _loading ? null : _toggle,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: accent.withValues(alpha: 0.18),
|
||||
),
|
||||
child: _loading
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: accent),
|
||||
)
|
||||
: Icon(_playing ? Icons.pause : Icons.play_arrow,
|
||||
color: accent, size: 24),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 4,
|
||||
backgroundColor: ink.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation(accent),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(Icons.mic, size: 13, color: ink.withValues(alpha: .6)),
|
||||
Text(
|
||||
_playing || _pos > Duration.zero
|
||||
? _fmt(_pos)
|
||||
: _fmt(total),
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: ink.withValues(alpha: .8)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dokument: karta s ikonou, názvem a velikostí. Klepnutí (stav ready)
|
||||
/// dešifruje do paměti a otevře systémový share sheet — plaintext se na
|
||||
/// disk dostane jen do cache sdílení (spravuje OS), nikdy do galerie.
|
||||
class DocumentAttachmentContent extends ConsumerStatefulWidget {
|
||||
const DocumentAttachmentContent({
|
||||
super.key,
|
||||
required this.contactId,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final String contactId;
|
||||
final Message message;
|
||||
|
||||
@override
|
||||
ConsumerState<DocumentAttachmentContent> createState() =>
|
||||
_DocumentAttachmentContentState();
|
||||
}
|
||||
|
||||
class _DocumentAttachmentContentState
|
||||
extends ConsumerState<DocumentAttachmentContent> {
|
||||
bool _opening = false;
|
||||
|
||||
Future<void> _open() async {
|
||||
final meta = widget.message.attachment!;
|
||||
final l10n = context.l10n;
|
||||
setState(() => _opening = true);
|
||||
try {
|
||||
final bytes = await ref
|
||||
.read(messagingServiceProvider)
|
||||
.loadDecryptedAttachment(widget.message);
|
||||
if (bytes == null) return;
|
||||
await Share.shareXFiles([
|
||||
XFile.fromData(
|
||||
bytes,
|
||||
name: meta.name ?? l10n.attachmentSharedFileFallbackName,
|
||||
mimeType: meta.mime,
|
||||
),
|
||||
]);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text(l10n.attachmentOpenFailed(describeError(l10n, e)))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _opening = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = widget.message.attachment!;
|
||||
|
||||
return InkWell(
|
||||
onTap: switch (meta.state) {
|
||||
AttachmentTransferState.ready => _opening ? null : _open,
|
||||
AttachmentTransferState.failed => () => ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.retryAttachmentDownload(widget.contactId, widget.message),
|
||||
_ => null,
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_leadingIcon(meta.state),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
meta.name ?? context.l10n.commonFile,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
meta.state == AttachmentTransferState.failed
|
||||
? context.l10n.attachmentDownloadFailedTapToRetry
|
||||
: formatBytes(context.l10n, meta.size),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _leadingIcon(AttachmentTransferState state) {
|
||||
const size = 38.0;
|
||||
if (_opening ||
|
||||
state == AttachmentTransferState.downloading ||
|
||||
state == AttachmentTransferState.pending) {
|
||||
return const SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.ember.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
state == AttachmentTransferState.failed
|
||||
? Icons.refresh_rounded
|
||||
: Icons.description_outlined,
|
||||
color: SepotColors.ember,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Založení skupiny: jméno + výběr členů.
|
||||
///
|
||||
/// Vybrat jde jen kontakty s doručovací adresou (routing) — bez ní jim
|
||||
/// fanout nemá kam doručit. Kontakty bez routingu jsou v seznamu vidět,
|
||||
/// ale zašedlé s vysvětlením.
|
||||
class CreateGroupScreen extends ConsumerStatefulWidget {
|
||||
const CreateGroupScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CreateGroupScreen> createState() => _CreateGroupScreenState();
|
||||
}
|
||||
|
||||
class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
final _selected = <String>{};
|
||||
var _creating = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _hasRouting(Contact c) =>
|
||||
c.publicIdentity.accountId != null &&
|
||||
c.publicIdentity.deliveryAddress != null;
|
||||
|
||||
Future<void> _create(List<Contact> contacts) async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty || _selected.isEmpty || _creating) return;
|
||||
|
||||
setState(() => _creating = true);
|
||||
try {
|
||||
final members =
|
||||
contacts.where((c) => _selected.contains(c.id)).toList();
|
||||
final group =
|
||||
await ref.read(groupsProvider.notifier).createGroup(name, members);
|
||||
if (mounted) context.pushReplacement('/conversation/${group.id}');
|
||||
} catch (e) {
|
||||
setState(() => _creating = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n
|
||||
.groupsCreateFailed(describeError(context.l10n, e))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final contacts = (contactsAsync.valueOrNull ?? [])
|
||||
.where((c) => !c.blocked)
|
||||
.toList();
|
||||
final canCreate = _nameController.text.trim().isNotEmpty &&
|
||||
_selected.isNotEmpty &&
|
||||
!_creating;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.groupsNewGroupTitle),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: canCreate ? () => _create(contacts) : null,
|
||||
child: _creating
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: SepotColors.ember),
|
||||
)
|
||||
: Text(context.l10n.groupsCreateAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
maxLength: 64,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.groupsNameHint,
|
||||
counterText: '',
|
||||
prefixIcon: const Icon(Icons.group_rounded,
|
||||
color: SepotColors.textMuted),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.groupsMembersCounter(
|
||||
_selected.length, Group.maxMembers - 1),
|
||||
style: const TextStyle(
|
||||
color: SepotColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: contacts.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
context.l10n.groupsEmptyContactsHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: SepotColors.textSecondary),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: contacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final c = contacts[index];
|
||||
final routable = _hasRouting(c);
|
||||
final checked = _selected.contains(c.id);
|
||||
return CheckboxListTile(
|
||||
value: checked,
|
||||
onChanged: routable
|
||||
? (v) => setState(() {
|
||||
if (v == true) {
|
||||
if (_selected.length <
|
||||
Group.maxMembers - 1) {
|
||||
_selected.add(c.id);
|
||||
}
|
||||
} else {
|
||||
_selected.remove(c.id);
|
||||
}
|
||||
})
|
||||
: null,
|
||||
secondary: SepotAvatar(name: c.displayName, size: 40),
|
||||
title: Text(
|
||||
c.displayName,
|
||||
style: TextStyle(
|
||||
color: routable
|
||||
? SepotColors.textPrimary
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
subtitle: routable
|
||||
? null
|
||||
: Text(
|
||||
context.l10n.groupsMemberMissingAddress,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: SepotColors.textMuted),
|
||||
),
|
||||
activeColor: SepotColors.ember,
|
||||
controlAffinity: ListTileControlAffinity.trailing,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/sepot_logo.dart';
|
||||
|
||||
/// Onboarding — 3 stránky + generování identity
|
||||
class OnboardingScreen extends ConsumerStatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||
final _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
final _nameController = TextEditingController();
|
||||
List<String>? _seedPhrase;
|
||||
bool _isCreating = false;
|
||||
bool _seedSaved = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Indikátor stránek
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Row(
|
||||
children: List.generate(3, (i) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 3,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: i <= _currentPage
|
||||
? SepotColors.ember
|
||||
: SepotColors.border,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
// Stránky
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
onPageChanged: (page) => setState(() => _currentPage = page),
|
||||
children: [
|
||||
_buildWelcomePage(),
|
||||
_buildEncryptionPage(),
|
||||
_buildIdentityPage(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stránka 1: Uvítání
|
||||
Widget _buildWelcomePage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SepotLogo(size: 100),
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
context.l10n.onboardingWelcomeTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.onboardingWelcomeSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 16,
|
||||
height: 1.6,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed: () => _pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
child: Text(context.l10n.onboardingStart),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/restore'),
|
||||
child: Text(context.l10n.onboardingRestoreExisting),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stránka 2: Vysvětlení šifrování
|
||||
Widget _buildEncryptionPage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.mist.withValues(alpha: 0.1),
|
||||
border: Border.all(color: SepotColors.mist.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.shield_rounded,
|
||||
size: 40,
|
||||
color: SepotColors.mist,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
context.l10n.onboardingEncryptionTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildFeatureRow(
|
||||
Icons.lock_rounded,
|
||||
context.l10n.onboardingFeatureSignalTitle,
|
||||
context.l10n.onboardingFeatureSignalSubtitle,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureRow(
|
||||
Icons.fingerprint,
|
||||
context.l10n.onboardingFeaturePqTitle,
|
||||
context.l10n.onboardingFeaturePqSubtitle,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureRow(
|
||||
Icons.phone_android_rounded,
|
||||
context.l10n.onboardingFeatureLocalKeysTitle,
|
||||
context.l10n.onboardingFeatureLocalKeysSubtitle,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureRow(
|
||||
Icons.timer_outlined,
|
||||
context.l10n.chatDisappearingMessages,
|
||||
context.l10n.onboardingFeatureDisappearingSubtitle,
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed: () => _pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
child: Text(context.l10n.commonContinue),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureRow(IconData icon, String title, String subtitle) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: SepotColors.surfaceVariant,
|
||||
),
|
||||
child: Icon(icon, size: 22, color: SepotColors.ember),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Stránka 3: Generování identity
|
||||
Widget _buildIdentityPage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.onboardingIdentityTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.onboardingIdentitySubtitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.onboardingDisplayNameLabel,
|
||||
hintText: context.l10n.onboardingDisplayNameHint,
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
// Bez rebuildů by tlačítko „Vygenerovat klíče" zůstalo
|
||||
// navždy disabled (jeho stav čte controller v build())
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (_seedPhrase != null) ...[
|
||||
Text(
|
||||
context.l10n.settingsSeedPhraseTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.onboardingSeedPhraseWarning(_seedPhrase!.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.warning,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: SepotColors.surfaceVariant,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(_seedPhrase!.length, (i) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: SepotColors.surface,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Text(
|
||||
'${i + 1}. ${_seedPhrase![i]}',
|
||||
style: SepotTheme.cryptoText.copyWith(fontSize: 13),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
value: _seedSaved,
|
||||
onChanged: (v) => setState(() => _seedSaved = v ?? false),
|
||||
title: Text(
|
||||
context.l10n.onboardingSeedSavedConfirm,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
activeColor: SepotColors.ember,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
if (_seedPhrase == null)
|
||||
ElevatedButton(
|
||||
onPressed: _nameController.text.trim().isNotEmpty
|
||||
? _generateIdentity
|
||||
: null,
|
||||
child: _isCreating
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(context.l10n.onboardingGenerateKeys),
|
||||
)
|
||||
else
|
||||
ElevatedButton(
|
||||
onPressed: _seedSaved ? _completeOnboarding : null,
|
||||
child: Text(context.l10n.commonContinue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _generateIdentity() async {
|
||||
if (_nameController.text.trim().isEmpty) return;
|
||||
|
||||
setState(() => _isCreating = true);
|
||||
|
||||
try {
|
||||
// Vygeneruj identitu
|
||||
final identity = await ref
|
||||
.read(identityProvider.notifier)
|
||||
.createIdentity(_nameController.text.trim());
|
||||
|
||||
// Seed fráze = skutečná záloha identity klíče (BIP39 enkódování),
|
||||
// NE nezávislá slova — jinak by obnova nikdy nefungovala
|
||||
final seed = identity.mnemonic;
|
||||
await ref.read(storageProvider).saveSeedPhrase(seed);
|
||||
|
||||
setState(() {
|
||||
_seedPhrase = seed;
|
||||
_isCreating = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isCreating = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(describeError(context.l10n, e))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _completeOnboarding() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
await storage.setOnboardingComplete(true);
|
||||
|
||||
if (mounted) {
|
||||
context.go('/pin-setup');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/crypto/mnemonic.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Obnova účtu ze zálohovací fráze (24 slov). Vrátí IDENTITU (klíč →
|
||||
/// stejný SEP kód a safety number), NE historii zpráv.
|
||||
class RestoreScreen extends ConsumerStatefulWidget {
|
||||
const RestoreScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RestoreScreen> createState() => _RestoreScreenState();
|
||||
}
|
||||
|
||||
class _RestoreScreenState extends ConsumerState<RestoreScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
final _wordsController = TextEditingController();
|
||||
bool _restoring = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_wordsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Rozsekej vstup na slova (mezery, nové řádky, čárky, čísla „1." pryč).
|
||||
List<String> _parseWords(String raw) {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'\d+[.)]?'), ' ')
|
||||
.split(RegExp(r'[\s,]+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
int get _wordCount => _parseWords(_wordsController.text).length;
|
||||
|
||||
Future<void> _restore() async {
|
||||
final name = _nameController.text.trim();
|
||||
final words = _parseWords(_wordsController.text);
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = context.l10n.restoreErrorNameRequired);
|
||||
return;
|
||||
}
|
||||
if (words.length != AppConstants.seedPhraseWords) {
|
||||
setState(() => _error = context.l10n.restoreErrorWordCount(
|
||||
_wordCount, AppConstants.seedPhraseWords));
|
||||
return;
|
||||
}
|
||||
if (!SepotMnemonic.isValid(words)) {
|
||||
setState(() => _error = context.l10n.restoreErrorInvalidPhrase);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_restoring = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref
|
||||
.read(identityProvider.notifier)
|
||||
.recoverIdentity(words, name);
|
||||
await ref.read(storageProvider).setOnboardingComplete(true);
|
||||
if (mounted) context.go('/pin-setup');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_restoring = false;
|
||||
_error =
|
||||
context.l10n.restoreErrorFailed(describeError(context.l10n, e));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ok = _wordCount == AppConstants.seedPhraseWords &&
|
||||
_nameController.text.trim().isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.restoreTitle)),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.restoreHeading,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.restoreIntro(AppConstants.seedPhraseWords),
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Upozornění na historii
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: SepotColors.warning.withValues(alpha: 0.1),
|
||||
border: Border.all(
|
||||
color: SepotColors.warning.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline,
|
||||
size: 20, color: SepotColors.warning),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.restoreHistoryWarning,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.textPrimary.withValues(alpha: .9)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.onboardingDisplayNameLabel,
|
||||
hintText: context.l10n.onboardingDisplayNameHint,
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _wordsController,
|
||||
minLines: 4,
|
||||
maxLines: 8,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.deny(RegExp(r'[^\w\s.,)]')),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.settingsSeedPhraseTitle,
|
||||
hintText: context.l10n.restorePhraseHint,
|
||||
alignLabelWithHint: true,
|
||||
helperText: context.l10n.restoreWordCountHelper(
|
||||
_wordCount, AppConstants.seedPhraseWords),
|
||||
helperStyle: TextStyle(
|
||||
color: _wordCount == AppConstants.seedPhraseWords
|
||||
? SepotColors.sage
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() => _error = null),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!,
|
||||
style: const TextStyle(
|
||||
color: SepotColors.error, fontSize: 13)),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: (ok && !_restoring) ? _restore : null,
|
||||
child: _restoring
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text(context.l10n.restoreTitle),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
|
||||
/// QR skener pro přidání kontaktů
|
||||
class QrScanScreen extends ConsumerStatefulWidget {
|
||||
const QrScanScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QrScanScreen> createState() => _QrScanScreenState();
|
||||
}
|
||||
|
||||
class _QrScanScreenState extends ConsumerState<QrScanScreen> {
|
||||
final _controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.normal,
|
||||
facing: CameraFacing.back,
|
||||
);
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.qrScanTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.flash_on),
|
||||
onPressed: () => _controller.toggleTorch(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cameraswitch),
|
||||
onPressed: () => _controller.switchCamera(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _onDetect,
|
||||
),
|
||||
// Overlay
|
||||
_buildOverlay(),
|
||||
// Spodní panel
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
SepotColors.background.withValues(alpha: 0.9),
|
||||
SepotColors.background,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.qr_code_scanner_rounded,
|
||||
color: SepotColors.ember,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.qrScanInstruction,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: SepotColors.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.qrScanHint,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverlay() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scanSize = constraints.maxWidth * 0.7;
|
||||
final left = (constraints.maxWidth - scanSize) / 2;
|
||||
final top = (constraints.maxHeight - scanSize) / 2 - 40;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Tmavý overlay kolem
|
||||
ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
SepotColors.background.withValues(alpha: 0.5),
|
||||
BlendMode.srcOut,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red, // Jakákoliv neprůhledná barva
|
||||
backgroundBlendMode: BlendMode.dstOut,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: Container(
|
||||
width: scanSize,
|
||||
height: scanSize,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Rámec skenu
|
||||
Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: Container(
|
||||
width: scanSize,
|
||||
height: scanSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: SepotColors.ember,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_isProcessing) return;
|
||||
|
||||
final barcode = capture.barcodes.firstOrNull;
|
||||
if (barcode?.rawValue == null) return;
|
||||
|
||||
final shareString = barcode!.rawValue!;
|
||||
final identity = PublicIdentity.fromShareString(shareString);
|
||||
|
||||
if (identity == null) return;
|
||||
|
||||
setState(() => _isProcessing = true);
|
||||
_controller.stop();
|
||||
|
||||
_showContactConfirmation(identity);
|
||||
}
|
||||
|
||||
void _showContactConfirmation(PublicIdentity identity) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.qrNewContactTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundColor: SepotColors.ember.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
// Naskenovaná identita může mít prázdný displayName
|
||||
// (i podvržený QR); guard proti pádu na substring(0,1).
|
||||
identity.displayName.isNotEmpty
|
||||
? identity.displayName.substring(0, 1).toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
identity.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
identity.sepotId,
|
||||
style: SepotTheme.cryptoText,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.settingsFingerprint(identity.fingerprint.display),
|
||||
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
_controller.start();
|
||||
setState(() => _isProcessing = false);
|
||||
},
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref
|
||||
.read(contactsProvider.notifier)
|
||||
.addContact(identity, verified: true);
|
||||
|
||||
if (ctx.mounted) Navigator.of(ctx).pop();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text(context.l10n.qrContactAdded(identity.displayName)),
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (ctx.mounted) Navigator.of(ctx).pop();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(describeError(context.l10n, e))),
|
||||
);
|
||||
}
|
||||
_controller.start();
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.commonAdd),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
|
||||
/// Sdílení vlastního QR kódu a Šepot ID
|
||||
class QrShareScreen extends ConsumerWidget {
|
||||
const QrShareScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final identityAsync = ref.watch(identityProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.qrShareTitle),
|
||||
),
|
||||
body: identityAsync.when(
|
||||
data: (identity) {
|
||||
if (identity == null) {
|
||||
return Center(child: Text(context.l10n.qrIdentityNotFound));
|
||||
}
|
||||
|
||||
// Sdílet s routingem (accountId + delivery address z relaye),
|
||||
// jinak protistrana nemá kam poslat první zprávu
|
||||
final service = ref.watch(messagingServiceProvider);
|
||||
final shareString = (service.shareableIdentity ??
|
||||
identity.publicIdentity)
|
||||
.toShareString();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
// QR kód
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: shareString,
|
||||
version: QrVersions.auto,
|
||||
size: 240,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
eyeStyle: const QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: Color(0xFF030712),
|
||||
),
|
||||
dataModuleStyle: const QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.circle,
|
||||
color: Color(0xFF030712),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Šepot ID
|
||||
Text(
|
||||
identity.sepotId,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
identity.displayName,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Fingerprint
|
||||
Text(
|
||||
context.l10n.settingsFingerprint(identity.fingerprint.display),
|
||||
style: SepotTheme.cryptoText,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Akce
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: shareString));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text(context.l10n.commonCopiedToClipboard),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: Text(context.l10n.commonCopy),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Share.share(
|
||||
context.l10n.qrShareInviteMessage(shareString),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.share),
|
||||
label: Text(context.l10n.commonShare),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Informace
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: SepotColors.textMuted, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.qrShareInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
error: (e, _) => Center(child: Text(describeError(context.l10n, e))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/// Nahlášení zneužití (Play UGC + CSAE, Apple Guideline 1.2).
|
||||
///
|
||||
/// Do verze 0.2.0 „nahlášení" jen zablokovalo kontakt a nikam nedošlo —
|
||||
/// obchody ale vyžadují cestu, kterou se o problému dozví provozovatel.
|
||||
/// Tahle obrazovka je ta cesta.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/safety/report_service.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
|
||||
class ReportScreen extends StatefulWidget {
|
||||
const ReportScreen({
|
||||
super.key,
|
||||
this.reportedFingerprint,
|
||||
this.reportedName,
|
||||
});
|
||||
|
||||
/// Otisk nahlášeného kontaktu (SEP-…), když se sem přišlo z konverzace.
|
||||
final String? reportedFingerprint;
|
||||
final String? reportedName;
|
||||
|
||||
@override
|
||||
State<ReportScreen> createState() => _ReportScreenState();
|
||||
}
|
||||
|
||||
class _ReportScreenState extends State<ReportScreen> {
|
||||
final _popis = TextEditingController();
|
||||
final _kontakt = TextEditingController();
|
||||
|
||||
ReportCategory _kategorie = ReportCategory.harassment;
|
||||
bool _prilozitOtisk = true;
|
||||
bool _odesila = false;
|
||||
bool _hotovo = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_popis.dispose();
|
||||
_kontakt.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _maKontakt =>
|
||||
widget.reportedFingerprint != null &&
|
||||
widget.reportedFingerprint!.isNotEmpty;
|
||||
|
||||
Future<void> _odeslat() async {
|
||||
setState(() => _odesila = true);
|
||||
try {
|
||||
await const ReportService().submit(
|
||||
category: _kategorie,
|
||||
description: _popis.text,
|
||||
reportedFingerprint:
|
||||
_maKontakt && _prilozitOtisk ? widget.reportedFingerprint : null,
|
||||
replyTo: _kontakt.text,
|
||||
);
|
||||
if (mounted) setState(() => _hotovo = true);
|
||||
} on SepotUserError catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _odesila = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(describeError(context.l10n, e)),
|
||||
backgroundColor: SepotColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.reportTitle)),
|
||||
body: _hotovo ? _uspech() : _formular(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _uspech() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline,
|
||||
size: 56, color: SepotColors.sage),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.l10n.reportSubmitted,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_kategorie.isChildSafety
|
||||
? context.l10n.reportSubmittedChildSafety
|
||||
: context.l10n.reportSubmittedThanks,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
FilledButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(context.l10n.commonDone),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _formular() {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 40),
|
||||
children: [
|
||||
_panel(
|
||||
icon: Icons.lock_outline,
|
||||
barva: SepotColors.mist,
|
||||
text: context.l10n.reportPanelE2eNotice,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_kategorie.isChildSafety) ...[
|
||||
_panel(
|
||||
icon: Icons.gpp_maybe_outlined,
|
||||
barva: SepotColors.error,
|
||||
text: context.l10n.reportPanelCsamWarning,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
_panel(
|
||||
icon: Icons.public,
|
||||
barva: SepotColors.warning,
|
||||
text: context.l10n.reportPanelIpNotice,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_nadpis(context.l10n.reportCategoryHeading),
|
||||
...ReportCategory.values.map(_volba),
|
||||
const SizedBox(height: 20),
|
||||
_nadpis(context.l10n.reportDescriptionHeading),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _popis,
|
||||
maxLines: 6,
|
||||
maxLength: 4000,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
hintText: _hintKategorie(context.l10n, _kategorie),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (_maKontakt) ...[
|
||||
const SizedBox(height: 4),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _prilozitOtisk,
|
||||
activeTrackColor: SepotColors.ember,
|
||||
onChanged: (v) => setState(() => _prilozitOtisk = v),
|
||||
title: Text(
|
||||
widget.reportedName == null
|
||||
? context.l10n.reportAttachContactId
|
||||
: context.l10n
|
||||
.reportAttachContactIdNamed(widget.reportedName!),
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 14,
|
||||
color: SepotColors.textPrimary),
|
||||
),
|
||||
subtitle: Text(
|
||||
widget.reportedFingerprint!,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
fontSize: 12,
|
||||
color: SepotColors.textMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_nadpis(context.l10n.reportReplyToHeading),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _kontakt,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(200)],
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
hintText: context.l10n.reportReplyToHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
FilledButton(
|
||||
onPressed: _odesila ? null : _odeslat,
|
||||
child: _odesila
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(context.l10n.reportSubmitButton),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse(AppConstants.childSafetyUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
child: Text(context.l10n.settingsChildSafetyTitle),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _labelKategorie(AppLocalizations l10n, ReportCategory k) =>
|
||||
switch (k) {
|
||||
ReportCategory.childSafety => l10n.reportCategoryChildSafety,
|
||||
ReportCategory.harassment => l10n.reportCategoryHarassment,
|
||||
ReportCategory.spam => l10n.reportCategorySpam,
|
||||
ReportCategory.fraud => l10n.reportCategoryFraud,
|
||||
ReportCategory.other => l10n.reportCategoryOther,
|
||||
};
|
||||
|
||||
String _hintKategorie(AppLocalizations l10n, ReportCategory k) =>
|
||||
switch (k) {
|
||||
ReportCategory.childSafety => l10n.reportCategoryChildSafetyHint,
|
||||
ReportCategory.harassment => l10n.reportCategoryHarassmentHint,
|
||||
ReportCategory.spam => l10n.reportCategorySpamHint,
|
||||
ReportCategory.fraud => l10n.reportCategoryFraudHint,
|
||||
ReportCategory.other => l10n.reportCategoryOtherHint,
|
||||
};
|
||||
|
||||
Widget _nadpis(String t) => Text(
|
||||
t,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: SepotColors.textMuted,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _volba(ReportCategory k) {
|
||||
final vybrano = k == _kategorie;
|
||||
return InkWell(
|
||||
onTap: () => setState(() => _kategorie = k),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
vybrano ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
size: 20,
|
||||
color: vybrano ? SepotColors.ember : SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_labelKategorie(context.l10n, k),
|
||||
style: TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 15,
|
||||
fontWeight: vybrano ? FontWeight.w600 : FontWeight.w400,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_hintKategorie(context.l10n, k),
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
color: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _panel({
|
||||
required IconData icon,
|
||||
required Color barva,
|
||||
required String text,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: barva),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/// Přepínač biometrického odemčení.
|
||||
///
|
||||
/// Zapnutí = kopie DEK do OS keystore (TEE) — vědomé změkčení čistého
|
||||
/// PIN-only modelu (DEK jinak existuje jen za Argon2id) výměnou za
|
||||
/// pohodlí. Vyžaduje nastavený PIN a odemčené úložiště; enrolment se
|
||||
/// potvrzuje systémovou biometrickou výzvou.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
class BiometricsTile extends ConsumerStatefulWidget {
|
||||
const BiometricsTile({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BiometricsTile> createState() => _BiometricsTileState();
|
||||
}
|
||||
|
||||
class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
||||
bool _enabled = false;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.read(storageProvider).isBiometricsEnabled().then((v) {
|
||||
if (mounted) setState(() => _enabled = v);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
if (_busy) return;
|
||||
setState(() => _busy = true);
|
||||
final l10n = context.l10n;
|
||||
final storage = ref.read(storageProvider);
|
||||
try {
|
||||
if (value) {
|
||||
if (!await storage.hasPin()) {
|
||||
_snack(l10n.biometricsPinRequiredFirst);
|
||||
return;
|
||||
}
|
||||
if (!storage.isUnlocked) {
|
||||
_snack(l10n.biometricsStorageLocked);
|
||||
return;
|
||||
}
|
||||
final auth = LocalAuthentication();
|
||||
if (!await auth.isDeviceSupported()) {
|
||||
_snack(l10n.biometricsNotAvailable);
|
||||
return;
|
||||
}
|
||||
final ok = await auth.authenticate(
|
||||
localizedReason: l10n.biometricsEnrollPromptReason,
|
||||
options: const AuthenticationOptions(stickyAuth: true),
|
||||
);
|
||||
if (!ok) return;
|
||||
await storage.enableBiometrics();
|
||||
if (mounted) setState(() => _enabled = true);
|
||||
} else {
|
||||
await storage.disableBiometrics();
|
||||
if (mounted) setState(() => _enabled = false);
|
||||
}
|
||||
} catch (e) {
|
||||
_snack(l10n.biometricsSetupFailed(describeError(l10n, e)));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String text) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: SepotColors.surfaceVariant,
|
||||
),
|
||||
child: const Icon(Icons.fingerprint,
|
||||
size: 20, color: SepotColors.textSecondary),
|
||||
),
|
||||
title: Text(context.l10n.biometricsTileTitle,
|
||||
style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
context.l10n.biometricsTileSubtitle,
|
||||
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
||||
),
|
||||
trailing: Switch(
|
||||
value: _enabled,
|
||||
onChanged: _busy ? null : _toggle,
|
||||
activeTrackColor: SepotColors.ember,
|
||||
),
|
||||
onTap: () => _toggle(!_enabled),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/locale_provider.dart';
|
||||
|
||||
/// Volba jazyka aplikace: podle systému, nebo ruční přepnutí.
|
||||
///
|
||||
/// Názvy jazyků se záměrně nepřekládají — každý je ve své nativní podobě,
|
||||
/// aby uživatel našel svůj jazyk i v cizím rozhraní.
|
||||
const _jazyky = <String, String>{
|
||||
'cs': 'Čeština',
|
||||
'en': 'English',
|
||||
'de': 'Deutsch',
|
||||
'ru': 'Русский',
|
||||
'it': 'Italiano',
|
||||
};
|
||||
|
||||
class LanguageSettingsTile extends ConsumerWidget {
|
||||
const LanguageSettingsTile({super.key});
|
||||
|
||||
Future<void> _pick(BuildContext context, WidgetRef ref) async {
|
||||
final current = ref.read(localeProvider).valueOrNull?.languageCode;
|
||||
final l10n = context.l10n;
|
||||
|
||||
final picked = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: Text(l10n.settingsLanguage),
|
||||
children: [
|
||||
RadioGroup<String>(
|
||||
groupValue: current ?? '',
|
||||
onChanged: (value) => Navigator.pop(ctx, value),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
value: '',
|
||||
title: Text(l10n.languageSystem),
|
||||
),
|
||||
for (final entry in _jazyky.entries)
|
||||
RadioListTile<String>(
|
||||
value: entry.key,
|
||||
title: Text(entry.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
await ref
|
||||
.read(localeProvider.notifier)
|
||||
.set(picked.isEmpty ? null : Locale(picked));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final code = ref.watch(localeProvider).valueOrNull?.languageCode;
|
||||
final subtitle = _jazyky[code] ?? context.l10n.languageSystem;
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: SepotColors.surfaceVariant,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.translate,
|
||||
size: 20,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
title: Text(context.l10n.settingsLanguage,
|
||||
style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
||||
),
|
||||
onTap: () => _pick(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
|
||||
/// Nastavení push notifikací — degooglovaný UnifiedPush.
|
||||
/// Zapnutí = výběr distributora (ntfy) + registrace endpointu u relaye.
|
||||
/// Budíček nenese obsah ani odesílatele; při probuzení se appka připojí
|
||||
/// a vyzvedne frontu.
|
||||
class PushSettingsTile extends ConsumerStatefulWidget {
|
||||
const PushSettingsTile({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PushSettingsTile> createState() => _PushSettingsTileState();
|
||||
}
|
||||
|
||||
class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
String _mode = 'off';
|
||||
String? _distributor;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
final push = ref.read(pushServiceProvider);
|
||||
final mode = await push.currentMode();
|
||||
final d = await push.currentDistributor();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mode = mode;
|
||||
_distributor = d;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _chooseMode() async {
|
||||
final picked = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: Text(ctx.l10n.pushTitle),
|
||||
children: [
|
||||
_modeOption(ctx, 'off', ctx.l10n.commonOff, ctx.l10n.pushModeOffDesc),
|
||||
_modeOption(ctx, 'up', ctx.l10n.pushModeUp, ctx.l10n.pushModeUpDesc),
|
||||
_modeOption(
|
||||
ctx, 'fcm', ctx.l10n.pushModeFcm, ctx.l10n.pushModeFcmDesc),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (picked == null || picked == _mode) return;
|
||||
|
||||
final push = ref.read(pushServiceProvider);
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
switch (picked) {
|
||||
case 'off':
|
||||
await push.setModeOff();
|
||||
case 'up':
|
||||
final ok = await push.setModeUnifiedPush();
|
||||
if (!ok && mounted) await _pickDistributor();
|
||||
case 'fcm':
|
||||
await push.setModeFcm();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n
|
||||
.pushSetupFailed(describeError(context.l10n, e)))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await _refresh();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _modeOption(
|
||||
BuildContext ctx, String value, String title, String subtitle) {
|
||||
return SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(ctx, value),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
value == _mode
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_off,
|
||||
size: 18,
|
||||
color: value == _mode
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: SepotColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDistributor() async {
|
||||
final push = ref.read(pushServiceProvider);
|
||||
final list = await push.distributors();
|
||||
if (!mounted) return;
|
||||
|
||||
if (list.isEmpty) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.pushNoDistributorTitle),
|
||||
content: Text(ctx.l10n.pushNoDistributorBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(ctx.l10n.commonOk),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final chosen = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: Text(ctx.l10n.pushPickDistributorTitle),
|
||||
children: [
|
||||
for (final d in list)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(ctx, d),
|
||||
child: Text(d),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (chosen != null) {
|
||||
await push.useDistributor(chosen);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = switch (_mode) {
|
||||
'up' => context.l10n.pushSubtitleUnifiedPush(
|
||||
_distributor ?? context.l10n.pushNoDistributorFallback),
|
||||
'fcm' => context.l10n.pushSubtitleFcm,
|
||||
_ => context.l10n.commonOff,
|
||||
};
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: SepotColors.surfaceVariant,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.notifications_outlined,
|
||||
size: 20,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
title: Text(context.l10n.pushTitle,
|
||||
style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
||||
),
|
||||
trailing: _busy
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.chevron_right, color: SepotColors.textMuted),
|
||||
onTap: _busy ? null : _chooseMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/providers/settings_provider.dart';
|
||||
import 'package:sepot/features/settings/biometrics_tile.dart';
|
||||
import 'package:sepot/features/settings/language_settings_tile.dart';
|
||||
import 'package:sepot/features/settings/push_settings_tile.dart';
|
||||
import 'package:sepot/features/settings/tor_settings_tile.dart';
|
||||
import 'package:sepot/widgets/sepot_logo.dart';
|
||||
|
||||
/// Nastavení aplikace
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final identityAsync = ref.watch(identityProvider);
|
||||
final l10n = context.l10n;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsTitle),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Profil
|
||||
identityAsync.when(
|
||||
data: (identity) {
|
||||
if (identity == null) return const SizedBox.shrink();
|
||||
return _buildProfileCard(context, identity);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Obecné
|
||||
_buildSectionHeader(l10n.settingsSectionGeneral),
|
||||
const LanguageSettingsTile(),
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Zabezpečení
|
||||
_buildSectionHeader(l10n.settingsSectionSecurity),
|
||||
_buildTile(
|
||||
icon: Icons.pin_outlined,
|
||||
title: l10n.settingsChangePinTitle,
|
||||
subtitle: l10n.settingsChangePinSubtitle,
|
||||
onTap: () => context.push('/change-pin'),
|
||||
),
|
||||
const BiometricsTile(),
|
||||
_buildTile(
|
||||
icon: Icons.timer_outlined,
|
||||
title: l10n.settingsAutoLockTitle,
|
||||
subtitle: _autoLockLabel(
|
||||
l10n,
|
||||
ref.watch(autoLockProvider).valueOrNull ??
|
||||
AutoLockNotifier.vychozi,
|
||||
),
|
||||
onTap: () => _nastavAutoLock(context, ref),
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.password_outlined,
|
||||
title: l10n.settingsDecoyPinTitle,
|
||||
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
|
||||
? l10n.settingsDecoyPinSetSubtitle
|
||||
: l10n.settingsDecoyPinUnsetSubtitle,
|
||||
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
|
||||
? TextButton(
|
||||
onPressed: () => _disableDecoyPin(context, ref),
|
||||
child: Text(l10n.commonCancel),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
|
||||
_setDecoyPin(context, ref);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Notifikace
|
||||
_buildSectionHeader(l10n.settingsSectionNotifications),
|
||||
const PushSettingsTile(),
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Připojení — Tor je přes Orbot (Android). Na iOS zatím
|
||||
// není cesta k lokální SOCKS proxy, tak dlaždici skryjeme.
|
||||
if (Platform.isAndroid) ...[
|
||||
_buildSectionHeader(l10n.settingsSectionConnection),
|
||||
const TorSettingsTile(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Sekce Soukromí
|
||||
// Mizící zprávy se nastavují per konverzaci (menu ⋮), globální
|
||||
// dlaždice tu byla mrtvá — odebrána místo předstírání funkce.
|
||||
_buildSectionHeader(l10n.settingsSectionPrivacy),
|
||||
_buildTile(
|
||||
icon: Icons.done_all,
|
||||
title: l10n.settingsReadReceiptsTitle,
|
||||
subtitle: l10n.settingsReadReceiptsSubtitle,
|
||||
trailing: Switch(
|
||||
value: ref.watch(readReceiptsProvider).valueOrNull ?? false,
|
||||
onChanged: (v) =>
|
||||
ref.read(readReceiptsProvider.notifier).set(v),
|
||||
activeTrackColor: SepotColors.ember,
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.screenshot_monitor_outlined,
|
||||
title: l10n.settingsScreenSecurityTitle,
|
||||
subtitle: l10n.settingsScreenSecuritySubtitle,
|
||||
trailing: Switch(
|
||||
value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
|
||||
onChanged: (v) =>
|
||||
ref.read(screenSecurityProvider.notifier).set(v),
|
||||
activeTrackColor: SepotColors.ember,
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Záloha
|
||||
_buildSectionHeader(l10n.settingsSectionBackup),
|
||||
_buildTile(
|
||||
icon: Icons.key_outlined,
|
||||
title: l10n.settingsSeedPhraseTitle,
|
||||
subtitle: l10n.settingsSeedPhraseSubtitle,
|
||||
onTap: () => _showSeedPhrase(context, ref),
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.backup_outlined,
|
||||
title: l10n.settingsBackupExportTitle,
|
||||
subtitle: l10n.settingsBackupExportSubtitle,
|
||||
onTap: () => _exportBackup(context, ref),
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.restore_outlined,
|
||||
title: l10n.settingsBackupImportTitle,
|
||||
subtitle: l10n.settingsBackupImportSubtitle,
|
||||
onTap: () => _importBackup(context, ref),
|
||||
),
|
||||
// „Web klient" byl slib funkce, která neexistuje (multi-device je
|
||||
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Bezpečnost — Play UGC/CSAE policy, Apple Guideline 1.2.
|
||||
// Nahlášení musí být dostupné i mimo konkrétní konverzaci.
|
||||
_buildSectionHeader(l10n.settingsSectionSafety),
|
||||
_buildTile(
|
||||
icon: Icons.flag_outlined,
|
||||
title: l10n.settingsReportAbuseTitle,
|
||||
subtitle: l10n.settingsReportAbuseSubtitle,
|
||||
onTap: () => context.push('/report'),
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.shield_outlined,
|
||||
title: l10n.settingsChildSafetyTitle,
|
||||
subtitle: l10n.settingsChildSafetySubtitle,
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse(AppConstants.childSafetyUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Sekce O aplikaci
|
||||
_buildSectionHeader(l10n.settingsSectionAbout),
|
||||
_buildTile(
|
||||
icon: Icons.info_outline,
|
||||
title: l10n.commonAppName,
|
||||
subtitle: l10n.settingsVersion(AppConstants.appVersion),
|
||||
onTap: () => _showAbout(context),
|
||||
),
|
||||
// Zdrojový kód je veřejný (AGPL-3.0). Detaily o licenci a odkaz
|
||||
// na repozitář dává dlaždice níže.
|
||||
_buildTile(
|
||||
icon: Icons.gavel_outlined,
|
||||
title: l10n.settingsLicenseTitle,
|
||||
subtitle: l10n.settingsLicenseSubtitle,
|
||||
onTap: () => _zobrazLicenci(context),
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
title: l10n.settingsPrivacyPolicyTitle,
|
||||
subtitle: l10n.settingsPrivacyPolicySubtitle,
|
||||
onTap: () => _otevri(AppConstants.privacyUrl),
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: l10n.settingsTermsTitle,
|
||||
subtitle: l10n.settingsTermsSubtitle,
|
||||
onTap: () => _otevri(AppConstants.termsUrl),
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.delete_outline,
|
||||
title: l10n.settingsDataDeletionTitle,
|
||||
subtitle: l10n.settingsDataDeletionSubtitle,
|
||||
onTap: () => _otevri(AppConstants.deletionUrl),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Smazat účet
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _confirmDeleteAccount(context, ref),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: SepotColors.error,
|
||||
side: const BorderSide(color: SepotColors.error),
|
||||
),
|
||||
child: Text(l10n.settingsDeleteAllDataButton),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _otevri(String url) => launchUrl(
|
||||
Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
/// Popisek intervalu auto-zámku pro daný počet sekund.
|
||||
String _autoLockLabel(AppLocalizations l10n, int sekundy) {
|
||||
switch (sekundy) {
|
||||
case 0:
|
||||
return l10n.settingsAutoLockNever;
|
||||
case 60:
|
||||
return l10n.settingsAutoLock1min;
|
||||
case 900:
|
||||
return l10n.settingsAutoLock15min;
|
||||
default:
|
||||
return l10n.settingsAutoLock5min;
|
||||
}
|
||||
}
|
||||
|
||||
/// AGPL-3.0 § 6 chce spolu s binárkou i nabídku zdrojového kódu.
|
||||
/// Zdroj je veřejný (repozitář, F-Droid, APK); tenhle text na něj odkazuje.
|
||||
void _zobrazLicenci(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.settingsLicenseTitle),
|
||||
content: Text(
|
||||
ctx.l10n.settingsLicenseBody(AppConstants.supportEmail),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(ctx.l10n.commonClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _nastavAutoLock(BuildContext context, WidgetRef ref) async {
|
||||
final ted = ref.read(autoLockProvider).valueOrNull ?? AutoLockNotifier.vychozi;
|
||||
final vybrano = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: Text(ctx.l10n.settingsAutoLockTitle),
|
||||
children: [
|
||||
for (final sekundy in AutoLockNotifier.moznostiSekundy)
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(ctx, sekundy),
|
||||
leading: Icon(
|
||||
sekundy == ted
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_off,
|
||||
color: sekundy == ted
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
title: Text(_autoLockLabel(ctx.l10n, sekundy)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (vybrano != null && vybrano != ted) {
|
||||
await ref.read(autoLockProvider.notifier).set(vybrano);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileCard(BuildContext context, dynamic identity) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: SepotColors.surface,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 36,
|
||||
backgroundColor: SepotColors.ember.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
identity.displayName.substring(0, 1).toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
identity.displayName,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: identity.sepotId));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.settingsIdCopied)),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
identity.sepotId,
|
||||
style: SepotTheme.idText,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.copy, size: 14, color: SepotColors.ember),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.settingsFingerprint(identity.fingerprint),
|
||||
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textMuted,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
Widget? trailing,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: SepotColors.surfaceVariant,
|
||||
),
|
||||
child: Icon(icon, size: 20, color: SepotColors.textSecondary),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
||||
),
|
||||
trailing: trailing ?? const Icon(Icons.chevron_right, color: SepotColors.textMuted),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportBackup(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
try {
|
||||
final bytes = await ref.read(backupServiceProvider).export(identity);
|
||||
final dir = await getTemporaryDirectory();
|
||||
final path = '${dir.path}/sepot-zaloha.sepotbackup';
|
||||
await File(path).writeAsBytes(bytes);
|
||||
await Share.shareXFiles(
|
||||
[XFile(path, mimeType: 'application/octet-stream')],
|
||||
subject: l10n.backupShareSubject,
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupExportFailed(describeError(l10n, e)))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importBackup(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
try {
|
||||
final res = await FilePicker.pickFiles(withData: true);
|
||||
final data = res?.files.firstOrNull?.bytes;
|
||||
if (data == null) return;
|
||||
final n = await ref.read(backupServiceProvider).import(identity, data);
|
||||
ref.invalidate(conversationsProvider);
|
||||
ref.invalidate(contactsProvider);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupRestoredCount(n))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.backupImportFailed(describeError(l10n, e)))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
final pinCtrl = TextEditingController();
|
||||
final confirmCtrl = TextEditingController();
|
||||
const numberField = TextInputType.number;
|
||||
final pin = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.settingsDecoyPinTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
ctx.l10n.settingsDecoyPinExplain,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: pinCtrl,
|
||||
obscureText: true,
|
||||
keyboardType: numberField,
|
||||
maxLength: 6,
|
||||
decoration: InputDecoration(
|
||||
labelText: ctx.l10n.settingsDecoyPinTitle, counterText: ''),
|
||||
),
|
||||
TextField(
|
||||
controller: confirmCtrl,
|
||||
obscureText: true,
|
||||
keyboardType: numberField,
|
||||
maxLength: 6,
|
||||
decoration: InputDecoration(
|
||||
labelText: ctx.l10n.commonConfirm, counterText: ''),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(ctx.l10n.commonCancel)),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (pinCtrl.text.length == 6 &&
|
||||
pinCtrl.text == confirmCtrl.text) {
|
||||
Navigator.pop(ctx, pinCtrl.text);
|
||||
}
|
||||
},
|
||||
child: Text(ctx.l10n.commonSet),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (pin == null) return;
|
||||
try {
|
||||
await ref.read(storageProvider).enableDecoyPin(pin);
|
||||
ref.invalidate(hasDecoyPinProvider);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.settingsDecoyPinEnabled)),
|
||||
);
|
||||
}
|
||||
} on DecoyPinConflict {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.settingsDecoyPinConflict)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(describeError(l10n, e))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
await ref.read(storageProvider).disableDecoyPin();
|
||||
ref.invalidate(hasDecoyPinProvider);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.settingsDecoyPinDisabled)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSeedPhrase(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
final storage = ref.read(storageProvider);
|
||||
final seed = await storage.loadSeedPhrase();
|
||||
|
||||
if (seed == null) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.settingsSeedPhraseNotFound)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.settingsSeedPhraseTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
ctx.l10n.settingsSeedPhraseWarning,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.warning,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(seed.length, (i) {
|
||||
return Chip(
|
||||
label: Text(
|
||||
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
|
||||
style: SepotTheme.cryptoText,
|
||||
),
|
||||
backgroundColor: SepotColors.surfaceVariant,
|
||||
side: const BorderSide(color: SepotColors.border),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(ctx.l10n.commonClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAbout(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SepotLogo(size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
ctx.l10n.settingsAboutTagline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
ctx.l10n.settingsVersion(AppConstants.appVersion),
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
ctx.l10n.settingsAboutBody,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(ctx.l10n.commonClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDeleteAccount(BuildContext context, WidgetRef ref) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.settingsDeleteAccountTitle),
|
||||
content: Text(
|
||||
ctx.l10n.settingsDeleteAccountBody,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(ctx.l10n.commonCancel),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final messaging = ref.read(messagingServiceProvider);
|
||||
// Serverová deregistrace PŘED lokálním wipe — podpis
|
||||
// požadavku potřebuje identity klíč, který wipe zahodí.
|
||||
final serverOk = await messaging.deleteRelayAccount();
|
||||
// Messaging drží referenci na DB — zastavit PŘED smazáním
|
||||
await messaging.stop();
|
||||
await ref.read(storageProvider).deleteAll();
|
||||
if (ctx.mounted) Navigator.of(ctx).pop();
|
||||
if (context.mounted) {
|
||||
if (!serverOk) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.settingsDeleteAccountServerOffline,
|
||||
),
|
||||
duration: const Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
context.go('/onboarding');
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: SepotColors.error,
|
||||
),
|
||||
child: Text(ctx.l10n.settingsDeleteAllConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/network/relay_client.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/settings_provider.dart';
|
||||
|
||||
/// Přepínač routování přes Tor (SOCKS5/Orbot na `.onion`).
|
||||
///
|
||||
/// Zapnutí skryje IP klienta před relayem — provoz jde přes lokální
|
||||
/// Orbot. Bez běžícího Orbotu se spojení nenaváže (indikátor zůstane
|
||||
/// „připojuji"), na což uživatele upozorníme předem. Přepnutí přestaví
|
||||
/// spojení za běhu, restart appky netřeba.
|
||||
class TorSettingsTile extends ConsumerStatefulWidget {
|
||||
const TorSettingsTile({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TorSettingsTile> createState() => _TorSettingsTileState();
|
||||
}
|
||||
|
||||
class _TorSettingsTileState extends ConsumerState<TorSettingsTile> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _toggle(bool value) async {
|
||||
if (value && !await _confirmOrbot()) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ref.read(torProvider.notifier).set(value);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.torToggleFailed(describeError(context.l10n, e)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tor přes Orbot potřebuje běžící Orbot s aktivním SOCKS proxy.
|
||||
Future<bool> _confirmOrbot() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.torConnectionTitle),
|
||||
content: Text(ctx.l10n.torDialogBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(ctx.l10n.commonCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(ctx.l10n.commonTurnOn),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = ref.watch(torProvider).valueOrNull ?? false;
|
||||
|
||||
// Stav připojení má smysl ukazovat jen když je Tor zapnutý — pomůže
|
||||
// odhalit chybějící/vypnutý Orbot (uvázne na „připojuji").
|
||||
String subtitle;
|
||||
if (!enabled) {
|
||||
subtitle = context.l10n.torSubtitleOff;
|
||||
} else {
|
||||
final conn = ref.watch(relayConnectionProvider).valueOrNull;
|
||||
subtitle = switch (conn) {
|
||||
RelayConnectionState.connected => context.l10n.torStatusConnected,
|
||||
RelayConnectionState.connecting => context.l10n.torStatusConnecting,
|
||||
_ => context.l10n.torStatusWaiting,
|
||||
};
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: SepotColors.surfaceVariant,
|
||||
),
|
||||
child: Icon(
|
||||
enabled ? Icons.vpn_lock : Icons.public,
|
||||
size: 20,
|
||||
color: enabled ? SepotColors.ember : SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.torConnectionTitle,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
||||
),
|
||||
trailing: _busy
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
)
|
||||
: Switch(
|
||||
value: enabled,
|
||||
onChanged: _toggle,
|
||||
activeTrackColor: SepotColors.ember,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,352 @@
|
||||
{
|
||||
"@@locale": "de",
|
||||
"appTitle": "Šepot",
|
||||
"settingsLanguage": "App-Sprache",
|
||||
"languageSystem": "Systemstandard",
|
||||
"commonUnknownContact": "Unbekannt",
|
||||
"verifyVerified": "Verifiziert",
|
||||
"verifyUnverified": "Nicht verifiziert",
|
||||
"chatMenuVerifyContact": "Kontakt verifizieren",
|
||||
"chatDisappearingMessages": "Verschwindende Nachrichten",
|
||||
"chatMenuUnblockContact": "Blockierung aufheben",
|
||||
"chatMenuBlockContact": "Kontakt blockieren",
|
||||
"reportAndBlock": "Melden und blockieren",
|
||||
"errorGeneric": "Fehler: {error}",
|
||||
"groupsYouLeftGroup": "Du bist nicht mehr in der Gruppe",
|
||||
"groupsMemberCount": "{count, plural, one{1 Mitglied} other{{count} Mitglieder}}",
|
||||
"groupsMembers": "Mitglieder",
|
||||
"groupsRename": "Gruppe umbenennen",
|
||||
"groupsLeave": "Gruppe verlassen",
|
||||
"groupsLeftBanner": "Du bist nicht mehr in der Gruppe. Der Verlauf bleibt, neue Nachrichten kommen nicht mehr an.",
|
||||
"groupsMembersWithCount": "Mitglieder ({count})",
|
||||
"groupsYou": "Du",
|
||||
"groupsFounder": "Hat die Gruppe gegründet",
|
||||
"groupsNameHint": "Gruppenname",
|
||||
"commonCancel": "Abbrechen",
|
||||
"commonSave": "Speichern",
|
||||
"groupsRenameFailed": "Umbenennen fehlgeschlagen: {error}",
|
||||
"groupsLeaveConfirmTitle": "Gruppe verlassen?",
|
||||
"groupsLeaveConfirmBody": "Die anderen sehen, dass du nicht mehr in der Gruppe bist. Der Verlauf bleibt dir erhalten, aber du bekommst keine neuen Nachrichten mehr und musst neu eingeladen werden.",
|
||||
"groupsLeaveAction": "Verlassen",
|
||||
"groupsLeaveFailed": "Verlassen fehlgeschlagen: {error}",
|
||||
"chatBlockedBanner": "Der Kontakt ist blockiert. Du bekommst keine Nachrichten von ihm und kannst ihm nicht schreiben.",
|
||||
"chatUnblock": "Blockierung aufheben",
|
||||
"chatContactBlocked": "Kontakt {name} wurde blockiert",
|
||||
"chatContactUnblocked": "Blockierung von {name} aufgehoben",
|
||||
"reportAndBlockBody": "Wir blockieren den Kontakt {name} und du bekommst keine Nachrichten mehr von ihm. Danach zeigen wir dir ein Formular, mit dem du uns mitteilst, was passiert ist — wir sehen den Inhalt der Nachrichten nicht und erfahren ohne deine Beschreibung nichts davon.",
|
||||
"chatEmptyEncrypted": "Nachrichten sind Ende-zu-Ende-verschlüsselt.",
|
||||
"chatEmptyNoOneElse": "Niemand sonst kann sie lesen.",
|
||||
"commonToday": "Heute",
|
||||
"commonYesterday": "Gestern",
|
||||
"chatMessageHint": "Nachricht schreiben…",
|
||||
"chatDiscardRecording": "Verwerfen",
|
||||
"chatRecording": "Aufnahme läuft…",
|
||||
"chatMicPermissionMissing": "Mikrofonberechtigung fehlt",
|
||||
"chatRecordingFailed": "Aufnahme fehlgeschlagen: {error}",
|
||||
"chatVoiceSendFailed": "Senden der Sprachnachricht fehlgeschlagen: {error}",
|
||||
"chatDeletedMessagePreview": "Gelöschte Nachricht",
|
||||
"chatVoiceMessagePreview": "🎤 Sprachnachricht",
|
||||
"chatPhotoPreview": "📷 Foto",
|
||||
"chatAttachmentPreview": "📎 Anhang",
|
||||
"chatEditingMessage": "Nachricht bearbeiten",
|
||||
"chatReply": "Antwort",
|
||||
"chatActionReply": "Antworten",
|
||||
"commonCopy": "Kopieren",
|
||||
"commonEdit": "Bearbeiten",
|
||||
"chatDeleteForMe": "Für mich löschen",
|
||||
"chatDeleteForEveryone": "Für alle löschen",
|
||||
"commonOff": "Aus",
|
||||
"chatDisappearing30s": "30 Sekunden",
|
||||
"chatDisappearing5m": "5 Minuten",
|
||||
"chatDisappearing1h": "1 Stunde",
|
||||
"chatDisappearing1d": "1 Tag",
|
||||
"chatDisappearing1w": "1 Woche",
|
||||
"chatDisappearingDescription": "Neue Nachrichten werden nach Ablauf der Zeit bei dir und beim Gegenüber gelöscht.",
|
||||
"chatDisappearingDisabled": "Verschwindende Nachrichten deaktiviert",
|
||||
"chatDisappearingEnabled": "Neue Nachrichten verschwinden nach: {duration}",
|
||||
"attachmentGallery": "Foto aus der Galerie",
|
||||
"attachmentTakePhoto": "Foto aufnehmen",
|
||||
"attachmentDocument": "Dokument",
|
||||
"attachmentTooLarge": "Datei ist zu groß (max. {maxMb} MB)",
|
||||
"attachmentPickFailed": "Auswahl des Anhangs fehlgeschlagen: {error}",
|
||||
"attachmentSendFailed": "Senden des Anhangs fehlgeschlagen: {error}",
|
||||
"chatSendFailed": "Senden fehlgeschlagen: {error}",
|
||||
"chatMessageDeleted": "Nachricht gelöscht",
|
||||
"chatEdited": "bearbeitet",
|
||||
"errorTrustRootChanged": "Der Trust Root des Relays hat sich geändert! Kommunikation wird verweigert.",
|
||||
"errorRelayUnavailable": "Relay nicht erreichbar (Sender-Zertifikat)",
|
||||
"errorAttachmentTooLarge": "Anhang ist zu groß (max. {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "Die Gruppe braucht mindestens 1 Mitglied",
|
||||
"errorGroupMaxMembers": "Die Gruppe kann höchstens {maxMembers} Mitglieder haben",
|
||||
"errorMemberNoDeliveryAddress": "{displayName} hat keine Zustelladresse — frag nach einem neuen Link",
|
||||
"errorContactNoDeliveryAddress": "Der Kontakt hat keine Zustelladresse — bitte ihn um einen neuen sepot://-Link",
|
||||
"errorIdentityKeyMismatch": "Der Identitätsschlüssel vom Relay passt NICHT zum Kontakt — möglicher MITM-Angriff! Nachricht nicht gesendet.",
|
||||
"settingsTitle": "Einstellungen",
|
||||
"settingsSectionGeneral": "Allgemein",
|
||||
"settingsSectionSecurity": "Sicherheit",
|
||||
"settingsChangePinTitle": "PIN ändern",
|
||||
"settingsChangePinSubtitle": "6-stellige PIN zum Entsperren",
|
||||
"settingsAutoLockTitle": "Automatische Sperre",
|
||||
"settingsAutoLock5min": "Nach 5 Minuten",
|
||||
"settingsDecoyPinTitle": "Tarn-PIN",
|
||||
"settingsDecoyPinSetSubtitle": "Eingerichtet — öffnet eine leere App",
|
||||
"settingsDecoyPinUnsetSubtitle": "Zweite PIN, die eine leere App öffnet",
|
||||
"settingsSectionNotifications": "Benachrichtigungen",
|
||||
"settingsSectionConnection": "Verbindung",
|
||||
"settingsSectionPrivacy": "Privatsphäre",
|
||||
"settingsReadReceiptsTitle": "Lesebestätigungen",
|
||||
"settingsReadReceiptsSubtitle": "Dem Gegenüber anzeigen, dass Nachrichten gelesen wurden (standardmäßig aus)",
|
||||
"settingsScreenSecurityTitle": "Bildschirmsicherheit",
|
||||
"settingsScreenSecuritySubtitle": "Screenshots blockieren und Inhalt in der App-Übersicht verbergen",
|
||||
"settingsSectionBackup": "Schlüssel-Backup",
|
||||
"settingsSeedPhraseTitle": "Wiederherstellungsphrase",
|
||||
"settingsSeedPhraseSubtitle": "24 Wörter zur Wiederherstellung deiner Identität",
|
||||
"settingsBackupExportTitle": "Verlauf sichern",
|
||||
"settingsBackupExportSubtitle": "Verschlüsselte Datei mit Kontakten und Nachrichten",
|
||||
"settingsBackupImportTitle": "Aus Backup wiederherstellen",
|
||||
"settingsBackupImportSubtitle": "Backup laden (nur mit deiner Identität)",
|
||||
"settingsSectionSafety": "Schutz",
|
||||
"settingsReportAbuseTitle": "Missbrauch melden",
|
||||
"settingsReportAbuseSubtitle": "Belästigung, Betrug, Gefährdung eines Kindes",
|
||||
"settingsChildSafetyTitle": "Kinderschutz-Standards",
|
||||
"settingsChildSafetySubtitle": "Wie wir gegen Kindesmissbrauch vorgehen",
|
||||
"settingsSectionAbout": "Über die App",
|
||||
"commonAppName": "Šepot",
|
||||
"settingsVersion": "Version {version}",
|
||||
"settingsLicenseTitle": "Lizenz",
|
||||
"settingsLicenseSubtitle": "AGPL-3.0",
|
||||
"settingsPrivacyPolicyTitle": "Datenschutzerklärung",
|
||||
"settingsPrivacyPolicySubtitle": "Was wir über dich wissen (und was nicht)",
|
||||
"settingsTermsTitle": "Nutzungsbedingungen",
|
||||
"settingsTermsSubtitle": "sepot.cz/podminky",
|
||||
"settingsDataDeletionTitle": "Datenlöschung",
|
||||
"settingsDataDeletionSubtitle": "So beantragst du die Löschung",
|
||||
"settingsDeleteAllDataButton": "Alle Daten löschen",
|
||||
"settingsLicenseBody": "Šepot wird unter der GNU Affero General Public License Version 3 (AGPL-3.0) verbreitet. Es nutzt die Bibliothek libsignal, ebenfalls AGPL-3.0.\n\nDen Quellcode zu dieser Version kannst du per E-Mail an {supportEmail} anfordern — wir schicken ihn dir.",
|
||||
"commonClose": "Schließen",
|
||||
"settingsIdCopied": "ID kopiert",
|
||||
"settingsFingerprint": "Fingerabdruck: {fingerprint}",
|
||||
"backupShareSubject": "Šepot — verschlüsseltes Backup",
|
||||
"backupExportFailed": "Backup fehlgeschlagen: {error}",
|
||||
"backupRestoredCount": "{count, plural, one{1 Unterhaltung aus dem Backup wiederhergestellt} other{{count} Unterhaltungen aus dem Backup wiederhergestellt}}",
|
||||
"backupImportFailed": "Wiederherstellung aus dem Backup fehlgeschlagen: {error}",
|
||||
"settingsDecoyPinExplain": "Mit dieser PIN öffnet sich die App leer — ohne deine Kontakte und Nachrichten. Sie muss sich von der normalen PIN unterscheiden.",
|
||||
"commonConfirm": "Bestätigen",
|
||||
"commonSet": "Festlegen",
|
||||
"settingsDecoyPinEnabled": "Tarn-PIN eingerichtet",
|
||||
"settingsDecoyPinConflict": "Die Tarn-PIN muss sich von der normalen PIN unterscheiden",
|
||||
"settingsDecoyPinDisabled": "Tarn-PIN deaktiviert",
|
||||
"settingsSeedPhraseNotFound": "Wiederherstellungsphrase nicht gefunden",
|
||||
"settingsSeedPhraseWarning": "Diese Wörter sind das einzige Backup deiner Identität. Zeig sie niemandem!",
|
||||
"settingsSeedWordChip": "{index}. {word}",
|
||||
"settingsAboutTagline": "Šepot — Privater Messenger",
|
||||
"settingsAboutBody": "E2E-verschlüsselter Messenger mit offenem Quellcode.\n\nKryptografie:\n• X25519 (Schlüsselaustausch)\n• XChaCha20-Poly1305 (Verschlüsselung)\n• Ed25519 (Signaturen)\n\n© 2026 ajtak.it",
|
||||
"settingsDeleteAccountTitle": "Konto und alle Daten löschen?",
|
||||
"settingsDeleteAccountBody": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten, Kontakte und Schlüssel werden von diesem Gerät gelöscht und die Registrierung auf dem Server wird entfernt. Ohne Wiederherstellungsphrase verlierst du deine Identität.",
|
||||
"settingsDeleteAccountServerOffline": "Der Server ist gerade nicht erreichbar — die Daten auf dem Gerät sind gelöscht, die Registrierung auf dem Server besteht noch. Den Rest löschen wir für dich — schreib an podpora@sepot.cz.",
|
||||
"settingsDeleteAllConfirm": "Alles löschen",
|
||||
"groupsCreateFailed": "Gruppe konnte nicht erstellt werden: {error}",
|
||||
"groupsNewGroupTitle": "Neue Gruppe",
|
||||
"groupsCreateAction": "Erstellen",
|
||||
"groupsMembersCounter": "Mitglieder ({selectedCount}/{maxMembers})",
|
||||
"groupsEmptyContactsHint": "Füge zuerst Kontakte hinzu — eine Gruppe besteht aus Leuten, mit denen du schon schreiben kannst.",
|
||||
"groupsMemberMissingAddress": "Zustelladresse fehlt — frag nach einem neuen Link",
|
||||
"attachmentSizeBytes": "{bytes} B",
|
||||
"attachmentSizeKb": "{kilobytes} kB",
|
||||
"attachmentSizeMb": "{megabytes} MB",
|
||||
"attachmentDownloadFailedTapToRetry": "Download fehlgeschlagen — zum Wiederholen antippen",
|
||||
"attachmentDownloadFailed": "Download fehlgeschlagen",
|
||||
"attachmentDownloading": "Wird heruntergeladen…",
|
||||
"attachmentSharedFileFallbackName": "datei",
|
||||
"attachmentOpenFailed": "Öffnen des Anhangs fehlgeschlagen: {error}",
|
||||
"commonFile": "Datei",
|
||||
"chatListNewConversation": "Neue Unterhaltung",
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"chatListReadReceiptPreview": "Gelesen",
|
||||
"chatListTimeNow": "jetzt",
|
||||
"chatListTimeMinutes": "{minutes} Min.",
|
||||
"qrScanTitle": "QR-Code scannen",
|
||||
"qrScanInstruction": "Richte die Kamera auf den QR-Code des Kontakts",
|
||||
"qrScanHint": "Der QR-Code enthält den öffentlichen Schlüssel des Kontakts",
|
||||
"qrNewContactTitle": "Neuer Kontakt",
|
||||
"qrContactAdded": "{name} hinzugefügt",
|
||||
"commonAdd": "Hinzufügen",
|
||||
"onboardingWelcomeTitle": "Willkommen bei Šepot",
|
||||
"onboardingWelcomeSubtitle": "Privater Messenger mit Ende-zu-Ende-Verschlüsselung.\nKeine Telefonnummer. Keine E-Mail.\nNur deine Schlüssel.",
|
||||
"onboardingStart": "Los geht's",
|
||||
"onboardingRestoreExisting": "Ich habe schon ein Konto — mit Wiederherstellungsphrase wiederherstellen",
|
||||
"onboardingEncryptionTitle": "Deine Nachrichten, deine Schlüssel",
|
||||
"onboardingFeatureSignalTitle": "Signal-Protokoll",
|
||||
"onboardingFeatureSignalSubtitle": "X3DH + Double Ratchet, Forward Secrecy",
|
||||
"onboardingFeaturePqTitle": "Post-Quanten-Schutz",
|
||||
"onboardingFeaturePqSubtitle": "Kyber (PQXDH) ab der ersten Nachricht",
|
||||
"onboardingFeatureLocalKeysTitle": "Schlüssel nur auf deinem Gerät",
|
||||
"onboardingFeatureLocalKeysSubtitle": "Kein Server kennt sie",
|
||||
"onboardingFeatureDisappearingSubtitle": "Werden automatisch gelöscht",
|
||||
"commonContinue": "Weiter",
|
||||
"onboardingIdentityTitle": "Erstelle deine Identität",
|
||||
"onboardingIdentitySubtitle": "Keine E-Mail, kein Telefon. Nur ein Name und kryptografische Schlüssel.",
|
||||
"onboardingDisplayNameLabel": "Anzeigename",
|
||||
"onboardingDisplayNameHint": "z. B. Anna",
|
||||
"onboardingSeedPhraseWarning": "Schreib dir diese {count} Wörter auf. Sie sind das einzige Backup deiner Schlüssel!",
|
||||
"onboardingSeedSavedConfirm": "Ich bestätige, dass ich die Phrase sicher gespeichert habe",
|
||||
"onboardingGenerateKeys": "Schlüssel erzeugen",
|
||||
"verifyMismatchTitle": "Nummern stimmen NICHT überein",
|
||||
"verifyMatchBody": "Die Schlüssel stimmen überein. {contactName} ist ein verifizierter Kontakt.",
|
||||
"verifyMismatchBody": "Die Sicherheitsnummern stimmen nicht überein. Jemand könnte die Verbindung abhören (MITM), oder du scannst den Code eines anderen Kontakts. Sende KEINE sensiblen Nachrichten und verifiziere den Kontakt noch einmal über einen anderen Kanal.",
|
||||
"commonUnderstood": "Verstanden",
|
||||
"verifyManualConfirmTitle": "Als verifiziert markieren?",
|
||||
"verifyManualConfirmBody": "Bestätige nur, wenn du die Ziffern mit deinem Gegenüber über einen anderen vertrauenswürdigen Kanal (persönlich, per Anruf…) verglichen hast und sie übereinstimmen.",
|
||||
"verifyManualConfirmAction": "Stimmen überein, verifizieren",
|
||||
"verifyTitle": "Kontakt verifizieren",
|
||||
"verifyContactVerifiedBanner": "Der Kontakt ist verifiziert",
|
||||
"verifyIntro": "Vergleiche diese Nummer mit {contactName}. Wenn sie übereinstimmt, hört niemand eure Kommunikation ab. Am schnellsten geht es, wenn ihr gegenseitig eure QR-Codes scannt.",
|
||||
"verifyScanButton": "Code des Gegenübers scannen",
|
||||
"verifyManualButton": "Ziffern stimmen — manuell markieren",
|
||||
"verifyUnverifyButton": "Verifizierung aufheben",
|
||||
"verifyScanScreenTitle": "Verifizierungs-QR scannen",
|
||||
"reportTitle": "Melden",
|
||||
"reportSubmitted": "Meldung gesendet",
|
||||
"reportSubmittedChildSafety": "Wir behandeln die Meldung mit Priorität. Wenn ein Kind unmittelbar in Gefahr ist, ruf die örtliche Polizei (Notruf) oder eine Kinderschutz-Hotline an.",
|
||||
"reportSubmittedThanks": "Danke. Wir sehen es uns so schnell wie möglich an.",
|
||||
"commonDone": "Fertig",
|
||||
"reportPanelE2eNotice": "Nachrichten sind Ende-zu-Ende-verschlüsselt — wir sehen ihren Inhalt auch jetzt nicht. Beschreibe bitte in eigenen Worten, was passiert ist. Füge keine Nachrichteninhalte oder Links darauf ein — wir dürfen sie nicht speichern und brauchen sie für die Prüfung nicht.",
|
||||
"reportPanelCsamWarning": "Schick uns KEIN Material, das den Missbrauch eines Kindes zeigt. Schon der Besitz ist auch für uns strafbar, und für die Meldung brauchen wir es nicht. Solches Material gehört direkt an die Polizei oder die zuständige Meldestelle in deinem Land. Schreib uns nur, was passiert ist — wir sperren das Konto und leiten die Sache weiter.",
|
||||
"reportPanelIpNotice": "Die Meldung läuft nicht über das Relay, sondern an unseren Webserver. Dieser erfährt beim Absenden deine IP-Adresse.",
|
||||
"reportCategoryHeading": "Worum es in der Meldung geht",
|
||||
"reportDescriptionHeading": "Was ist passiert",
|
||||
"reportAttachContactId": "ID des gemeldeten Kontakts anhängen",
|
||||
"reportAttachContactIdNamed": "ID des Kontakts {contactName} anhängen",
|
||||
"reportReplyToHeading": "Kontakt zu dir (optional)",
|
||||
"reportReplyToHint": "E-Mail, falls wir Rückfragen haben",
|
||||
"reportSubmitButton": "Meldung senden",
|
||||
"contactsTitle": "Kontakte",
|
||||
"contactsAddContact": "Kontakt hinzufügen",
|
||||
"contactsSearchHint": "Kontakt suchen...",
|
||||
"contactsEmpty": "Noch keine Kontakte",
|
||||
"contactsNoResults": "Keine Ergebnisse",
|
||||
"contactsShareCardTitle": "Teile deine Šepot-ID",
|
||||
"contactsShareCardSubtitle": "Zeig deinen QR-Code oder schick einen Link",
|
||||
"contactsAddDialogPrompt": "Füge den Share-String des Kontakts ein:",
|
||||
"contactsShareStringHint": "Share-String einfügen...",
|
||||
"contactsInvalidShareString": "Ungültiger Share-String",
|
||||
"contactsAdded": "Kontakt {name} hinzugefügt",
|
||||
"verifyContactSubtitle": "Sicherheitsnummer vergleichen",
|
||||
"contactsDeleteContact": "Kontakt löschen",
|
||||
"pinConfirmTitle": "PIN bestätigen",
|
||||
"pinSetupTitle": "PIN festlegen",
|
||||
"pinConfirmSubtitle": "Gib die PIN zur Bestätigung erneut ein",
|
||||
"pinSetupSubtitle": "6-stellige PIN zum Entsperren der App",
|
||||
"pinSetupFailed": "PIN-Einrichtung fehlgeschlagen. Versuch es noch mal.",
|
||||
"pinMismatchRetry": "Die PINs stimmen nicht überein. Versuch es noch mal.",
|
||||
"pinChangeCurrentTitle": "Aktuelle PIN eingeben",
|
||||
"pinChangeCurrentSubtitle": "Wir prüfen sie durch Entsperren des Schlüssels",
|
||||
"pinChangeNewTitle": "Neue PIN eingeben",
|
||||
"pinChangeNewSubtitle": "6 Ziffern",
|
||||
"pinChangeConfirmTitle": "Neue PIN bestätigen",
|
||||
"pinChangeConfirmSubtitle": "Gib sie noch einmal ein",
|
||||
"pinWrong": "Falsche PIN.",
|
||||
"pinChangeMustDiffer": "Die neue PIN muss sich von der aktuellen unterscheiden.",
|
||||
"pinMismatch": "Die PINs stimmen nicht überein.",
|
||||
"pinDecoyConflict": "Diese PIN gehört schon zum Tarn-Modus. Wähle eine andere.",
|
||||
"pinChangeFailed": "PIN-Änderung fehlgeschlagen. Versuch es noch mal.",
|
||||
"pinChanged": "PIN geändert",
|
||||
"errorCryptoInitTitle": "Der Verschlüsselungskern konnte nicht gestartet werden",
|
||||
"errorStartupTitle": "Šepot konnte nicht gestartet werden",
|
||||
"errorScreenNotRenderedTitle": "Dieser Bildschirm konnte nicht dargestellt werden",
|
||||
"errorStartupBody": "Gleich beim Start ist etwas schiefgegangen. Schick mir bitte diesen Text (gedrückt halten und kopieren) — er hilft mir, den Fehler zu finden.",
|
||||
"errorContactFooter": "sepot.cz · schreib mir über die Kontaktseite",
|
||||
"pinUnlockTitle": "Šepot entsperren",
|
||||
"pinEnterPin": "PIN eingeben",
|
||||
"pinUnlockWithBiometrics": "Mit Biometrie entsperren",
|
||||
"pinWrong2": "Falsche PIN",
|
||||
"pinUnlockError": "Fehler beim Entsperren",
|
||||
"pushTitle": "Push-Benachrichtigungen",
|
||||
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
|
||||
"pushModeUp": "UnifiedPush (empfohlen)",
|
||||
"pushModeUpDesc": "Google-frei — braucht die App ntfy (F-Droid/Play).",
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Zuverlässig ohne zusätzliche App. Der Push-Token des Geräts geht an Google; der Inhalt der Nachrichten nie.",
|
||||
"pushSetupFailed": "Einrichtung der Push-Benachrichtigungen fehlgeschlagen: {error}",
|
||||
"pushNoDistributorTitle": "Kein Distributor",
|
||||
"pushNoDistributorBody": "Für Google-freie Benachrichtigungen installiere einen UnifiedPush-Distributor, z. B. ntfy (F-Droid oder Play). Aktiviere Push danach erneut.",
|
||||
"commonOk": "OK",
|
||||
"pushPickDistributorTitle": "Distributor wählen",
|
||||
"pushSubtitleUnifiedPush": "UnifiedPush · {distributor}",
|
||||
"pushNoDistributorFallback": "kein Distributor",
|
||||
"pushSubtitleFcm": "Google (FCM) — schnell, opt-in",
|
||||
"restoreErrorNameRequired": "Gib einen Anzeigenamen ein.",
|
||||
"restoreErrorWordCount": "{wordCount, plural, one{Die Phrase hat 1 Wort, benötigt werden {requiredWords}.} other{Die Phrase hat {wordCount} Wörter, benötigt werden {requiredWords}.}}",
|
||||
"restoreErrorInvalidPhrase": "Die Phrase ist ungültig — prüfe die Wörter und ihre Reihenfolge.",
|
||||
"restoreErrorFailed": "Wiederherstellung fehlgeschlagen: {error}",
|
||||
"restoreTitle": "Konto wiederherstellen",
|
||||
"restoreHeading": "Gib deine Wiederherstellungsphrase ein",
|
||||
"restoreIntro": "{wordCount} Wörter aus dem Backup, das beim Erstellen des Kontos angelegt wurde. Stellt deine Identität wieder her (gleicher SEP-Code, deine Kontakte erkennen dich weiter).",
|
||||
"restoreHistoryWarning": "Der Nachrichtenverlauf wird nicht wiederhergestellt — er ist nur auf dem ursprünglichen Gerät gespeichert.",
|
||||
"restorePhraseHint": "wort1 wort2 wort3 …",
|
||||
"restoreWordCountHelper": "{wordCount} / {requiredWords} Wörter",
|
||||
"chatListArchivedCount": "Archiviert ({count})",
|
||||
"chatListEmptyTitle": "Noch keine Unterhaltungen",
|
||||
"chatListEmptySubtitle": "Füge einen Kontakt hinzu und fang an zu flüstern!",
|
||||
"chatListUnmute": "Stummschaltung aufheben",
|
||||
"chatListMute": "Stummschalten",
|
||||
"chatListArchive": "Archivieren",
|
||||
"qrShareTitle": "Meine Šepot-ID",
|
||||
"qrIdentityNotFound": "Identität nicht gefunden",
|
||||
"commonCopiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"qrShareInviteMessage": "Füg mich auf Šepot hinzu!\n\n{shareString}",
|
||||
"commonShare": "Teilen",
|
||||
"qrShareInfo": "Dein Kontakt scannt deinen QR-Code oder fügt den Share-String in die App ein.",
|
||||
"reportCategoryChildSafety": "Gefährdung eines Kindes",
|
||||
"reportCategoryChildSafetyHint": "Beschreibe die Situation in eigenen Worten. Füge keine Nachrichteninhalte oder Links zu Material ein.",
|
||||
"reportCategoryHarassment": "Belästigung oder Drohungen",
|
||||
"reportCategoryHarassmentHint": "Jemand schüchtert dich ein, droht dir oder belästigt dich.",
|
||||
"reportCategorySpam": "Spam",
|
||||
"reportCategorySpamHint": "Unerwünschte Nachrichten, Massenversand.",
|
||||
"reportCategoryFraud": "Betrug",
|
||||
"reportCategoryFraudHint": "Versuch, Geld, Schlüssel oder persönliche Daten zu erschleichen.",
|
||||
"reportCategoryOther": "Sonstiges",
|
||||
"reportCategoryOtherHint": "Etwas anderes, das wir wissen sollten.",
|
||||
"reportErrorEmptyDescription": "Schreib bitte, was passiert ist.",
|
||||
"reportErrorConnection": "Verbindung zum Server fehlgeschlagen. Versuch es noch mal oder schreib an {supportEmail}.",
|
||||
"reportErrorRateLimited": "Aus deinem Netzwerk kamen zu viele Meldungen. Versuch es in einer Stunde noch mal oder schreib an {supportEmail}.",
|
||||
"reportErrorServer": "Senden fehlgeschlagen (Code {statusCode}). Schreib bitte an {supportEmail}.",
|
||||
"settingsAutoLockNever": "Nie",
|
||||
"settingsAutoLock1min": "Nach 1 Minute",
|
||||
"settingsAutoLock15min": "Nach 15 Minuten",
|
||||
"torToggleFailed": "Umschalten von Tor fehlgeschlagen: {error}",
|
||||
"torConnectionTitle": "Verbindung über Tor",
|
||||
"torDialogBody": "Der Verkehr zum Server läuft über das Tor-Netzwerk (verbirgt deine IP-Adresse). Erfordert die installierte und laufende App Orbot mit aktivem SOCKS-Proxy auf Port 9050.\n\nOhne laufendes Orbot werden Nachrichten weder gesendet noch empfangen. Die Verbindung ist meist langsamer.",
|
||||
"commonTurnOn": "Aktivieren",
|
||||
"torSubtitleOff": "Direkte verschlüsselte Verbindung (WSS). Aktiviere Tor, um deine IP zu verbergen.",
|
||||
"torStatusConnected": "Über Tor verbunden (.onion).",
|
||||
"torStatusConnecting": "Verbinde über Tor… (läuft Orbot?)",
|
||||
"torStatusWaiting": "Über Tor — warte auf Orbot (SOCKS 9050).",
|
||||
"biometricsPinRequiredFirst": "Richte zuerst eine PIN ein — Biometrie ergänzt sie nur.",
|
||||
"biometricsStorageLocked": "Der Speicher ist gesperrt — entsperre die App mit der PIN.",
|
||||
"biometricsNotAvailable": "Auf dem Gerät ist keine Biometrie eingerichtet.",
|
||||
"biometricsEnrollPromptReason": "Bestätige die Aktivierung der biometrischen Entsperrung",
|
||||
"biometricsSetupFailed": "Biometrie konnte nicht eingerichtet werden: {error}",
|
||||
"biometricsTileTitle": "Biometrie",
|
||||
"biometricsTileSubtitle": "Entsperren per Fingerabdruck/Gesicht (PIN bleibt als Backup)",
|
||||
"backupErrorCorruptedFile": "Beschädigte Backup-Datei",
|
||||
"backupErrorUnknownFormat": "Unbekanntes Backup-Format",
|
||||
"backupErrorWrongIdentity": "Backup lässt sich nicht entschlüsseln — gehört es zu einer anderen Identität?",
|
||||
"attachmentSharedImageFilename": "sepot-bild.{ext}",
|
||||
"attachmentDecryptFailed": "Anhang konnte nicht entschlüsselt werden",
|
||||
"pushActionOpen": "Öffnen",
|
||||
"pushChannelMessagesName": "Nachrichten",
|
||||
"pushChannelMessagesDescription": "Hinweise auf neue Nachrichten (ohne Inhalt)",
|
||||
"pushNewMessageFrom": "Neue Nachricht von {senderName}",
|
||||
"pushNewMessages": "Du hast neue Nachrichten",
|
||||
"archiveTitle": "Archiviert",
|
||||
"archiveEmpty": "Keine archivierten Unterhaltungen",
|
||||
"archiveUnarchive": "Archivierung aufheben",
|
||||
"restoreErrorMnemonicWordCount": "Die Phrase hat {count} Wörter, erwartet werden {expected}",
|
||||
"restoreErrorInvalidMnemonic": "Ungültige Phrase: {message}",
|
||||
"contactsErrorAlreadyExists": "Kontakt existiert bereits",
|
||||
"commonAppTagline": "Privater Messenger",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Ende-zu-Ende-verschlüsselt"
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"appTitle": "Šepot",
|
||||
"settingsLanguage": "App language",
|
||||
"languageSystem": "System default",
|
||||
"commonUnknownContact": "Unknown",
|
||||
"verifyVerified": "Verified",
|
||||
"verifyUnverified": "Not verified",
|
||||
"chatMenuVerifyContact": "Verify contact",
|
||||
"chatDisappearingMessages": "Disappearing messages",
|
||||
"chatMenuUnblockContact": "Unblock contact",
|
||||
"chatMenuBlockContact": "Block contact",
|
||||
"reportAndBlock": "Report and block",
|
||||
"errorGeneric": "Error: {error}",
|
||||
"groupsYouLeftGroup": "You're no longer in this group",
|
||||
"groupsMemberCount": "{count, plural, one{1 member} other{{count} members}}",
|
||||
"groupsMembers": "Members",
|
||||
"groupsRename": "Rename group",
|
||||
"groupsLeave": "Leave group",
|
||||
"groupsLeftBanner": "You're no longer in this group. Your history stays, but new messages won't arrive.",
|
||||
"groupsMembersWithCount": "Members ({count})",
|
||||
"groupsYou": "You",
|
||||
"groupsFounder": "Founder",
|
||||
"groupsNameHint": "Group name",
|
||||
"commonCancel": "Cancel",
|
||||
"commonSave": "Save",
|
||||
"groupsRenameFailed": "Rename failed: {error}",
|
||||
"groupsLeaveConfirmTitle": "Leave group?",
|
||||
"groupsLeaveConfirmBody": "Others will see that you've left. Your history stays, but you won't receive new messages and someone will have to invite you back.",
|
||||
"groupsLeaveAction": "Leave",
|
||||
"groupsLeaveFailed": "Leaving failed: {error}",
|
||||
"chatBlockedBanner": "This contact is blocked. You won't receive their messages and can't message them.",
|
||||
"chatUnblock": "Unblock",
|
||||
"chatContactBlocked": "{name} is blocked",
|
||||
"chatContactUnblocked": "{name} is unblocked",
|
||||
"reportAndBlockBody": "We'll block {name} and you'll stop receiving their messages. Then we'll show you a form to tell us what happened — we can't see message content, so without your description we won't know about it.",
|
||||
"chatEmptyEncrypted": "Messages are end-to-end encrypted.",
|
||||
"chatEmptyNoOneElse": "No one else can read them.",
|
||||
"commonToday": "Today",
|
||||
"commonYesterday": "Yesterday",
|
||||
"chatMessageHint": "Type a message…",
|
||||
"chatDiscardRecording": "Discard",
|
||||
"chatRecording": "Recording…",
|
||||
"chatMicPermissionMissing": "Microphone permission missing",
|
||||
"chatRecordingFailed": "Recording failed: {error}",
|
||||
"chatVoiceSendFailed": "Sending voice message failed: {error}",
|
||||
"chatDeletedMessagePreview": "Deleted message",
|
||||
"chatVoiceMessagePreview": "🎤 Voice message",
|
||||
"chatPhotoPreview": "📷 Photo",
|
||||
"chatAttachmentPreview": "📎 Attachment",
|
||||
"chatEditingMessage": "Editing message",
|
||||
"chatReply": "Reply",
|
||||
"chatActionReply": "Reply",
|
||||
"commonCopy": "Copy",
|
||||
"commonEdit": "Edit",
|
||||
"chatDeleteForMe": "Delete for me",
|
||||
"chatDeleteForEveryone": "Delete for everyone",
|
||||
"commonOff": "Off",
|
||||
"chatDisappearing30s": "30 seconds",
|
||||
"chatDisappearing5m": "5 minutes",
|
||||
"chatDisappearing1h": "1 hour",
|
||||
"chatDisappearing1d": "1 day",
|
||||
"chatDisappearing1w": "1 week",
|
||||
"chatDisappearingDescription": "New messages will be deleted for both you and the other person once the time is up.",
|
||||
"chatDisappearingDisabled": "Disappearing messages turned off",
|
||||
"chatDisappearingEnabled": "New messages will disappear after: {duration}",
|
||||
"attachmentGallery": "Photo from gallery",
|
||||
"attachmentTakePhoto": "Take photo",
|
||||
"attachmentDocument": "Document",
|
||||
"attachmentTooLarge": "File is too large (max {maxMb} MB)",
|
||||
"attachmentPickFailed": "Picking attachment failed: {error}",
|
||||
"attachmentSendFailed": "Sending attachment failed: {error}",
|
||||
"chatSendFailed": "Sending failed: {error}",
|
||||
"chatMessageDeleted": "Message deleted",
|
||||
"chatEdited": "edited",
|
||||
"errorTrustRootChanged": "The relay's trust root has changed! Refusing to communicate.",
|
||||
"errorRelayUnavailable": "Relay unavailable (sender certificate)",
|
||||
"errorAttachmentTooLarge": "Attachment is too large (max {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "A group needs at least 1 member",
|
||||
"errorGroupMaxMembers": "A group can have at most {maxMembers} members",
|
||||
"errorMemberNoDeliveryAddress": "{displayName} has no delivery address — ask them for a new link",
|
||||
"errorContactNoDeliveryAddress": "This contact has no delivery address — ask them for a new sepot:// link",
|
||||
"errorIdentityKeyMismatch": "The identity key from the relay does NOT match this contact — possible MITM! Message not sent.",
|
||||
"settingsTitle": "Settings",
|
||||
"settingsSectionGeneral": "General",
|
||||
"settingsSectionSecurity": "Security",
|
||||
"settingsChangePinTitle": "Change PIN",
|
||||
"settingsChangePinSubtitle": "6-digit PIN for unlocking",
|
||||
"settingsAutoLockTitle": "Auto-lock",
|
||||
"settingsAutoLock5min": "After 5 minutes",
|
||||
"settingsDecoyPinTitle": "Duress PIN",
|
||||
"settingsDecoyPinSetSubtitle": "Set — opens an empty app",
|
||||
"settingsDecoyPinUnsetSubtitle": "A second PIN that opens an empty app",
|
||||
"settingsSectionNotifications": "Notifications",
|
||||
"settingsSectionConnection": "Connection",
|
||||
"settingsSectionPrivacy": "Privacy",
|
||||
"settingsReadReceiptsTitle": "Read receipts",
|
||||
"settingsReadReceiptsSubtitle": "Let others see when you've read their messages (off by default)",
|
||||
"settingsScreenSecurityTitle": "Screen security",
|
||||
"settingsScreenSecuritySubtitle": "Block screenshots and hide content in the app switcher",
|
||||
"settingsSectionBackup": "Key backup",
|
||||
"settingsSeedPhraseTitle": "Recovery phrase",
|
||||
"settingsSeedPhraseSubtitle": "24 words to restore your identity",
|
||||
"settingsBackupExportTitle": "Back up history",
|
||||
"settingsBackupExportSubtitle": "Encrypted file with your contacts and messages",
|
||||
"settingsBackupImportTitle": "Restore from backup",
|
||||
"settingsBackupImportSubtitle": "Load a backup (only with your identity)",
|
||||
"settingsSectionSafety": "Safety",
|
||||
"settingsReportAbuseTitle": "Report abuse",
|
||||
"settingsReportAbuseSubtitle": "Harassment, fraud, child endangerment",
|
||||
"settingsChildSafetyTitle": "Child safety standards",
|
||||
"settingsChildSafetySubtitle": "How we respond to child abuse",
|
||||
"settingsSectionAbout": "About",
|
||||
"commonAppName": "Šepot",
|
||||
"settingsVersion": "Version {version}",
|
||||
"settingsLicenseTitle": "License",
|
||||
"settingsLicenseSubtitle": "AGPL-3.0",
|
||||
"settingsPrivacyPolicyTitle": "Privacy policy",
|
||||
"settingsPrivacyPolicySubtitle": "What we know about you (and what we don't)",
|
||||
"settingsTermsTitle": "Terms of use",
|
||||
"settingsTermsSubtitle": "sepot.cz/podminky",
|
||||
"settingsDataDeletionTitle": "Data deletion",
|
||||
"settingsDataDeletionSubtitle": "How to request erasure",
|
||||
"settingsDeleteAllDataButton": "Delete all data",
|
||||
"settingsLicenseBody": "Šepot is distributed under the GNU Affero General Public License version 3 (AGPL-3.0). It uses the libsignal library, also AGPL-3.0.\n\nTo get the source code corresponding to this version, write to {supportEmail} and we'll send it to you.",
|
||||
"commonClose": "Close",
|
||||
"settingsIdCopied": "ID copied",
|
||||
"settingsFingerprint": "Fingerprint: {fingerprint}",
|
||||
"backupShareSubject": "Šepot — encrypted backup",
|
||||
"backupExportFailed": "Backup failed: {error}",
|
||||
"backupRestoredCount": "{count, plural, one{Restored 1 conversation from the backup} other{Restored {count} conversations from the backup}}",
|
||||
"backupImportFailed": "Restoring from backup failed: {error}",
|
||||
"settingsDecoyPinExplain": "Entering this PIN opens the app empty — without your contacts and messages. It must be different from your regular PIN.",
|
||||
"commonConfirm": "Confirm",
|
||||
"commonSet": "Set",
|
||||
"settingsDecoyPinEnabled": "Duress PIN set",
|
||||
"settingsDecoyPinConflict": "The duress PIN must differ from your regular PIN",
|
||||
"settingsDecoyPinDisabled": "Duress PIN removed",
|
||||
"settingsSeedPhraseNotFound": "Recovery phrase not found",
|
||||
"settingsSeedPhraseWarning": "These words are the only backup of your identity. Don't show them to anyone!",
|
||||
"settingsSeedWordChip": "{index}. {word}",
|
||||
"settingsAboutTagline": "Šepot — Private Messenger",
|
||||
"settingsAboutBody": "An open-source end-to-end encrypted messenger.\n\nCryptography:\n• X25519 (key exchange)\n• XChaCha20-Poly1305 (encryption)\n• Ed25519 (signatures)\n\n© 2026 ajtak.it",
|
||||
"settingsDeleteAccountTitle": "Delete account and all data?",
|
||||
"settingsDeleteAccountBody": "This action cannot be undone. All messages, contacts and keys will be deleted from this device and the account will be deregistered from the server. If you don't have your recovery phrase, you'll lose your identity.",
|
||||
"settingsDeleteAccountServerOffline": "The server is unreachable right now — data on this device has been deleted, but the server registration remains. Write to podpora@sepot.cz and we'll delete the rest.",
|
||||
"settingsDeleteAllConfirm": "Delete everything",
|
||||
"groupsCreateFailed": "Couldn't create the group: {error}",
|
||||
"groupsNewGroupTitle": "New group",
|
||||
"groupsCreateAction": "Create",
|
||||
"groupsMembersCounter": "Members ({selectedCount}/{maxMembers})",
|
||||
"groupsEmptyContactsHint": "Add some contacts first — a group is made of people you can already message.",
|
||||
"groupsMemberMissingAddress": "Missing delivery address — ask them for a new link",
|
||||
"attachmentSizeBytes": "{bytes} B",
|
||||
"attachmentSizeKb": "{kilobytes} KB",
|
||||
"attachmentSizeMb": "{megabytes} MB",
|
||||
"attachmentDownloadFailedTapToRetry": "Download failed — tap to retry",
|
||||
"attachmentDownloadFailed": "Download failed",
|
||||
"attachmentDownloading": "Downloading…",
|
||||
"attachmentSharedFileFallbackName": "file",
|
||||
"attachmentOpenFailed": "Opening attachment failed: {error}",
|
||||
"commonFile": "File",
|
||||
"chatListNewConversation": "New conversation",
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"chatListReadReceiptPreview": "Read",
|
||||
"chatListTimeNow": "now",
|
||||
"chatListTimeMinutes": "{minutes} min",
|
||||
"qrScanTitle": "Scan QR code",
|
||||
"qrScanInstruction": "Point the camera at your contact's QR code",
|
||||
"qrScanHint": "The QR code contains the contact's public key",
|
||||
"qrNewContactTitle": "New contact",
|
||||
"qrContactAdded": "{name} added",
|
||||
"commonAdd": "Add",
|
||||
"onboardingWelcomeTitle": "Welcome to Šepot",
|
||||
"onboardingWelcomeSubtitle": "A private messenger with end-to-end encryption.\nNo phone number. No email.\nJust your keys.",
|
||||
"onboardingStart": "Get started",
|
||||
"onboardingRestoreExisting": "I already have an account — restore from recovery phrase",
|
||||
"onboardingEncryptionTitle": "Your messages, your keys",
|
||||
"onboardingFeatureSignalTitle": "Signal protocol",
|
||||
"onboardingFeatureSignalSubtitle": "X3DH + Double Ratchet, forward secrecy",
|
||||
"onboardingFeaturePqTitle": "Post-quantum protection",
|
||||
"onboardingFeaturePqSubtitle": "Kyber (PQXDH) from the first message",
|
||||
"onboardingFeatureLocalKeysTitle": "Keys stay on your device",
|
||||
"onboardingFeatureLocalKeysSubtitle": "No server ever sees them",
|
||||
"onboardingFeatureDisappearingSubtitle": "Deleted automatically",
|
||||
"commonContinue": "Continue",
|
||||
"onboardingIdentityTitle": "Create your identity",
|
||||
"onboardingIdentitySubtitle": "No email or phone. Just a name and cryptographic keys.",
|
||||
"onboardingDisplayNameLabel": "Display name",
|
||||
"onboardingDisplayNameHint": "e.g. John",
|
||||
"onboardingSeedPhraseWarning": "Write down these {count} words. They're the only backup of your keys!",
|
||||
"onboardingSeedSavedConfirm": "I confirm I've stored the phrase safely",
|
||||
"onboardingGenerateKeys": "Generate keys",
|
||||
"verifyMismatchTitle": "Numbers DO NOT match",
|
||||
"verifyMatchBody": "The keys match. {contactName} is a verified contact.",
|
||||
"verifyMismatchBody": "The safety numbers don't match. Someone may be intercepting your connection (MITM), or you're scanning a different contact's code. Do NOT send sensitive messages and verify each other again over a different channel.",
|
||||
"commonUnderstood": "Got it",
|
||||
"verifyManualConfirmTitle": "Mark as verified?",
|
||||
"verifyManualConfirmBody": "Confirm only if you've compared the digits with the other person over another trusted channel (in person, on a call…) and they match.",
|
||||
"verifyManualConfirmAction": "They match — verify",
|
||||
"verifyTitle": "Verify contact",
|
||||
"verifyContactVerifiedBanner": "This contact is verified",
|
||||
"verifyIntro": "Compare this number with {contactName}. If it matches, no one is eavesdropping on your encryption. The fastest way is to scan each other's QR codes.",
|
||||
"verifyScanButton": "Scan their code",
|
||||
"verifyManualButton": "Digits match — mark manually",
|
||||
"verifyUnverifyButton": "Clear verification",
|
||||
"verifyScanScreenTitle": "Scan verification QR",
|
||||
"reportTitle": "Report",
|
||||
"reportSubmitted": "Report submitted",
|
||||
"reportSubmittedChildSafety": "We handle these reports with priority. If a child is in immediate danger, call your local police or a child helpline.",
|
||||
"reportSubmittedThanks": "Thanks. We'll look into it as soon as possible.",
|
||||
"commonDone": "Done",
|
||||
"reportPanelE2eNotice": "Messages are end-to-end encrypted, so we can't see their content even now. Please describe in your own words what happened. Don't paste message content or links to it here — we're not allowed to store it and we don't need it to assess the report.",
|
||||
"reportPanelCsamWarning": "Do NOT send us material depicting child abuse. Possessing it is a crime — for us too — and we don't need it for a report. It should go directly to the police or a dedicated child sexual abuse reporting hotline. Just tell us what happened — we'll block the account and pass the matter on.",
|
||||
"reportPanelIpNotice": "Reports aren't sent through the relay — they go to our web server, which will see your IP address when you submit.",
|
||||
"reportCategoryHeading": "What the report is about",
|
||||
"reportDescriptionHeading": "What happened",
|
||||
"reportAttachContactId": "Attach the reported contact's ID",
|
||||
"reportAttachContactIdNamed": "Attach {contactName}'s ID",
|
||||
"reportReplyToHeading": "How to reach you (optional)",
|
||||
"reportReplyToHint": "email, in case we need to follow up",
|
||||
"reportSubmitButton": "Submit report",
|
||||
"contactsTitle": "Contacts",
|
||||
"contactsAddContact": "Add contact",
|
||||
"contactsSearchHint": "Search contacts…",
|
||||
"contactsEmpty": "No contacts yet",
|
||||
"contactsNoResults": "No results",
|
||||
"contactsShareCardTitle": "Share your Šepot ID",
|
||||
"contactsShareCardSubtitle": "Show your QR code or send a link",
|
||||
"contactsAddDialogPrompt": "Paste the contact's share string:",
|
||||
"contactsShareStringHint": "Paste share string…",
|
||||
"contactsInvalidShareString": "Invalid share string",
|
||||
"contactsAdded": "Contact {name} added",
|
||||
"verifyContactSubtitle": "Compare safety numbers",
|
||||
"contactsDeleteContact": "Delete contact",
|
||||
"pinConfirmTitle": "Confirm PIN",
|
||||
"pinSetupTitle": "Set up a PIN",
|
||||
"pinConfirmSubtitle": "Enter the PIN again to confirm",
|
||||
"pinSetupSubtitle": "6-digit PIN to unlock the app",
|
||||
"pinSetupFailed": "Setting the PIN failed. Try again.",
|
||||
"pinMismatchRetry": "PINs don't match. Try again.",
|
||||
"pinChangeCurrentTitle": "Enter your current PIN",
|
||||
"pinChangeCurrentSubtitle": "We'll verify it by unlocking your key",
|
||||
"pinChangeNewTitle": "Enter a new PIN",
|
||||
"pinChangeNewSubtitle": "6 digits",
|
||||
"pinChangeConfirmTitle": "Confirm the new PIN",
|
||||
"pinChangeConfirmSubtitle": "Enter it one more time",
|
||||
"pinWrong": "Wrong PIN.",
|
||||
"pinChangeMustDiffer": "The new PIN must differ from the current one.",
|
||||
"pinMismatch": "PINs don't match.",
|
||||
"pinDecoyConflict": "This PIN already belongs to duress mode. Choose a different one.",
|
||||
"pinChangeFailed": "Changing the PIN failed. Try again.",
|
||||
"pinChanged": "PIN changed",
|
||||
"errorCryptoInitTitle": "The encryption core failed to start",
|
||||
"errorStartupTitle": "Šepot failed to start",
|
||||
"errorScreenNotRenderedTitle": "This screen failed to render",
|
||||
"errorStartupBody": "Something went wrong right at startup. Please send me this text (long-press and copy) — it will help me find the bug.",
|
||||
"errorContactFooter": "sepot.cz · get in touch",
|
||||
"pinUnlockTitle": "Unlock Šepot",
|
||||
"pinEnterPin": "Enter PIN",
|
||||
"pinUnlockWithBiometrics": "Unlock with biometrics",
|
||||
"pinWrong2": "Wrong PIN",
|
||||
"pinUnlockError": "Unlock error",
|
||||
"pushTitle": "Push notifications",
|
||||
"pushModeOffDesc": "Messages arrive only after you open the app.",
|
||||
"pushModeUp": "UnifiedPush (recommended)",
|
||||
"pushModeUpDesc": "De-Googled — needs the ntfy app (F-Droid/Play).",
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Reliable without an extra app. The device push token goes to Google; message content never does.",
|
||||
"pushSetupFailed": "Setting up push failed: {error}",
|
||||
"pushNoDistributorTitle": "No distributor",
|
||||
"pushNoDistributorBody": "For de-Googled notifications, install a UnifiedPush distributor such as ntfy (F-Droid or Play). Then turn push on again.",
|
||||
"commonOk": "OK",
|
||||
"pushPickDistributorTitle": "Choose a distributor",
|
||||
"pushSubtitleUnifiedPush": "UnifiedPush · {distributor}",
|
||||
"pushNoDistributorFallback": "no distributor",
|
||||
"pushSubtitleFcm": "Google (FCM) — fast, opt-in",
|
||||
"restoreErrorNameRequired": "Enter a display name.",
|
||||
"restoreErrorWordCount": "{wordCount, plural, one{The phrase has 1 word, {requiredWords} are needed.} other{The phrase has {wordCount} words, {requiredWords} are needed.}}",
|
||||
"restoreErrorInvalidPhrase": "The phrase isn't valid — check the words and their order.",
|
||||
"restoreErrorFailed": "Restore failed: {error}",
|
||||
"restoreTitle": "Restore account",
|
||||
"restoreHeading": "Enter your recovery phrase",
|
||||
"restoreIntro": "{wordCount} words from the backup created when you set up your account. This restores your identity (same SEP code — your contacts will still recognize you).",
|
||||
"restoreHistoryWarning": "Message history won't be restored — it's stored only on the original device.",
|
||||
"restorePhraseHint": "word1 word2 word3 …",
|
||||
"restoreWordCountHelper": "{wordCount} / {requiredWords} words",
|
||||
"chatListArchivedCount": "Archived ({count})",
|
||||
"chatListEmptyTitle": "No conversations yet",
|
||||
"chatListEmptySubtitle": "Add a contact and start whispering!",
|
||||
"chatListUnmute": "Unmute",
|
||||
"chatListMute": "Mute",
|
||||
"chatListArchive": "Archive",
|
||||
"qrShareTitle": "My Šepot ID",
|
||||
"qrIdentityNotFound": "Identity not found",
|
||||
"commonCopiedToClipboard": "Copied to clipboard",
|
||||
"qrShareInviteMessage": "Add me on Šepot!\n\n{shareString}",
|
||||
"commonShare": "Share",
|
||||
"qrShareInfo": "Your contact scans your QR code or pastes the share string into the app.",
|
||||
"reportCategoryChildSafety": "Child endangerment",
|
||||
"reportCategoryChildSafetyHint": "Describe the situation in your own words. Don't paste message content or links to material.",
|
||||
"reportCategoryHarassment": "Harassment or threats",
|
||||
"reportCategoryHarassmentHint": "Someone is intimidating, threatening or harassing you.",
|
||||
"reportCategorySpam": "Spam",
|
||||
"reportCategorySpamHint": "Unsolicited messages, bulk sending.",
|
||||
"reportCategoryFraud": "Fraud",
|
||||
"reportCategoryFraudHint": "An attempt to trick you out of money, keys or personal data.",
|
||||
"reportCategoryOther": "Other",
|
||||
"reportCategoryOtherHint": "Something else we should know about.",
|
||||
"reportErrorEmptyDescription": "Please describe what happened.",
|
||||
"reportErrorConnection": "Couldn't reach the server. Try again, or write to {supportEmail}.",
|
||||
"reportErrorRateLimited": "Too many reports came from your network. Try again in an hour, or write to {supportEmail}.",
|
||||
"reportErrorServer": "Submission failed (code {statusCode}). Please write to {supportEmail}.",
|
||||
"settingsAutoLockNever": "Never",
|
||||
"settingsAutoLock1min": "After 1 minute",
|
||||
"settingsAutoLock15min": "After 15 minutes",
|
||||
"torToggleFailed": "Toggling Tor failed: {error}",
|
||||
"torConnectionTitle": "Connect via Tor",
|
||||
"torDialogBody": "Traffic to the server will go through the Tor network (hides your IP address). Requires the Orbot app installed and running with an active SOCKS proxy on port 9050.\n\nWithout Orbot running, messages won't be sent or received. The connection is usually slower.",
|
||||
"commonTurnOn": "Turn on",
|
||||
"torSubtitleOff": "Direct encrypted connection (WSS). Turn on to hide your IP via Tor.",
|
||||
"torStatusConnected": "Connected via Tor (.onion).",
|
||||
"torStatusConnecting": "Connecting via Tor… (is Orbot running?)",
|
||||
"torStatusWaiting": "Via Tor — waiting for Orbot (SOCKS 9050).",
|
||||
"biometricsPinRequiredFirst": "Set a PIN first — biometrics only complement it.",
|
||||
"biometricsStorageLocked": "Storage is locked — unlock the app with your PIN.",
|
||||
"biometricsNotAvailable": "This device has no biometrics set up.",
|
||||
"biometricsEnrollPromptReason": "Confirm enabling biometric unlock",
|
||||
"biometricsSetupFailed": "Couldn't set up biometrics: {error}",
|
||||
"biometricsTileTitle": "Biometrics",
|
||||
"biometricsTileSubtitle": "Unlock with fingerprint / face (PIN stays as a backup)",
|
||||
"backupErrorCorruptedFile": "Corrupted backup file",
|
||||
"backupErrorUnknownFormat": "Unrecognized backup format",
|
||||
"backupErrorWrongIdentity": "Can't decrypt this backup — does it belong to a different identity?",
|
||||
"attachmentSharedImageFilename": "sepot-image.{ext}",
|
||||
"attachmentDecryptFailed": "Couldn't decrypt the attachment",
|
||||
"pushActionOpen": "Open",
|
||||
"pushChannelMessagesName": "Messages",
|
||||
"pushChannelMessagesDescription": "Alerts about new messages (no content)",
|
||||
"pushNewMessageFrom": "New message from {senderName}",
|
||||
"pushNewMessages": "You have new messages",
|
||||
"archiveTitle": "Archived",
|
||||
"archiveEmpty": "No archived conversations",
|
||||
"archiveUnarchive": "Unarchive",
|
||||
"restoreErrorMnemonicWordCount": "The phrase has {count} words, expected {expected}",
|
||||
"restoreErrorInvalidMnemonic": "Invalid phrase: {message}",
|
||||
"contactsErrorAlreadyExists": "Contact already exists",
|
||||
"commonAppTagline": "Private messenger",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "End-to-end encrypted"
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
{
|
||||
"@@locale": "it",
|
||||
"appTitle": "Šepot",
|
||||
"settingsLanguage": "Lingua dell'app",
|
||||
"languageSystem": "Predefinita di sistema",
|
||||
"commonUnknownContact": "Sconosciuto",
|
||||
"verifyVerified": "Verificato",
|
||||
"verifyUnverified": "Non verificato",
|
||||
"chatMenuVerifyContact": "Verifica contatto",
|
||||
"chatDisappearingMessages": "Messaggi a scomparsa",
|
||||
"chatMenuUnblockContact": "Sblocca contatto",
|
||||
"chatMenuBlockContact": "Blocca contatto",
|
||||
"reportAndBlock": "Segnala e blocca",
|
||||
"errorGeneric": "Errore: {error}",
|
||||
"groupsYouLeftGroup": "Non sei più nel gruppo",
|
||||
"groupsMemberCount": "{count, plural, one{1 membro} other{{count} membri}}",
|
||||
"groupsMembers": "Membri",
|
||||
"groupsRename": "Rinomina gruppo",
|
||||
"groupsLeave": "Abbandona il gruppo",
|
||||
"groupsLeftBanner": "Non sei più nel gruppo. La cronologia resta, i nuovi messaggi non arriveranno.",
|
||||
"groupsMembersWithCount": "Membri ({count})",
|
||||
"groupsYou": "Tu",
|
||||
"groupsFounder": "Fondatore",
|
||||
"groupsNameHint": "Nome del gruppo",
|
||||
"commonCancel": "Annulla",
|
||||
"commonSave": "Salva",
|
||||
"groupsRenameFailed": "Rinomina non riuscita: {error}",
|
||||
"groupsLeaveConfirmTitle": "Abbandonare il gruppo?",
|
||||
"groupsLeaveConfirmBody": "Gli altri vedranno che non sei più nel gruppo. La cronologia ti resterà, ma non riceverai più nuovi messaggi e per rientrare qualcuno dovrà invitarti.",
|
||||
"groupsLeaveAction": "Abbandona",
|
||||
"groupsLeaveFailed": "Uscita non riuscita: {error}",
|
||||
"chatBlockedBanner": "Il contatto è bloccato. Non ricevi i suoi messaggi e non puoi scrivergli.",
|
||||
"chatUnblock": "Sblocca",
|
||||
"chatContactBlocked": "Contatto {name} bloccato",
|
||||
"chatContactUnblocked": "Contatto {name} sbloccato",
|
||||
"reportAndBlockBody": "Bloccheremo il contatto {name} e smetterai di ricevere i suoi messaggi. Poi ti proporremo un modulo per farci sapere cosa è successo — non vediamo il contenuto dei messaggi, quindi senza la tua descrizione non ne sappiamo nulla.",
|
||||
"chatEmptyEncrypted": "I messaggi sono cifrati end-to-end.",
|
||||
"chatEmptyNoOneElse": "Nessun altro può leggerli.",
|
||||
"commonToday": "Oggi",
|
||||
"commonYesterday": "Ieri",
|
||||
"chatMessageHint": "Scrivi un messaggio…",
|
||||
"chatDiscardRecording": "Scarta",
|
||||
"chatRecording": "Registrazione…",
|
||||
"chatMicPermissionMissing": "Manca l'autorizzazione per il microfono",
|
||||
"chatRecordingFailed": "Registrazione non riuscita: {error}",
|
||||
"chatVoiceSendFailed": "Invio del messaggio vocale non riuscito: {error}",
|
||||
"chatDeletedMessagePreview": "Messaggio eliminato",
|
||||
"chatVoiceMessagePreview": "🎤 Messaggio vocale",
|
||||
"chatPhotoPreview": "📷 Foto",
|
||||
"chatAttachmentPreview": "📎 Allegato",
|
||||
"chatEditingMessage": "Modifica messaggio",
|
||||
"chatReply": "Risposta",
|
||||
"chatActionReply": "Rispondi",
|
||||
"commonCopy": "Copia",
|
||||
"commonEdit": "Modifica",
|
||||
"chatDeleteForMe": "Elimina per me",
|
||||
"chatDeleteForEveryone": "Elimina per tutti",
|
||||
"commonOff": "Disattivato",
|
||||
"chatDisappearing30s": "30 secondi",
|
||||
"chatDisappearing5m": "5 minuti",
|
||||
"chatDisappearing1h": "1 ora",
|
||||
"chatDisappearing1d": "1 giorno",
|
||||
"chatDisappearing1w": "1 settimana",
|
||||
"chatDisappearingDescription": "I nuovi messaggi verranno eliminati allo scadere del tempo, sia per te che per l'altra persona.",
|
||||
"chatDisappearingDisabled": "Messaggi a scomparsa disattivati",
|
||||
"chatDisappearingEnabled": "I nuovi messaggi scompariranno dopo: {duration}",
|
||||
"attachmentGallery": "Foto dalla galleria",
|
||||
"attachmentTakePhoto": "Scatta foto",
|
||||
"attachmentDocument": "Documento",
|
||||
"attachmentTooLarge": "Il file è troppo grande (max {maxMb} MB)",
|
||||
"attachmentPickFailed": "Selezione dell'allegato non riuscita: {error}",
|
||||
"attachmentSendFailed": "Invio dell'allegato non riuscito: {error}",
|
||||
"chatSendFailed": "Invio non riuscito: {error}",
|
||||
"chatMessageDeleted": "Messaggio eliminato",
|
||||
"chatEdited": "modificato",
|
||||
"errorTrustRootChanged": "Il trust root del relay è cambiato! Comunicazione rifiutata.",
|
||||
"errorRelayUnavailable": "Relay non disponibile (sender certificate)",
|
||||
"errorAttachmentTooLarge": "L'allegato è troppo grande (max {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "Il gruppo ha bisogno di almeno 1 membro",
|
||||
"errorGroupMaxMembers": "Il gruppo può avere al massimo {maxMembers} membri",
|
||||
"errorMemberNoDeliveryAddress": "{displayName} non ha un indirizzo di consegna — chiedi un nuovo link",
|
||||
"errorContactNoDeliveryAddress": "Il contatto non ha un indirizzo di consegna — chiedigli un nuovo link sepot://",
|
||||
"errorIdentityKeyMismatch": "La chiave di identità dal relay NON corrisponde al contatto — possibile MITM! Messaggio non inviato.",
|
||||
"settingsTitle": "Impostazioni",
|
||||
"settingsSectionGeneral": "Generali",
|
||||
"settingsSectionSecurity": "Sicurezza",
|
||||
"settingsChangePinTitle": "Cambia PIN",
|
||||
"settingsChangePinSubtitle": "PIN a 6 cifre per sbloccare",
|
||||
"settingsAutoLockTitle": "Blocco automatico",
|
||||
"settingsAutoLock5min": "Dopo 5 minuti",
|
||||
"settingsDecoyPinTitle": "PIN anti-coercizione",
|
||||
"settingsDecoyPinSetSubtitle": "Impostato — apre un'app vuota",
|
||||
"settingsDecoyPinUnsetSubtitle": "Un secondo PIN che apre un'app vuota",
|
||||
"settingsSectionNotifications": "Notifiche",
|
||||
"settingsSectionConnection": "Connessione",
|
||||
"settingsSectionPrivacy": "Privacy",
|
||||
"settingsReadReceiptsTitle": "Conferme di lettura",
|
||||
"settingsReadReceiptsSubtitle": "Mostra all'altra persona quando leggi i messaggi (disattivato di default)",
|
||||
"settingsScreenSecurityTitle": "Sicurezza dello schermo",
|
||||
"settingsScreenSecuritySubtitle": "Blocca gli screenshot e nasconde il contenuto nelle app recenti",
|
||||
"settingsSectionBackup": "Backup delle chiavi",
|
||||
"settingsSeedPhraseTitle": "Frase di recupero",
|
||||
"settingsSeedPhraseSubtitle": "24 parole per ripristinare l'identità",
|
||||
"settingsBackupExportTitle": "Backup della cronologia",
|
||||
"settingsBackupExportSubtitle": "File cifrato con contatti e messaggi",
|
||||
"settingsBackupImportTitle": "Ripristina da backup",
|
||||
"settingsBackupImportSubtitle": "Carica un backup (solo con la tua identità)",
|
||||
"settingsSectionSafety": "Protezione",
|
||||
"settingsReportAbuseTitle": "Segnala un abuso",
|
||||
"settingsReportAbuseSubtitle": "Molestie, truffe, minori in pericolo",
|
||||
"settingsChildSafetyTitle": "Standard di tutela dei minori",
|
||||
"settingsChildSafetySubtitle": "Come agiamo contro gli abusi sui minori",
|
||||
"settingsSectionAbout": "Informazioni",
|
||||
"commonAppName": "Šepot",
|
||||
"settingsVersion": "Versione {version}",
|
||||
"settingsLicenseTitle": "Licenza",
|
||||
"settingsLicenseSubtitle": "AGPL-3.0",
|
||||
"settingsPrivacyPolicyTitle": "Informativa sulla privacy",
|
||||
"settingsPrivacyPolicySubtitle": "Cosa sappiamo di te (e cosa no)",
|
||||
"settingsTermsTitle": "Termini di utilizzo",
|
||||
"settingsTermsSubtitle": "sepot.cz/podminky",
|
||||
"settingsDataDeletionTitle": "Eliminazione dei dati",
|
||||
"settingsDataDeletionSubtitle": "Come richiedere la cancellazione",
|
||||
"settingsDeleteAllDataButton": "Elimina tutti i dati",
|
||||
"settingsLicenseBody": "Šepot è distribuito sotto licenza GNU Affero General Public License versione 3 (AGPL-3.0). Usa la libreria libsignal, anch'essa AGPL-3.0.\n\nPer ricevere il codice sorgente corrispondente a questa versione scrivi a {supportEmail} e te lo invieremo.",
|
||||
"commonClose": "Chiudi",
|
||||
"settingsIdCopied": "ID copiato",
|
||||
"settingsFingerprint": "Impronta: {fingerprint}",
|
||||
"backupShareSubject": "Šepot — backup cifrato",
|
||||
"backupExportFailed": "Backup non riuscito: {error}",
|
||||
"backupRestoredCount": "{count, plural, one{Ripristinata 1 conversazione dal backup} other{Ripristinate {count} conversazioni dal backup}}",
|
||||
"backupImportFailed": "Ripristino dal backup non riuscito: {error}",
|
||||
"settingsDecoyPinExplain": "Inserendo questo PIN l'app si apre vuota — senza i tuoi contatti e messaggi. Deve essere diverso dal PIN normale.",
|
||||
"commonConfirm": "Conferma",
|
||||
"commonSet": "Imposta",
|
||||
"settingsDecoyPinEnabled": "PIN anti-coercizione impostato",
|
||||
"settingsDecoyPinConflict": "Il PIN anti-coercizione deve essere diverso da quello normale",
|
||||
"settingsDecoyPinDisabled": "PIN anti-coercizione rimosso",
|
||||
"settingsSeedPhraseNotFound": "Frase di recupero non trovata",
|
||||
"settingsSeedPhraseWarning": "Queste parole sono l'unico backup della tua identità. Non mostrarle a nessuno!",
|
||||
"settingsSeedWordChip": "{index}. {word}",
|
||||
"settingsAboutTagline": "Šepot — Messenger privato",
|
||||
"settingsAboutBody": "Messenger cifrato E2E e open source.\n\nCrittografia:\n• X25519 (scambio di chiavi)\n• XChaCha20-Poly1305 (cifratura)\n• Ed25519 (firme)\n\n© 2026 ajtak.it",
|
||||
"settingsDeleteAccountTitle": "Eliminare l'account e tutti i dati?",
|
||||
"settingsDeleteAccountBody": "Questa azione è irreversibile. Tutti i messaggi, i contatti e le chiavi verranno eliminati da questo dispositivo e l'account verrà deregistrato dal server. Se non hai la frase di recupero, perderai la tua identità.",
|
||||
"settingsDeleteAccountServerOffline": "Il server al momento non è raggiungibile — i dati sul dispositivo sono stati eliminati, ma la registrazione sul server è rimasta. Scrivici a podpora@sepot.cz e cancelleremo il resto.",
|
||||
"settingsDeleteAllConfirm": "Elimina tutto",
|
||||
"groupsCreateFailed": "Impossibile creare il gruppo: {error}",
|
||||
"groupsNewGroupTitle": "Nuovo gruppo",
|
||||
"groupsCreateAction": "Crea",
|
||||
"groupsMembersCounter": "Membri ({selectedCount}/{maxMembers})",
|
||||
"groupsEmptyContactsHint": "Prima aggiungi dei contatti — un gruppo è fatto di persone con cui puoi già scrivere.",
|
||||
"groupsMemberMissingAddress": "Manca l'indirizzo di consegna — chiedi un nuovo link",
|
||||
"attachmentSizeBytes": "{bytes} B",
|
||||
"attachmentSizeKb": "{kilobytes} kB",
|
||||
"attachmentSizeMb": "{megabytes} MB",
|
||||
"attachmentDownloadFailedTapToRetry": "Download non riuscito — tocca per riprovare",
|
||||
"attachmentDownloadFailed": "Download non riuscito",
|
||||
"attachmentDownloading": "Download in corso…",
|
||||
"attachmentSharedFileFallbackName": "file",
|
||||
"attachmentOpenFailed": "Impossibile aprire l'allegato: {error}",
|
||||
"commonFile": "File",
|
||||
"chatListNewConversation": "Nuova conversazione",
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"chatListReadReceiptPreview": "Letto",
|
||||
"chatListTimeNow": "adesso",
|
||||
"chatListTimeMinutes": "{minutes} min",
|
||||
"qrScanTitle": "Scansiona codice QR",
|
||||
"qrScanInstruction": "Inquadra il codice QR del contatto",
|
||||
"qrScanHint": "Il codice QR contiene la chiave pubblica del contatto",
|
||||
"qrNewContactTitle": "Nuovo contatto",
|
||||
"qrContactAdded": "Contatto {name} aggiunto",
|
||||
"commonAdd": "Aggiungi",
|
||||
"onboardingWelcomeTitle": "Ti diamo il benvenuto in Šepot",
|
||||
"onboardingWelcomeSubtitle": "Un messenger privato con cifratura end-to-end.\nNessun numero di telefono. Nessuna email.\nSolo le tue chiavi.",
|
||||
"onboardingStart": "Inizia",
|
||||
"onboardingRestoreExisting": "Ho già un account — ripristina dalla frase di recupero",
|
||||
"onboardingEncryptionTitle": "I tuoi messaggi, le tue chiavi",
|
||||
"onboardingFeatureSignalTitle": "Protocollo Signal",
|
||||
"onboardingFeatureSignalSubtitle": "X3DH + Double Ratchet, forward secrecy",
|
||||
"onboardingFeaturePqTitle": "Protezione post-quantistica",
|
||||
"onboardingFeaturePqSubtitle": "Kyber (PQXDH) fin dal primo messaggio",
|
||||
"onboardingFeatureLocalKeysTitle": "Chiavi solo sul tuo dispositivo",
|
||||
"onboardingFeatureLocalKeysSubtitle": "Nessun server le possiede",
|
||||
"onboardingFeatureDisappearingSubtitle": "Si eliminano automaticamente",
|
||||
"commonContinue": "Continua",
|
||||
"onboardingIdentityTitle": "Crea la tua identità",
|
||||
"onboardingIdentitySubtitle": "Nessuna email né telefono. Solo un nome e chiavi crittografiche.",
|
||||
"onboardingDisplayNameLabel": "Nome visualizzato",
|
||||
"onboardingDisplayNameHint": "Es. Marco",
|
||||
"onboardingSeedPhraseWarning": "Annota queste {count} parole. Sono l'unico backup delle tue chiavi!",
|
||||
"onboardingSeedSavedConfirm": "Confermo di aver salvato la frase in un luogo sicuro",
|
||||
"onboardingGenerateKeys": "Genera le chiavi",
|
||||
"verifyMismatchTitle": "I numeri NON corrispondono",
|
||||
"verifyMatchBody": "Le chiavi corrispondono. {contactName} è un contatto verificato.",
|
||||
"verifyMismatchBody": "I numeri di sicurezza non corrispondono. Qualcuno potrebbe intercettare la connessione (MITM), oppure stai scansionando il codice di un altro contatto. NON inviare messaggi sensibili e verificatevi di nuovo tramite un altro canale.",
|
||||
"commonUnderstood": "Ho capito",
|
||||
"verifyManualConfirmTitle": "Segnare come verificato?",
|
||||
"verifyManualConfirmBody": "Conferma solo se hai confrontato le cifre con l'altra persona tramite un altro canale affidabile (di persona, con una chiamata…) e corrispondono.",
|
||||
"verifyManualConfirmAction": "Corrispondono, verifica",
|
||||
"verifyTitle": "Verifica del contatto",
|
||||
"verifyContactVerifiedBanner": "Il contatto è verificato",
|
||||
"verifyIntro": "Confronta questo numero con {contactName}. Se corrisponde, nessuno sta intercettando la vostra cifratura. Il modo più rapido è scansionare a vicenda i codici QR.",
|
||||
"verifyScanButton": "Scansiona il codice dell'altra persona",
|
||||
"verifyManualButton": "Le cifre corrispondono — segna manualmente",
|
||||
"verifyUnverifyButton": "Rimuovi verifica",
|
||||
"verifyScanScreenTitle": "Scansiona il QR di verifica",
|
||||
"reportTitle": "Segnala",
|
||||
"reportSubmitted": "Segnalazione inviata",
|
||||
"reportSubmittedChildSafety": "Diamo priorità a queste segnalazioni. Se un minore è in pericolo immediato, chiama subito il 112 o il 114 (Emergenza Infanzia).",
|
||||
"reportSubmittedThanks": "Grazie. L'esamineremo il prima possibile.",
|
||||
"commonDone": "Fatto",
|
||||
"reportPanelE2eNotice": "I messaggi sono cifrati end-to-end, quindi non possiamo vederne il contenuto nemmeno ora. Descrivi con parole tue cosa è successo. Non incollare qui il contenuto dei messaggi né link ad esso — non possiamo conservarlo e non ci serve per la valutazione.",
|
||||
"reportPanelCsamWarning": "NON inviarci materiale che mostra abusi su minori. Detenerlo è un reato anche per noi e non ci serve per la segnalazione. Il materiale va segnalato direttamente alla polizia o a una hotline dedicata alla protezione dei minori. Scrivici solo cosa è successo — bloccheremo l'account e inoltreremo il caso.",
|
||||
"reportPanelIpNotice": "La segnalazione non passa dal relay ma dal nostro server web, che al momento dell'invio vedrà il tuo indirizzo IP.",
|
||||
"reportCategoryHeading": "Oggetto della segnalazione",
|
||||
"reportDescriptionHeading": "Cosa è successo",
|
||||
"reportAttachContactId": "Allega l'ID del contatto segnalato",
|
||||
"reportAttachContactIdNamed": "Allega l'ID del contatto {contactName}",
|
||||
"reportReplyToHeading": "Come contattarti (facoltativo)",
|
||||
"reportReplyToHint": "email, nel caso avessimo bisogno di chiarimenti",
|
||||
"reportSubmitButton": "Invia segnalazione",
|
||||
"contactsTitle": "Contatti",
|
||||
"contactsAddContact": "Aggiungi contatto",
|
||||
"contactsSearchHint": "Cerca un contatto...",
|
||||
"contactsEmpty": "Ancora nessun contatto",
|
||||
"contactsNoResults": "Nessun risultato",
|
||||
"contactsShareCardTitle": "Condividi il tuo Šepot ID",
|
||||
"contactsShareCardSubtitle": "Mostra il codice QR o invia un link",
|
||||
"contactsAddDialogPrompt": "Incolla la stringa di condivisione (share string) del contatto:",
|
||||
"contactsShareStringHint": "Incolla la share string...",
|
||||
"contactsInvalidShareString": "Share string non valida",
|
||||
"contactsAdded": "Contatto {name} aggiunto",
|
||||
"verifyContactSubtitle": "Confronto del numero di sicurezza",
|
||||
"contactsDeleteContact": "Elimina contatto",
|
||||
"pinConfirmTitle": "Conferma il PIN",
|
||||
"pinSetupTitle": "Imposta il tuo PIN",
|
||||
"pinConfirmSubtitle": "Inserisci di nuovo il PIN per confermare",
|
||||
"pinSetupSubtitle": "PIN a 6 cifre per sbloccare l'app",
|
||||
"pinSetupFailed": "Impostazione del PIN non riuscita. Riprova.",
|
||||
"pinMismatchRetry": "I PIN non corrispondono. Riprova.",
|
||||
"pinChangeCurrentTitle": "Inserisci il PIN attuale",
|
||||
"pinChangeCurrentSubtitle": "Lo verifichiamo sbloccando la chiave",
|
||||
"pinChangeNewTitle": "Inserisci il nuovo PIN",
|
||||
"pinChangeNewSubtitle": "6 cifre",
|
||||
"pinChangeConfirmTitle": "Conferma il nuovo PIN",
|
||||
"pinChangeConfirmSubtitle": "Inseriscilo ancora una volta",
|
||||
"pinWrong": "PIN errato.",
|
||||
"pinChangeMustDiffer": "Il nuovo PIN deve essere diverso da quello attuale.",
|
||||
"pinMismatch": "I PIN non corrispondono.",
|
||||
"pinDecoyConflict": "Questo PIN appartiene già alla modalità anti-coercizione. Scegline un altro.",
|
||||
"pinChangeFailed": "Cambio del PIN non riuscito. Riprova.",
|
||||
"pinChanged": "PIN cambiato",
|
||||
"errorCryptoInitTitle": "Il motore di cifratura non si è avviato",
|
||||
"errorStartupTitle": "Šepot non si è avviato",
|
||||
"errorScreenNotRenderedTitle": "Questa schermata non è stata visualizzata",
|
||||
"errorStartupBody": "Qualcosa è andato storto subito all'avvio. Inviami questo testo, per favore (tieni premuto e copia) — mi aiuterà a trovare l'errore.",
|
||||
"errorContactFooter": "sepot.cz · contattami",
|
||||
"pinUnlockTitle": "Sblocca Šepot",
|
||||
"pinEnterPin": "Inserisci il PIN",
|
||||
"pinUnlockWithBiometrics": "Sblocca con biometria",
|
||||
"pinWrong2": "PIN errato",
|
||||
"pinUnlockError": "Errore di sblocco",
|
||||
"pushTitle": "Notifiche push",
|
||||
"pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.",
|
||||
"pushModeUp": "UnifiedPush (consigliato)",
|
||||
"pushModeUpDesc": "Senza Google — richiede l'app ntfy (F-Droid/Play).",
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Affidabile senza altre app. Il token push del dispositivo va a Google; il contenuto dei messaggi mai.",
|
||||
"pushSetupFailed": "Configurazione delle notifiche push non riuscita: {error}",
|
||||
"pushNoDistributorTitle": "Nessun distributore",
|
||||
"pushNoDistributorBody": "Per notifiche senza Google installa un distributore UnifiedPush, ad es. ntfy (F-Droid o Play). Poi riattiva le notifiche push.",
|
||||
"commonOk": "OK",
|
||||
"pushPickDistributorTitle": "Scegli il distributore",
|
||||
"pushSubtitleUnifiedPush": "UnifiedPush · {distributor}",
|
||||
"pushNoDistributorFallback": "nessun distributore",
|
||||
"pushSubtitleFcm": "Google (FCM) — veloce, opt-in",
|
||||
"restoreErrorNameRequired": "Inserisci il nome visualizzato.",
|
||||
"restoreErrorWordCount": "{wordCount, plural, one{La frase ha 1 parola, ne servono {requiredWords}.} other{La frase ha {wordCount} parole, ne servono {requiredWords}.}}",
|
||||
"restoreErrorInvalidPhrase": "La frase non è valida — controlla le parole e il loro ordine.",
|
||||
"restoreErrorFailed": "Ripristino non riuscito: {error}",
|
||||
"restoreTitle": "Ripristina account",
|
||||
"restoreHeading": "Inserisci la tua frase di recupero",
|
||||
"restoreIntro": "{wordCount} parole, dal backup fatto quando hai creato l'account. Ripristina la tua identità (stesso codice SEP, i contatti continueranno a riconoscerti).",
|
||||
"restoreHistoryWarning": "La cronologia dei messaggi non verrà ripristinata — è salvata solo sul dispositivo originale.",
|
||||
"restorePhraseHint": "parola1 parola2 parola3 …",
|
||||
"restoreWordCountHelper": "{wordCount} / {requiredWords} parole",
|
||||
"chatListArchivedCount": "Archiviate ({count})",
|
||||
"chatListEmptyTitle": "Ancora nessuna conversazione",
|
||||
"chatListEmptySubtitle": "Aggiungi un contatto e inizia a sussurrare!",
|
||||
"chatListUnmute": "Riattiva notifiche",
|
||||
"chatListMute": "Silenzia",
|
||||
"chatListArchive": "Archivia",
|
||||
"qrShareTitle": "Il mio Šepot ID",
|
||||
"qrIdentityNotFound": "Identità non trovata",
|
||||
"commonCopiedToClipboard": "Copiato negli appunti",
|
||||
"qrShareInviteMessage": "Aggiungimi su Šepot!\n\n{shareString}",
|
||||
"commonShare": "Condividi",
|
||||
"qrShareInfo": "Il contatto scansiona il tuo codice QR o incolla la stringa di condivisione nell'app.",
|
||||
"reportCategoryChildSafety": "Minore in pericolo",
|
||||
"reportCategoryChildSafetyHint": "Descrivi la situazione con parole tue. Non incollare il contenuto dei messaggi né link al materiale.",
|
||||
"reportCategoryHarassment": "Molestie o minacce",
|
||||
"reportCategoryHarassmentHint": "Qualcuno ti intimidisce, ti minaccia o ti molesta.",
|
||||
"reportCategorySpam": "Spam",
|
||||
"reportCategorySpamHint": "Messaggi indesiderati, invii di massa.",
|
||||
"reportCategoryFraud": "Truffa",
|
||||
"reportCategoryFraudHint": "Un tentativo di ottenere con l'inganno denaro, chiavi o dati personali.",
|
||||
"reportCategoryOther": "Altro",
|
||||
"reportCategoryOtherHint": "Qualcos'altro che dovremmo sapere.",
|
||||
"reportErrorEmptyDescription": "Scrivi cosa è successo, per favore.",
|
||||
"reportErrorConnection": "Impossibile contattare il server. Riprova, oppure scrivi a {supportEmail}.",
|
||||
"reportErrorRateLimited": "Troppe segnalazioni dalla tua rete. Riprova tra un'ora, oppure scrivi a {supportEmail}.",
|
||||
"reportErrorServer": "Invio non riuscito (codice {statusCode}). Scrivi a {supportEmail}, per favore.",
|
||||
"settingsAutoLockNever": "Mai",
|
||||
"settingsAutoLock1min": "Dopo 1 minuto",
|
||||
"settingsAutoLock15min": "Dopo 15 minuti",
|
||||
"torToggleFailed": "Impossibile cambiare lo stato di Tor: {error}",
|
||||
"torConnectionTitle": "Connessione via Tor",
|
||||
"torDialogBody": "Il traffico verso il server passerà dalla rete Tor (nasconde il tuo indirizzo IP). Richiede l'app Orbot installata e in esecuzione, con proxy SOCKS attivo sulla porta 9050.\n\nSenza Orbot in esecuzione i messaggi non vengono né inviati né ricevuti. La connessione tende a essere più lenta.",
|
||||
"commonTurnOn": "Attiva",
|
||||
"torSubtitleOff": "Connessione cifrata diretta (WSS). Attiva per nascondere l'IP via Tor.",
|
||||
"torStatusConnected": "Connesso via Tor (.onion).",
|
||||
"torStatusConnecting": "Connessione via Tor… (Orbot è in esecuzione?)",
|
||||
"torStatusWaiting": "Via Tor — in attesa di Orbot (SOCKS 9050).",
|
||||
"biometricsPinRequiredFirst": "Prima imposta un PIN — la biometria è solo un complemento.",
|
||||
"biometricsStorageLocked": "La memoria protetta è bloccata — sblocca l'app con il PIN.",
|
||||
"biometricsNotAvailable": "Il dispositivo non ha la biometria configurata.",
|
||||
"biometricsEnrollPromptReason": "Conferma l'attivazione dello sblocco biometrico",
|
||||
"biometricsSetupFailed": "Impossibile configurare la biometria: {error}",
|
||||
"biometricsTileTitle": "Biometria",
|
||||
"biometricsTileSubtitle": "Sblocco con impronta / volto (il PIN resta di riserva)",
|
||||
"backupErrorCorruptedFile": "File di backup danneggiato",
|
||||
"backupErrorUnknownFormat": "Formato di backup non riconosciuto",
|
||||
"backupErrorWrongIdentity": "Impossibile decifrare il backup — appartiene a un'altra identità?",
|
||||
"attachmentSharedImageFilename": "sepot-immagine.{ext}",
|
||||
"attachmentDecryptFailed": "Impossibile decifrare l'allegato",
|
||||
"pushActionOpen": "Apri",
|
||||
"pushChannelMessagesName": "Messaggi",
|
||||
"pushChannelMessagesDescription": "Avvisi per i nuovi messaggi (senza contenuto)",
|
||||
"pushNewMessageFrom": "Nuovo messaggio da {senderName}",
|
||||
"pushNewMessages": "Hai nuovi messaggi",
|
||||
"archiveTitle": "Archiviate",
|
||||
"archiveEmpty": "Nessuna conversazione archiviata",
|
||||
"archiveUnarchive": "Rimuovi dall'archivio",
|
||||
"restoreErrorMnemonicWordCount": "La frase ha {count} parole, ne servono {expected}",
|
||||
"restoreErrorInvalidMnemonic": "Frase non valida: {message}",
|
||||
"contactsErrorAlreadyExists": "Il contatto esiste già",
|
||||
"commonAppTagline": "Messenger privato",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Cifrato end-to-end"
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
"appTitle": "Šepot",
|
||||
"settingsLanguage": "Язык приложения",
|
||||
"languageSystem": "Как в системе",
|
||||
"commonUnknownContact": "Неизвестный",
|
||||
"verifyVerified": "Подтверждён",
|
||||
"verifyUnverified": "Не подтверждён",
|
||||
"chatMenuVerifyContact": "Подтвердить контакт",
|
||||
"chatDisappearingMessages": "Исчезающие сообщения",
|
||||
"chatMenuUnblockContact": "Разблокировать контакт",
|
||||
"chatMenuBlockContact": "Заблокировать контакт",
|
||||
"reportAndBlock": "Пожаловаться и заблокировать",
|
||||
"errorGeneric": "Ошибка: {error}",
|
||||
"groupsYouLeftGroup": "Вы больше не в группе",
|
||||
"groupsMemberCount": "{count, plural, one{{count} участник} few{{count} участника} many{{count} участников} other{{count} участника}}",
|
||||
"groupsMembers": "Участники",
|
||||
"groupsRename": "Переименовать группу",
|
||||
"groupsLeave": "Покинуть группу",
|
||||
"groupsLeftBanner": "Вы больше не в группе. История сохранится, но новые сообщения приходить не будут.",
|
||||
"groupsMembersWithCount": "Участники ({count})",
|
||||
"groupsYou": "Вы",
|
||||
"groupsFounder": "Создатель",
|
||||
"groupsNameHint": "Название группы",
|
||||
"commonCancel": "Отмена",
|
||||
"commonSave": "Сохранить",
|
||||
"groupsRenameFailed": "Не удалось переименовать группу: {error}",
|
||||
"groupsLeaveConfirmTitle": "Покинуть группу?",
|
||||
"groupsLeaveConfirmBody": "Остальные увидят, что вы больше не в группе. История у вас сохранится, но новые сообщения приходить не будут, а вернуться можно только по новому приглашению.",
|
||||
"groupsLeaveAction": "Покинуть",
|
||||
"groupsLeaveFailed": "Не удалось покинуть группу: {error}",
|
||||
"chatBlockedBanner": "Контакт заблокирован. Вы не получаете от него сообщения и не можете ему писать.",
|
||||
"chatUnblock": "Разблокировать",
|
||||
"chatContactBlocked": "Контакт {name} заблокирован",
|
||||
"chatContactUnblocked": "Контакт {name} разблокирован",
|
||||
"reportAndBlockBody": "Мы заблокируем контакт {name}, и вы перестанете получать от него сообщения. Затем мы предложим форму, в которой вы опишете, что произошло, — мы не видим содержимое сообщений, поэтому без вашего описания ничего об этом не знаем.",
|
||||
"chatEmptyEncrypted": "Сообщения защищены сквозным шифрованием.",
|
||||
"chatEmptyNoOneElse": "Никто другой не может их прочитать.",
|
||||
"commonToday": "Сегодня",
|
||||
"commonYesterday": "Вчера",
|
||||
"chatMessageHint": "Напишите сообщение…",
|
||||
"chatDiscardRecording": "Удалить запись",
|
||||
"chatRecording": "Идёт запись…",
|
||||
"chatMicPermissionMissing": "Нет доступа к микрофону",
|
||||
"chatRecordingFailed": "Не удалось начать запись: {error}",
|
||||
"chatVoiceSendFailed": "Не удалось отправить голосовое сообщение: {error}",
|
||||
"chatDeletedMessagePreview": "Удалённое сообщение",
|
||||
"chatVoiceMessagePreview": "🎤 Голосовое сообщение",
|
||||
"chatPhotoPreview": "📷 Фото",
|
||||
"chatAttachmentPreview": "📎 Вложение",
|
||||
"chatEditingMessage": "Редактирование сообщения",
|
||||
"chatReply": "Ответ",
|
||||
"chatActionReply": "Ответить",
|
||||
"commonCopy": "Копировать",
|
||||
"commonEdit": "Изменить",
|
||||
"chatDeleteForMe": "Удалить у меня",
|
||||
"chatDeleteForEveryone": "Удалить у всех",
|
||||
"commonOff": "Выключено",
|
||||
"chatDisappearing30s": "30 секунд",
|
||||
"chatDisappearing5m": "5 минут",
|
||||
"chatDisappearing1h": "1 час",
|
||||
"chatDisappearing1d": "1 день",
|
||||
"chatDisappearing1w": "1 неделя",
|
||||
"chatDisappearingDescription": "Новые сообщения будут удаляться по истечении заданного срока и у вас, и у собеседника.",
|
||||
"chatDisappearingDisabled": "Исчезающие сообщения выключены",
|
||||
"chatDisappearingEnabled": "Новые сообщения исчезнут через: {duration}",
|
||||
"attachmentGallery": "Фото из галереи",
|
||||
"attachmentTakePhoto": "Сделать фото",
|
||||
"attachmentDocument": "Документ",
|
||||
"attachmentTooLarge": "Файл слишком большой (макс. {maxMb} МБ)",
|
||||
"attachmentPickFailed": "Не удалось выбрать вложение: {error}",
|
||||
"attachmentSendFailed": "Не удалось отправить вложение: {error}",
|
||||
"chatSendFailed": "Не удалось отправить: {error}",
|
||||
"chatMessageDeleted": "Сообщение удалено",
|
||||
"chatEdited": "изменено",
|
||||
"errorTrustRootChanged": "Trust root релея изменился! Соединение отклонено.",
|
||||
"errorRelayUnavailable": "Релей недоступен (sender certificate)",
|
||||
"errorAttachmentTooLarge": "Вложение слишком большое (макс. {maxMb} МБ)",
|
||||
"errorGroupNeedsMember": "В группе должен быть хотя бы 1 участник",
|
||||
"errorGroupMaxMembers": "Максимальное число участников группы — {maxMembers}",
|
||||
"errorMemberNoDeliveryAddress": "У контакта {displayName} нет адреса доставки — запросите новую ссылку",
|
||||
"errorContactNoDeliveryAddress": "У контакта нет адреса доставки — попросите у него новую ссылку sepot://",
|
||||
"errorIdentityKeyMismatch": "Ключ идентификации с релея НЕ СОВПАДАЕТ с контактом — возможен MITM! Сообщение не отправлено.",
|
||||
"settingsTitle": "Настройки",
|
||||
"settingsSectionGeneral": "Общие",
|
||||
"settingsSectionSecurity": "Безопасность",
|
||||
"settingsChangePinTitle": "Изменить PIN",
|
||||
"settingsChangePinSubtitle": "6-значный PIN для разблокировки",
|
||||
"settingsAutoLockTitle": "Автоблокировка",
|
||||
"settingsAutoLock5min": "Через 5 минут",
|
||||
"settingsDecoyPinTitle": "PIN принуждения",
|
||||
"settingsDecoyPinSetSubtitle": "Установлен — откроет пустое приложение",
|
||||
"settingsDecoyPinUnsetSubtitle": "Второй PIN, открывающий пустое приложение",
|
||||
"settingsSectionNotifications": "Уведомления",
|
||||
"settingsSectionConnection": "Подключение",
|
||||
"settingsSectionPrivacy": "Конфиденциальность",
|
||||
"settingsReadReceiptsTitle": "Отчёты о прочтении",
|
||||
"settingsReadReceiptsSubtitle": "Показывать собеседнику, что сообщения прочитаны (по умолчанию выключено)",
|
||||
"settingsScreenSecurityTitle": "Защита экрана",
|
||||
"settingsScreenSecuritySubtitle": "Блокировать скриншоты и скрывать содержимое в списке недавних приложений",
|
||||
"settingsSectionBackup": "Резервная копия ключей",
|
||||
"settingsSeedPhraseTitle": "Фраза восстановления",
|
||||
"settingsSeedPhraseSubtitle": "24 слова для восстановления идентичности",
|
||||
"settingsBackupExportTitle": "Резервная копия истории",
|
||||
"settingsBackupExportSubtitle": "Зашифрованный файл с контактами и сообщениями",
|
||||
"settingsBackupImportTitle": "Восстановить из копии",
|
||||
"settingsBackupImportSubtitle": "Загрузить резервную копию (только вашей идентичности)",
|
||||
"settingsSectionSafety": "Защита от злоупотреблений",
|
||||
"settingsReportAbuseTitle": "Сообщить о нарушении",
|
||||
"settingsReportAbuseSubtitle": "Домогательства, мошенничество, угроза ребёнку",
|
||||
"settingsChildSafetyTitle": "Стандарты защиты детей",
|
||||
"settingsChildSafetySubtitle": "Как мы противодействуем насилию над детьми",
|
||||
"settingsSectionAbout": "О приложении",
|
||||
"commonAppName": "Šepot",
|
||||
"settingsVersion": "Версия {version}",
|
||||
"settingsLicenseTitle": "Лицензия",
|
||||
"settingsLicenseSubtitle": "AGPL-3.0",
|
||||
"settingsPrivacyPolicyTitle": "Политика конфиденциальности",
|
||||
"settingsPrivacyPolicySubtitle": "Что мы о вас знаем (и чего не знаем)",
|
||||
"settingsTermsTitle": "Условия использования",
|
||||
"settingsTermsSubtitle": "sepot.cz/podminky",
|
||||
"settingsDataDeletionTitle": "Удаление данных",
|
||||
"settingsDataDeletionSubtitle": "Как запросить удаление данных",
|
||||
"settingsDeleteAllDataButton": "Удалить все данные",
|
||||
"settingsLicenseBody": "Šepot распространяется по лицензии GNU Affero General Public License версии 3 (AGPL-3.0). Приложение использует библиотеку libsignal, также под AGPL-3.0.\n\nЧтобы получить исходный код этой версии, напишите на {supportEmail}, и мы вам его отправим.",
|
||||
"commonClose": "Закрыть",
|
||||
"settingsIdCopied": "ID скопирован",
|
||||
"settingsFingerprint": "Отпечаток: {fingerprint}",
|
||||
"backupShareSubject": "Šepot — зашифрованная резервная копия",
|
||||
"backupExportFailed": "Не удалось создать резервную копию: {error}",
|
||||
"backupRestoredCount": "{count, plural, one{Из резервной копии восстановлен {count} чат} few{Из резервной копии восстановлены {count} чата} many{Из резервной копии восстановлено {count} чатов} other{Из резервной копии восстановлено {count} чата}}",
|
||||
"backupImportFailed": "Не удалось восстановить из копии: {error}",
|
||||
"settingsDecoyPinExplain": "При вводе этого PIN приложение откроется пустым — без ваших контактов и сообщений. Он должен отличаться от обычного PIN.",
|
||||
"commonConfirm": "Подтвердить",
|
||||
"commonSet": "Установить",
|
||||
"settingsDecoyPinEnabled": "PIN принуждения установлен",
|
||||
"settingsDecoyPinConflict": "PIN принуждения должен отличаться от обычного",
|
||||
"settingsDecoyPinDisabled": "PIN принуждения отключён",
|
||||
"settingsSeedPhraseNotFound": "Фраза восстановления не найдена",
|
||||
"settingsSeedPhraseWarning": "Эти слова — единственная резервная копия вашей идентичности. Никому их не показывайте!",
|
||||
"settingsSeedWordChip": "{index}. {word}",
|
||||
"settingsAboutTagline": "Šepot — приватный мессенджер",
|
||||
"settingsAboutBody": "E2E-шифрованный мессенджер с открытым исходным кодом.\n\nКриптография:\n• X25519 (обмен ключами)\n• XChaCha20-Poly1305 (шифрование)\n• Ed25519 (подписи)\n\n© 2026 ajtak.it",
|
||||
"settingsDeleteAccountTitle": "Удалить аккаунт и все данные?",
|
||||
"settingsDeleteAccountBody": "Это действие необратимо. Все сообщения, контакты и ключи будут удалены с этого устройства, а аккаунт будет снят с регистрации на сервере. Без фразы восстановления вы потеряете свою идентичность навсегда.",
|
||||
"settingsDeleteAccountServerOffline": "Сервер сейчас недоступен — данные на устройстве удалены, но регистрация на сервере осталась. Остальное удалим по запросу на podpora@sepot.cz.",
|
||||
"settingsDeleteAllConfirm": "Удалить всё",
|
||||
"groupsCreateFailed": "Не удалось создать группу: {error}",
|
||||
"groupsNewGroupTitle": "Новая группа",
|
||||
"groupsCreateAction": "Создать",
|
||||
"groupsMembersCounter": "Участники ({selectedCount}/{maxMembers})",
|
||||
"groupsEmptyContactsHint": "Сначала добавьте контакты — группа состоит из людей, с которыми вы уже можете переписываться.",
|
||||
"groupsMemberMissingAddress": "Нет адреса доставки — запросите новую ссылку",
|
||||
"attachmentSizeBytes": "{bytes} Б",
|
||||
"attachmentSizeKb": "{kilobytes} КБ",
|
||||
"attachmentSizeMb": "{megabytes} МБ",
|
||||
"attachmentDownloadFailedTapToRetry": "Не удалось скачать — нажмите, чтобы повторить",
|
||||
"attachmentDownloadFailed": "Не удалось скачать",
|
||||
"attachmentDownloading": "Скачивание…",
|
||||
"attachmentSharedFileFallbackName": "файл",
|
||||
"attachmentOpenFailed": "Не удалось открыть вложение: {error}",
|
||||
"commonFile": "Файл",
|
||||
"chatListNewConversation": "Новый чат",
|
||||
"chatListPhotoWithCaptionPreview": "📷 {caption}",
|
||||
"chatListFilePreview": "📎 {fileName}",
|
||||
"chatListReadReceiptPreview": "Прочитано",
|
||||
"chatListTimeNow": "сейчас",
|
||||
"chatListTimeMinutes": "{minutes} мин",
|
||||
"qrScanTitle": "Сканировать QR-код",
|
||||
"qrScanInstruction": "Наведите камеру на QR-код контакта",
|
||||
"qrScanHint": "QR-код содержит открытый ключ контакта",
|
||||
"qrNewContactTitle": "Новый контакт",
|
||||
"qrContactAdded": "Контакт {name} добавлен",
|
||||
"commonAdd": "Добавить",
|
||||
"onboardingWelcomeTitle": "Добро пожаловать в Šepot",
|
||||
"onboardingWelcomeSubtitle": "Приватный мессенджер со сквозным шифрованием.\nБез номера телефона. Без электронной почты.\nТолько ваши ключи.",
|
||||
"onboardingStart": "Начать",
|
||||
"onboardingRestoreExisting": "У меня уже есть аккаунт — восстановить из фразы",
|
||||
"onboardingEncryptionTitle": "Ваши сообщения, ваши ключи",
|
||||
"onboardingFeatureSignalTitle": "Протокол Signal",
|
||||
"onboardingFeatureSignalSubtitle": "X3DH + Double Ratchet, forward secrecy",
|
||||
"onboardingFeaturePqTitle": "Постквантовая защита",
|
||||
"onboardingFeaturePqSubtitle": "Kyber (PQXDH) с первого сообщения",
|
||||
"onboardingFeatureLocalKeysTitle": "Ключи только на вашем устройстве",
|
||||
"onboardingFeatureLocalKeysSubtitle": "Ни у одного сервера их нет",
|
||||
"onboardingFeatureDisappearingSubtitle": "Удаляются автоматически",
|
||||
"commonContinue": "Продолжить",
|
||||
"onboardingIdentityTitle": "Создайте свою идентичность",
|
||||
"onboardingIdentitySubtitle": "Без почты и телефона. Только имя и криптографические ключи.",
|
||||
"onboardingDisplayNameLabel": "Отображаемое имя",
|
||||
"onboardingDisplayNameHint": "Например, Иван",
|
||||
"onboardingSeedPhraseWarning": "Запишите {count, plural, one{это {count} слово} few{эти {count} слова} many{эти {count} слов} other{эти {count} слова}}. Это единственная резервная копия ваших ключей!",
|
||||
"onboardingSeedSavedConfirm": "Подтверждаю: фраза надёжно сохранена",
|
||||
"onboardingGenerateKeys": "Сгенерировать ключи",
|
||||
"verifyMismatchTitle": "Номера НЕ СОВПАДАЮТ",
|
||||
"verifyMatchBody": "Ключи совпадают. {contactName} — подтверждённый контакт.",
|
||||
"verifyMismatchBody": "Номера безопасности не совпадают. Возможно, кто-то перехватывает соединение (MITM), либо вы сканируете код другого контакта. НЕ отправляйте конфиденциальные сообщения и повторите сверку по другому каналу.",
|
||||
"commonUnderstood": "Понятно",
|
||||
"verifyManualConfirmTitle": "Отметить как подтверждённый?",
|
||||
"verifyManualConfirmBody": "Подтверждайте только в том случае, если вы сверили цифры с собеседником по другому надёжному каналу (лично, по звонку…) и они совпадают.",
|
||||
"verifyManualConfirmAction": "Совпадают, подтвердить",
|
||||
"verifyTitle": "Подтверждение контакта",
|
||||
"verifyContactVerifiedBanner": "Контакт подтверждён",
|
||||
"verifyIntro": "Сравните этот номер с тем, который видит {contactName}. Если номера совпадают, ваше шифрование никто не прослушивает. Быстрее всего — взаимно отсканировать QR-коды.",
|
||||
"verifyScanButton": "Сканировать код собеседника",
|
||||
"verifyManualButton": "Цифры совпадают — отметить вручную",
|
||||
"verifyUnverifyButton": "Отменить подтверждение",
|
||||
"verifyScanScreenTitle": "Отсканируйте QR для сверки",
|
||||
"reportTitle": "Пожаловаться",
|
||||
"reportSubmitted": "Жалоба отправлена",
|
||||
"reportSubmittedChildSafety": "Такие жалобы мы рассматриваем в первую очередь. Если ребёнок в непосредственной опасности, немедленно обратитесь в местную полицию или на телефон доверия.",
|
||||
"reportSubmittedThanks": "Спасибо. Мы рассмотрим её как можно скорее.",
|
||||
"commonDone": "Готово",
|
||||
"reportPanelE2eNotice": "Сообщения защищены сквозным шифрованием, поэтому их содержимое нам не видно даже сейчас. Пожалуйста, опишите своими словами, что произошло. Не вставляйте сюда содержимое сообщений или ссылки на него — мы не имеем права его хранить, и для рассмотрения оно нам не нужно.",
|
||||
"reportPanelCsamWarning": "НЕ ОТПРАВЛЯЙТЕ нам материалы, изображающие сексуальное насилие над детьми. Их хранение уголовно наказуемо в том числе для нас, и для жалобы они не нужны. Такие материалы следует передавать напрямую в местную полицию или на официальную горячую линию по борьбе с CSAM. Нам напишите только то, что произошло, — мы заблокируем аккаунт и передадим дело дальше.",
|
||||
"reportPanelIpNotice": "Жалоба отправляется не через релей, а на наш веб-сервер. При отправке он узнает ваш IP-адрес.",
|
||||
"reportCategoryHeading": "О чём жалоба",
|
||||
"reportDescriptionHeading": "Что произошло",
|
||||
"reportAttachContactId": "Приложить ID нарушителя",
|
||||
"reportAttachContactIdNamed": "Приложить ID контакта {contactName}",
|
||||
"reportReplyToHeading": "Как с вами связаться (необязательно)",
|
||||
"reportReplyToHint": "e-mail на случай уточняющих вопросов",
|
||||
"reportSubmitButton": "Отправить жалобу",
|
||||
"contactsTitle": "Контакты",
|
||||
"contactsAddContact": "Добавить контакт",
|
||||
"contactsSearchHint": "Поиск контактов...",
|
||||
"contactsEmpty": "Пока нет контактов",
|
||||
"contactsNoResults": "Ничего не найдено",
|
||||
"contactsShareCardTitle": "Поделитесь своим Šepot ID",
|
||||
"contactsShareCardSubtitle": "Покажите QR-код или отправьте ссылку",
|
||||
"contactsAddDialogPrompt": "Вставьте строку обмена (share string) контакта:",
|
||||
"contactsShareStringHint": "Вставить share string...",
|
||||
"contactsInvalidShareString": "Недействительный share string",
|
||||
"contactsAdded": "Контакт {name} добавлен",
|
||||
"verifyContactSubtitle": "Сверка номера безопасности",
|
||||
"contactsDeleteContact": "Удалить контакт",
|
||||
"pinConfirmTitle": "Подтвердите PIN",
|
||||
"pinSetupTitle": "Задайте PIN",
|
||||
"pinConfirmSubtitle": "Введите PIN ещё раз для подтверждения",
|
||||
"pinSetupSubtitle": "6-значный PIN для разблокировки приложения",
|
||||
"pinSetupFailed": "Не удалось установить PIN. Попробуйте ещё раз.",
|
||||
"pinMismatchRetry": "PIN-коды не совпадают. Попробуйте ещё раз.",
|
||||
"pinChangeCurrentTitle": "Введите текущий PIN",
|
||||
"pinChangeCurrentSubtitle": "Мы проверим его, разблокировав ключ",
|
||||
"pinChangeNewTitle": "Введите новый PIN",
|
||||
"pinChangeNewSubtitle": "6 цифр",
|
||||
"pinChangeConfirmTitle": "Подтвердите новый PIN",
|
||||
"pinChangeConfirmSubtitle": "Введите его ещё раз",
|
||||
"pinWrong": "Неверный PIN.",
|
||||
"pinChangeMustDiffer": "Новый PIN должен отличаться от текущего.",
|
||||
"pinMismatch": "PIN-коды не совпадают.",
|
||||
"pinDecoyConflict": "Этот PIN уже используется режимом принуждения. Выберите другой.",
|
||||
"pinChangeFailed": "Не удалось изменить PIN. Попробуйте ещё раз.",
|
||||
"pinChanged": "PIN изменён",
|
||||
"errorCryptoInitTitle": "Криптографическое ядро не запустилось",
|
||||
"errorStartupTitle": "Šepot не запустился",
|
||||
"errorScreenNotRenderedTitle": "Этот экран не отрисовался",
|
||||
"errorStartupBody": "Что-то пошло не так прямо при запуске. Пожалуйста, отправьте мне этот текст (удерживайте палец и скопируйте) — это поможет найти ошибку.",
|
||||
"errorContactFooter": "sepot.cz · напишите мне через контакты на сайте",
|
||||
"pinUnlockTitle": "Разблокируйте Šepot",
|
||||
"pinEnterPin": "Введите PIN",
|
||||
"pinUnlockWithBiometrics": "Разблокировать биометрией",
|
||||
"pinWrong2": "Неверный PIN",
|
||||
"pinUnlockError": "Ошибка разблокировки",
|
||||
"pushTitle": "Push-уведомления",
|
||||
"pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.",
|
||||
"pushModeUp": "UnifiedPush (рекомендуется)",
|
||||
"pushModeUpDesc": "Без сервисов Google — требуется приложение ntfy (F-Droid/Play).",
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Надёжно и без дополнительных приложений. Push-токен устройства передаётся Google; содержимое сообщений — никогда.",
|
||||
"pushSetupFailed": "Не удалось настроить push: {error}",
|
||||
"pushNoDistributorTitle": "Нет дистрибьютора",
|
||||
"pushNoDistributorBody": "Для уведомлений без сервисов Google установите дистрибьютор UnifiedPush, например ntfy (F-Droid или Play). Затем включите push снова.",
|
||||
"commonOk": "OK",
|
||||
"pushPickDistributorTitle": "Выберите дистрибьютора",
|
||||
"pushSubtitleUnifiedPush": "UnifiedPush · {distributor}",
|
||||
"pushNoDistributorFallback": "без дистрибьютора",
|
||||
"pushSubtitleFcm": "Google (FCM) — быстро, только по вашему выбору",
|
||||
"restoreErrorNameRequired": "Введите отображаемое имя.",
|
||||
"restoreErrorWordCount": "{wordCount, plural, one{Во фразе {wordCount} слово, а нужно {requiredWords}.} few{Во фразе {wordCount} слова, а нужно {requiredWords}.} many{Во фразе {wordCount} слов, а нужно {requiredWords}.} other{Во фразе {wordCount} слова, а нужно {requiredWords}.}}",
|
||||
"restoreErrorInvalidPhrase": "Фраза недействительна — проверьте слова и их порядок.",
|
||||
"restoreErrorFailed": "Не удалось восстановить: {error}",
|
||||
"restoreTitle": "Восстановить аккаунт",
|
||||
"restoreHeading": "Введите свою фразу восстановления",
|
||||
"restoreIntro": "{wordCount, plural, one{{wordCount} слово} few{{wordCount} слова} many{{wordCount} слов} other{{wordCount} слова}} из резервной копии, сделанной при создании аккаунта. Фраза восстановит вашу идентичность (тот же код SEP, контакты по-прежнему вас узнают).",
|
||||
"restoreHistoryWarning": "История сообщений не восстановится — она хранится только на исходном устройстве.",
|
||||
"restorePhraseHint": "слово1 слово2 слово3 …",
|
||||
"restoreWordCountHelper": "{wordCount} / {requiredWords} слов",
|
||||
"chatListArchivedCount": "Архив ({count})",
|
||||
"chatListEmptyTitle": "Пока нет чатов",
|
||||
"chatListEmptySubtitle": "Добавьте контакт и начните шептаться!",
|
||||
"chatListUnmute": "Включить звук",
|
||||
"chatListMute": "Отключить звук",
|
||||
"chatListArchive": "Архивировать",
|
||||
"qrShareTitle": "Мой Šepot ID",
|
||||
"qrIdentityNotFound": "Идентичность не найдена",
|
||||
"commonCopiedToClipboard": "Скопировано в буфер обмена",
|
||||
"qrShareInviteMessage": "Добавьте меня в Šepot!\n\n{shareString}",
|
||||
"commonShare": "Поделиться",
|
||||
"qrShareInfo": "Контакт отсканирует ваш QR-код или вставит строку обмена в приложение.",
|
||||
"reportCategoryChildSafety": "Угроза ребёнку",
|
||||
"reportCategoryChildSafetyHint": "Опишите ситуацию своими словами. Не вставляйте содержимое сообщений или ссылки на материалы.",
|
||||
"reportCategoryHarassment": "Домогательства или угрозы",
|
||||
"reportCategoryHarassmentHint": "Кто-то запугивает вас, угрожает или домогается.",
|
||||
"reportCategorySpam": "Спам",
|
||||
"reportCategorySpamHint": "Нежелательные сообщения, массовые рассылки.",
|
||||
"reportCategoryFraud": "Мошенничество",
|
||||
"reportCategoryFraudHint": "Попытка выманить деньги, ключи или личные данные.",
|
||||
"reportCategoryOther": "Другое",
|
||||
"reportCategoryOtherHint": "Что-то ещё, о чём нам стоит знать.",
|
||||
"reportErrorEmptyDescription": "Пожалуйста, напишите, что произошло.",
|
||||
"reportErrorConnection": "Не удалось связаться с сервером. Попробуйте ещё раз или напишите на {supportEmail}.",
|
||||
"reportErrorRateLimited": "Из вашей сети поступило слишком много жалоб. Попробуйте через час или напишите на {supportEmail}.",
|
||||
"reportErrorServer": "Не удалось отправить (код {statusCode}). Пожалуйста, напишите на {supportEmail}.",
|
||||
"settingsAutoLockNever": "Никогда",
|
||||
"settingsAutoLock1min": "Через 1 минуту",
|
||||
"settingsAutoLock15min": "Через 15 минут",
|
||||
"torToggleFailed": "Не удалось переключить Tor: {error}",
|
||||
"torConnectionTitle": "Подключение через Tor",
|
||||
"torDialogBody": "Трафик на сервер пойдёт через сеть Tor (это скроет ваш IP-адрес). Требуется установленное и запущенное приложение Orbot с активным SOCKS-прокси на порту 9050.\n\nБез работающего Orbot сообщения не будут ни отправляться, ни приниматься. Соединение обычно медленнее.",
|
||||
"commonTurnOn": "Включить",
|
||||
"torSubtitleOff": "Прямое зашифрованное соединение (WSS). Включите, чтобы скрыть IP через Tor.",
|
||||
"torStatusConnected": "Подключено через Tor (.onion).",
|
||||
"torStatusConnecting": "Подключение через Tor… (Orbot запущен?)",
|
||||
"torStatusWaiting": "Через Tor — ожидание Orbot (SOCKS 9050).",
|
||||
"biometricsPinRequiredFirst": "Сначала задайте PIN — биометрия лишь дополняет его.",
|
||||
"biometricsStorageLocked": "Хранилище заблокировано — разблокируйте приложение PIN-кодом.",
|
||||
"biometricsNotAvailable": "На устройстве не настроена биометрия.",
|
||||
"biometricsEnrollPromptReason": "Подтвердите включение биометрической разблокировки",
|
||||
"biometricsSetupFailed": "Не удалось настроить биометрию: {error}",
|
||||
"biometricsTileTitle": "Биометрия",
|
||||
"biometricsTileSubtitle": "Разблокировка отпечатком пальца / лицом (PIN остаётся запасным способом)",
|
||||
"backupErrorCorruptedFile": "Файл резервной копии повреждён",
|
||||
"backupErrorUnknownFormat": "Нераспознанный формат резервной копии",
|
||||
"backupErrorWrongIdentity": "Не удалось расшифровать копию — она принадлежит другой идентичности?",
|
||||
"attachmentSharedImageFilename": "sepot-image.{ext}",
|
||||
"attachmentDecryptFailed": "Не удалось расшифровать вложение",
|
||||
"pushActionOpen": "Открыть",
|
||||
"pushChannelMessagesName": "Сообщения",
|
||||
"pushChannelMessagesDescription": "Уведомления о новых сообщениях (без содержимого)",
|
||||
"pushNewMessageFrom": "Новое сообщение от {senderName}",
|
||||
"pushNewMessages": "У вас новые сообщения",
|
||||
"archiveTitle": "Архив",
|
||||
"archiveEmpty": "Нет архивированных чатов",
|
||||
"archiveUnarchive": "Вернуть из архива",
|
||||
"restoreErrorMnemonicWordCount": "{count, plural, one{Во фразе {count} слово, ожидается {expected}} few{Во фразе {count} слова, ожидается {expected}} many{Во фразе {count} слов, ожидается {expected}} other{Во фразе {count} слова, ожидается {expected}}}",
|
||||
"restoreErrorInvalidMnemonic": "Недействительная фраза: {message}",
|
||||
"contactsErrorAlreadyExists": "Контакт уже существует",
|
||||
"commonAppTagline": "Приватный мессенджер",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Сквозное шифрование"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:sepot/app.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/push/push_bootstrap.dart';
|
||||
|
||||
/// Proces nastartoval Android kvůli pushi, bez UI — `runApp` se nesmí zavolat
|
||||
/// ani ze záchranného handleru.
|
||||
bool _bezUi = false;
|
||||
|
||||
/// Reálné UI už běží. Pozdější async chyba ho nesmí přepsat chybovou obrazovkou.
|
||||
bool _uiNabehla = false;
|
||||
|
||||
/// Start aplikace.
|
||||
///
|
||||
/// KRITICKÉ: mezi startem a prvním `runApp()` nesmí zůstat nechráněné `await`.
|
||||
/// Když nativní init (libsignal) na zařízení selže, appka nesmí skončit němou
|
||||
/// bílou obrazovkou — místo toho ukáže chybu, aby šla diagnostikovat.
|
||||
void main(List<String> args) {
|
||||
runZonedGuarded(() async {
|
||||
// ÚPLNĚ PRVNÍ: kdyby cokoli níž (i ensureInitialized) hodilo, záchranný
|
||||
// handler musí vědět, že v headless push startu runApp volat nesmí.
|
||||
// Čteme z args nezávisle na PushBootstrap, aby to platilo i kdyby jeho
|
||||
// init selhal.
|
||||
_bezUi = args.contains('--unifiedpush-bg');
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Bez tohohle vykreslí release build chybu v build() jako němou šedou
|
||||
// plochu bez textu. V debug si necháme červenou obrazovku Flutteru.
|
||||
if (kReleaseMode) {
|
||||
ErrorWidget.builder =
|
||||
(details) => _ChybovaPlocha(detail: details.exceptionAsString());
|
||||
|
||||
// network_security_config hlídá jen platformní HTTP stack, ne dart:io
|
||||
// sockety. `--dart-define=SEPOT_RELAY=http://…` by tedy v release
|
||||
// buildu tiše poslal provoz v plaintextu. Radši tvrdý pád.
|
||||
final relay = Uri.parse(AppConstants.relayUrl);
|
||||
if (relay.scheme != 'https' && relay.scheme != 'wss') {
|
||||
throw StateError(
|
||||
'Release build must talk https/wss, not "${relay.scheme}".',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Chyby render pipeline zalogovat, ale NEshazovat kvůli nim běžící UI.
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
debugPrint('FlutterError: ${details.exceptionAsString()}');
|
||||
};
|
||||
|
||||
// Formáty dat všech podporovaných jazyků — nekritické, appka poběží i bez nich
|
||||
for (final lang in const ['cs', 'en', 'de', 'ru', 'it']) {
|
||||
try {
|
||||
await initializeDateFormatting(lang);
|
||||
} catch (e, s) {
|
||||
debugPrint('initializeDateFormatting($lang) selhalo (nekritické): $e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
// UnifiedPush callbacky VŽDY před runApp — Android budí zabitou appku
|
||||
// s `--unifiedpush-bg` bez UI (killed-state push budíček).
|
||||
try {
|
||||
await PushBootstrap.init(args);
|
||||
} catch (e, s) {
|
||||
// Push je nekritický: bez něj appka funguje, jen nechodí budíčky.
|
||||
debugPrint('PushBootstrap.init selhalo (nekritické): $e\n$s');
|
||||
}
|
||||
if (_bezUi || PushBootstrap.backgroundStart) {
|
||||
// Bez UI: onMessage jen ukáže generickou notifikaci (DB je zamčená,
|
||||
// dešifrovat nejde). Engine drží plugin, runApp se NEvolá.
|
||||
return;
|
||||
}
|
||||
|
||||
// Nativní krypto backend (libsignal) — musí běžet dřív než cokoli,
|
||||
// co sahá na identitu. Nejčastější příčina bílé obrazovky na iOS.
|
||||
// Timeout kryje i zásek (init nevrátí) — nesmí viset donekonečna.
|
||||
try {
|
||||
await SepotCrypto.init().timeout(const Duration(seconds: 15));
|
||||
} catch (e, s) {
|
||||
debugPrint('SepotCrypto.init selhalo: $e\n$s');
|
||||
// Chybová cesta: keystore může být sám rozbitý, uloženou volbu jazyka
|
||||
// nečteme — jen systémový jazyk.
|
||||
runApp(_ChybaApp(
|
||||
titulek: l10nFor(null).errorCryptoInitTitle,
|
||||
detail: '$e',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Zamkni orientaci na portrét
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
|
||||
// Tmavý status bar
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Color(0xFF0B0C12),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
));
|
||||
|
||||
// Úložiště startuje ZAMČENÉ — odemyká se až v loading/pin-lock flow
|
||||
_spustUi(const ProviderScope(child: SepotApp()));
|
||||
}, (error, stack) {
|
||||
debugPrint('Neodchycená chyba při startu: $error\n$stack');
|
||||
// Headless push nemá UI, kam chybu vykreslit.
|
||||
if (_bezUi) return;
|
||||
// Appka už jede — pozdější async chyba ji nesmí shodit do chybovky.
|
||||
if (_uiNabehla) return;
|
||||
runApp(_ChybaApp(titulek: l10nFor(null).errorStartupTitle, detail: '$error'));
|
||||
});
|
||||
}
|
||||
|
||||
void _spustUi(Widget app) {
|
||||
_uiNabehla = true;
|
||||
runApp(app);
|
||||
}
|
||||
|
||||
/// Náhrada za výchozí `ErrorWidget` — ten v release módu kreslí prázdnou
|
||||
/// šedou plochu bez jediného slova. Nesmí spoléhat na `MaterialApp` nad sebou.
|
||||
class _ChybovaPlocha extends StatelessWidget {
|
||||
final String detail;
|
||||
|
||||
const _ChybovaPlocha({required this.detail});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: ColoredBox(
|
||||
color: const Color(0xFF0B0C12),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10nFor(null).errorScreenNotRenderedTitle,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFF0EEE8),
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SelectableText(
|
||||
detail,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFF0795A),
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimální chybová obrazovka — bez závislosti na krypto/providerech,
|
||||
/// aby se vykreslila i když selže start. Text jde označit a zkopírovat.
|
||||
class _ChybaApp extends StatelessWidget {
|
||||
final String titulek;
|
||||
final String detail;
|
||||
|
||||
const _ChybaApp({required this.titulek, required this.detail});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
backgroundColor: const Color(0xFF0B0C12),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Text(
|
||||
titulek,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFF0EEE8),
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10nFor(null).errorStartupBody,
|
||||
style: const TextStyle(color: Color(0xFF9AA0B5), fontSize: 15, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF12141D),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF262A3A)),
|
||||
),
|
||||
child: SelectableText(
|
||||
detail,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFF0795A),
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
l10nFor(null).errorContactFooter,
|
||||
style: const TextStyle(color: Color(0xFF626880), fontSize: 12.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {},
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
// This is a generated file - do not edit.
|
||||
//
|
||||
// Generated from sepot.proto.
|
||||
|
||||
// @dart = 3.3
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: curly_braces_in_flow_control_structures
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
|
||||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
class ContentHint extends $pb.ProtobufEnum {
|
||||
static const ContentHint CONTENT_HINT_DEFAULT =
|
||||
ContentHint._(0, _omitEnumNames ? '' : 'CONTENT_HINT_DEFAULT');
|
||||
static const ContentHint CONTENT_HINT_RESYNC =
|
||||
ContentHint._(1, _omitEnumNames ? '' : 'CONTENT_HINT_RESYNC');
|
||||
static const ContentHint CONTENT_HINT_SUPPLEMENTARY =
|
||||
ContentHint._(2, _omitEnumNames ? '' : 'CONTENT_HINT_SUPPLEMENTARY');
|
||||
|
||||
static const $core.List<ContentHint> values = <ContentHint>[
|
||||
CONTENT_HINT_DEFAULT,
|
||||
CONTENT_HINT_RESYNC,
|
||||
CONTENT_HINT_SUPPLEMENTARY,
|
||||
];
|
||||
|
||||
static final $core.List<ContentHint?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 2);
|
||||
static ContentHint? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const ContentHint._(super.value, super.name);
|
||||
}
|
||||
|
||||
class Receipt_Status extends $pb.ProtobufEnum {
|
||||
static const Receipt_Status STATUS_QUEUED =
|
||||
Receipt_Status._(0, _omitEnumNames ? '' : 'STATUS_QUEUED');
|
||||
static const Receipt_Status STATUS_DELIVERED_ONLINE =
|
||||
Receipt_Status._(1, _omitEnumNames ? '' : 'STATUS_DELIVERED_ONLINE');
|
||||
static const Receipt_Status STATUS_STORED_OFFLINE =
|
||||
Receipt_Status._(2, _omitEnumNames ? '' : 'STATUS_STORED_OFFLINE');
|
||||
|
||||
static const $core.List<Receipt_Status> values = <Receipt_Status>[
|
||||
STATUS_QUEUED,
|
||||
STATUS_DELIVERED_ONLINE,
|
||||
STATUS_STORED_OFFLINE,
|
||||
];
|
||||
|
||||
static final $core.List<Receipt_Status?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 2);
|
||||
static Receipt_Status? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const Receipt_Status._(super.value, super.name);
|
||||
}
|
||||
|
||||
class RegisterPush_Platform extends $pb.ProtobufEnum {
|
||||
static const RegisterPush_Platform PLATFORM_NONE =
|
||||
RegisterPush_Platform._(0, _omitEnumNames ? '' : 'PLATFORM_NONE');
|
||||
static const RegisterPush_Platform PLATFORM_FCM =
|
||||
RegisterPush_Platform._(1, _omitEnumNames ? '' : 'PLATFORM_FCM');
|
||||
static const RegisterPush_Platform PLATFORM_APNS =
|
||||
RegisterPush_Platform._(2, _omitEnumNames ? '' : 'PLATFORM_APNS');
|
||||
static const RegisterPush_Platform PLATFORM_UNIFIEDPUSH =
|
||||
RegisterPush_Platform._(3, _omitEnumNames ? '' : 'PLATFORM_UNIFIEDPUSH');
|
||||
static const RegisterPush_Platform PLATFORM_WEBPUSH =
|
||||
RegisterPush_Platform._(4, _omitEnumNames ? '' : 'PLATFORM_WEBPUSH');
|
||||
|
||||
static const $core.List<RegisterPush_Platform> values =
|
||||
<RegisterPush_Platform>[
|
||||
PLATFORM_NONE,
|
||||
PLATFORM_FCM,
|
||||
PLATFORM_APNS,
|
||||
PLATFORM_UNIFIEDPUSH,
|
||||
PLATFORM_WEBPUSH,
|
||||
];
|
||||
|
||||
static final $core.List<RegisterPush_Platform?> _byValue =
|
||||
$pb.ProtobufEnum.$_initByValueList(values, 4);
|
||||
static RegisterPush_Platform? valueOf($core.int value) =>
|
||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||
|
||||
const RegisterPush_Platform._(super.value, super.name);
|
||||
}
|
||||
|
||||
const $core.bool _omitEnumNames =
|
||||
$core.bool.fromEnvironment('protobuf.omit_enum_names');
|
||||
@@ -0,0 +1,490 @@
|
||||
// This is a generated file - do not edit.
|
||||
//
|
||||
// Generated from sepot.proto.
|
||||
|
||||
// @dart = 3.3
|
||||
|
||||
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: curly_braces_in_flow_control_structures
|
||||
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
|
||||
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
|
||||
// ignore_for_file: unused_import
|
||||
|
||||
import 'dart:convert' as $convert;
|
||||
import 'dart:core' as $core;
|
||||
import 'dart:typed_data' as $typed_data;
|
||||
|
||||
@$core.Deprecated('Use contentHintDescriptor instead')
|
||||
const ContentHint$json = {
|
||||
'1': 'ContentHint',
|
||||
'2': [
|
||||
{'1': 'CONTENT_HINT_DEFAULT', '2': 0},
|
||||
{'1': 'CONTENT_HINT_RESYNC', '2': 1},
|
||||
{'1': 'CONTENT_HINT_SUPPLEMENTARY', '2': 2},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `ContentHint`. Decode as a `google.protobuf.EnumDescriptorProto`.
|
||||
final $typed_data.Uint8List contentHintDescriptor = $convert.base64Decode(
|
||||
'CgtDb250ZW50SGludBIYChRDT05URU5UX0hJTlRfREVGQVVMVBAAEhcKE0NPTlRFTlRfSElOVF'
|
||||
'9SRVNZTkMQARIeChpDT05URU5UX0hJTlRfU1VQUExFTUVOVEFSWRAC');
|
||||
|
||||
@$core.Deprecated('Use envelopeDescriptor instead')
|
||||
const Envelope$json = {
|
||||
'1': 'Envelope',
|
||||
'2': [
|
||||
{'1': 'dst_address', '3': 1, '4': 1, '5': 12, '10': 'dstAddress'},
|
||||
{'1': 'msg_uuid', '3': 2, '4': 1, '5': 12, '10': 'msgUuid'},
|
||||
{
|
||||
'1': 'content_hint',
|
||||
'3': 3,
|
||||
'4': 1,
|
||||
'5': 14,
|
||||
'6': '.sepot.v1.ContentHint',
|
||||
'10': 'contentHint'
|
||||
},
|
||||
{'1': 'payload', '3': 4, '4': 1, '5': 12, '10': 'payload'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Envelope`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List envelopeDescriptor = $convert.base64Decode(
|
||||
'CghFbnZlbG9wZRIfCgtkc3RfYWRkcmVzcxgBIAEoDFIKZHN0QWRkcmVzcxIZCghtc2dfdXVpZB'
|
||||
'gCIAEoDFIHbXNnVXVpZBI4Cgxjb250ZW50X2hpbnQYAyABKA4yFS5zZXBvdC52MS5Db250ZW50'
|
||||
'SGludFILY29udGVudEhpbnQSGAoHcGF5bG9hZBgEIAEoDFIHcGF5bG9hZA==');
|
||||
|
||||
@$core.Deprecated('Use ackDescriptor instead')
|
||||
const Ack$json = {
|
||||
'1': 'Ack',
|
||||
'2': [
|
||||
{'1': 'msg_uuid', '3': 1, '4': 1, '5': 12, '10': 'msgUuid'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Ack`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List ackDescriptor =
|
||||
$convert.base64Decode('CgNBY2sSGQoIbXNnX3V1aWQYASABKAxSB21zZ1V1aWQ=');
|
||||
|
||||
@$core.Deprecated('Use receiptDescriptor instead')
|
||||
const Receipt$json = {
|
||||
'1': 'Receipt',
|
||||
'2': [
|
||||
{'1': 'msg_uuid', '3': 1, '4': 1, '5': 12, '10': 'msgUuid'},
|
||||
{
|
||||
'1': 'status',
|
||||
'3': 2,
|
||||
'4': 1,
|
||||
'5': 14,
|
||||
'6': '.sepot.v1.Receipt.Status',
|
||||
'10': 'status'
|
||||
},
|
||||
],
|
||||
'4': [Receipt_Status$json],
|
||||
};
|
||||
|
||||
@$core.Deprecated('Use receiptDescriptor instead')
|
||||
const Receipt_Status$json = {
|
||||
'1': 'Status',
|
||||
'2': [
|
||||
{'1': 'STATUS_QUEUED', '2': 0},
|
||||
{'1': 'STATUS_DELIVERED_ONLINE', '2': 1},
|
||||
{'1': 'STATUS_STORED_OFFLINE', '2': 2},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Receipt`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List receiptDescriptor = $convert.base64Decode(
|
||||
'CgdSZWNlaXB0EhkKCG1zZ191dWlkGAEgASgMUgdtc2dVdWlkEjAKBnN0YXR1cxgCIAEoDjIYLn'
|
||||
'NlcG90LnYxLlJlY2VpcHQuU3RhdHVzUgZzdGF0dXMiUwoGU3RhdHVzEhEKDVNUQVRVU19RVUVV'
|
||||
'RUQQABIbChdTVEFUVVNfREVMSVZFUkVEX09OTElORRABEhkKFVNUQVRVU19TVE9SRURfT0ZGTE'
|
||||
'lORRAC');
|
||||
|
||||
@$core.Deprecated('Use authDescriptor instead')
|
||||
const Auth$json = {
|
||||
'1': 'Auth',
|
||||
'2': [
|
||||
{'1': 'device_uuid', '3': 1, '4': 1, '5': 12, '10': 'deviceUuid'},
|
||||
{'1': 'nonce_client', '3': 2, '4': 1, '5': 12, '10': 'nonceClient'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Auth`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List authDescriptor = $convert.base64Decode(
|
||||
'CgRBdXRoEh8KC2RldmljZV91dWlkGAEgASgMUgpkZXZpY2VVdWlkEiEKDG5vbmNlX2NsaWVudB'
|
||||
'gCIAEoDFILbm9uY2VDbGllbnQ=');
|
||||
|
||||
@$core.Deprecated('Use challengeDescriptor instead')
|
||||
const Challenge$json = {
|
||||
'1': 'Challenge',
|
||||
'2': [
|
||||
{'1': 'nonce_server', '3': 1, '4': 1, '5': 12, '10': 'nonceServer'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Challenge`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List challengeDescriptor = $convert.base64Decode(
|
||||
'CglDaGFsbGVuZ2USIQoMbm9uY2Vfc2VydmVyGAEgASgMUgtub25jZVNlcnZlcg==');
|
||||
|
||||
@$core.Deprecated('Use authProofDescriptor instead')
|
||||
const AuthProof$json = {
|
||||
'1': 'AuthProof',
|
||||
'2': [
|
||||
{'1': 'signature', '3': 1, '4': 1, '5': 12, '10': 'signature'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `AuthProof`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List authProofDescriptor = $convert
|
||||
.base64Decode('CglBdXRoUHJvb2YSHAoJc2lnbmF0dXJlGAEgASgMUglzaWduYXR1cmU=');
|
||||
|
||||
@$core.Deprecated('Use prekeysLowDescriptor instead')
|
||||
const PrekeysLow$json = {
|
||||
'1': 'PrekeysLow',
|
||||
'2': [
|
||||
{'1': 'remaining', '3': 1, '4': 1, '5': 13, '10': 'remaining'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PrekeysLow`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List prekeysLowDescriptor = $convert
|
||||
.base64Decode('CgpQcmVrZXlzTG93EhwKCXJlbWFpbmluZxgBIAEoDVIJcmVtYWluaW5n');
|
||||
|
||||
@$core.Deprecated('Use errorDescriptor instead')
|
||||
const Error$json = {
|
||||
'1': 'Error',
|
||||
'2': [
|
||||
{'1': 'code', '3': 1, '4': 1, '5': 13, '10': 'code'},
|
||||
{'1': 'message', '3': 2, '4': 1, '5': 9, '10': 'message'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `Error`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List errorDescriptor = $convert.base64Decode(
|
||||
'CgVFcnJvchISCgRjb2RlGAEgASgNUgRjb2RlEhgKB21lc3NhZ2UYAiABKAlSB21lc3NhZ2U=');
|
||||
|
||||
@$core.Deprecated('Use pingDescriptor instead')
|
||||
const Ping$json = {
|
||||
'1': 'Ping',
|
||||
};
|
||||
|
||||
/// Descriptor for `Ping`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List pingDescriptor = $convert.base64Decode('CgRQaW5n');
|
||||
|
||||
@$core.Deprecated('Use pongDescriptor instead')
|
||||
const Pong$json = {
|
||||
'1': 'Pong',
|
||||
};
|
||||
|
||||
/// Descriptor for `Pong`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List pongDescriptor = $convert.base64Decode('CgRQb25n');
|
||||
|
||||
@$core.Deprecated('Use authOkDescriptor instead')
|
||||
const AuthOk$json = {
|
||||
'1': 'AuthOk',
|
||||
};
|
||||
|
||||
/// Descriptor for `AuthOk`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List authOkDescriptor =
|
||||
$convert.base64Decode('CgZBdXRoT2s=');
|
||||
|
||||
@$core.Deprecated('Use registerPushDescriptor instead')
|
||||
const RegisterPush$json = {
|
||||
'1': 'RegisterPush',
|
||||
'2': [
|
||||
{
|
||||
'1': 'platform',
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 14,
|
||||
'6': '.sepot.v1.RegisterPush.Platform',
|
||||
'10': 'platform'
|
||||
},
|
||||
{'1': 'endpoint', '3': 2, '4': 1, '5': 12, '10': 'endpoint'},
|
||||
],
|
||||
'4': [RegisterPush_Platform$json],
|
||||
};
|
||||
|
||||
@$core.Deprecated('Use registerPushDescriptor instead')
|
||||
const RegisterPush_Platform$json = {
|
||||
'1': 'Platform',
|
||||
'2': [
|
||||
{'1': 'PLATFORM_NONE', '2': 0},
|
||||
{'1': 'PLATFORM_FCM', '2': 1},
|
||||
{'1': 'PLATFORM_APNS', '2': 2},
|
||||
{'1': 'PLATFORM_UNIFIEDPUSH', '2': 3},
|
||||
{'1': 'PLATFORM_WEBPUSH', '2': 4},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `RegisterPush`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List registerPushDescriptor = $convert.base64Decode(
|
||||
'CgxSZWdpc3RlclB1c2gSOwoIcGxhdGZvcm0YASABKA4yHy5zZXBvdC52MS5SZWdpc3RlclB1c2'
|
||||
'guUGxhdGZvcm1SCHBsYXRmb3JtEhoKCGVuZHBvaW50GAIgASgMUghlbmRwb2ludCJyCghQbGF0'
|
||||
'Zm9ybRIRCg1QTEFURk9STV9OT05FEAASEAoMUExBVEZPUk1fRkNNEAESEQoNUExBVEZPUk1fQV'
|
||||
'BOUxACEhgKFFBMQVRGT1JNX1VOSUZJRURQVVNIEAMSFAoQUExBVEZPUk1fV0VCUFVTSBAE');
|
||||
|
||||
@$core.Deprecated('Use signedPreKeyDescriptor instead')
|
||||
const SignedPreKey$json = {
|
||||
'1': 'SignedPreKey',
|
||||
'2': [
|
||||
{'1': 'key_id', '3': 1, '4': 1, '5': 13, '10': 'keyId'},
|
||||
{'1': 'public_key', '3': 2, '4': 1, '5': 12, '10': 'publicKey'},
|
||||
{'1': 'signature', '3': 3, '4': 1, '5': 12, '10': 'signature'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SignedPreKey`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List signedPreKeyDescriptor = $convert.base64Decode(
|
||||
'CgxTaWduZWRQcmVLZXkSFQoGa2V5X2lkGAEgASgNUgVrZXlJZBIdCgpwdWJsaWNfa2V5GAIgAS'
|
||||
'gMUglwdWJsaWNLZXkSHAoJc2lnbmF0dXJlGAMgASgMUglzaWduYXR1cmU=');
|
||||
|
||||
@$core.Deprecated('Use kyberPreKeyDescriptor instead')
|
||||
const KyberPreKey$json = {
|
||||
'1': 'KyberPreKey',
|
||||
'2': [
|
||||
{'1': 'key_id', '3': 1, '4': 1, '5': 13, '10': 'keyId'},
|
||||
{'1': 'public_key', '3': 2, '4': 1, '5': 12, '10': 'publicKey'},
|
||||
{'1': 'signature', '3': 3, '4': 1, '5': 12, '10': 'signature'},
|
||||
{'1': 'is_last_resort', '3': 4, '4': 1, '5': 8, '10': 'isLastResort'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `KyberPreKey`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List kyberPreKeyDescriptor = $convert.base64Decode(
|
||||
'CgtLeWJlclByZUtleRIVCgZrZXlfaWQYASABKA1SBWtleUlkEh0KCnB1YmxpY19rZXkYAiABKA'
|
||||
'xSCXB1YmxpY0tleRIcCglzaWduYXR1cmUYAyABKAxSCXNpZ25hdHVyZRIkCg5pc19sYXN0X3Jl'
|
||||
'c29ydBgEIAEoCFIMaXNMYXN0UmVzb3J0');
|
||||
|
||||
@$core.Deprecated('Use oneTimePreKeyDescriptor instead')
|
||||
const OneTimePreKey$json = {
|
||||
'1': 'OneTimePreKey',
|
||||
'2': [
|
||||
{'1': 'key_id', '3': 1, '4': 1, '5': 13, '10': 'keyId'},
|
||||
{'1': 'public_key', '3': 2, '4': 1, '5': 12, '10': 'publicKey'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `OneTimePreKey`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List oneTimePreKeyDescriptor = $convert.base64Decode(
|
||||
'Cg1PbmVUaW1lUHJlS2V5EhUKBmtleV9pZBgBIAEoDVIFa2V5SWQSHQoKcHVibGljX2tleRgCIA'
|
||||
'EoDFIJcHVibGljS2V5');
|
||||
|
||||
@$core.Deprecated('Use preKeyBundleDescriptor instead')
|
||||
const PreKeyBundle$json = {
|
||||
'1': 'PreKeyBundle',
|
||||
'2': [
|
||||
{'1': 'registration_id', '3': 1, '4': 1, '5': 13, '10': 'registrationId'},
|
||||
{'1': 'device_id', '3': 2, '4': 1, '5': 13, '10': 'deviceId'},
|
||||
{'1': 'identity_key', '3': 3, '4': 1, '5': 12, '10': 'identityKey'},
|
||||
{
|
||||
'1': 'signed_prekey',
|
||||
'3': 4,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.SignedPreKey',
|
||||
'10': 'signedPrekey'
|
||||
},
|
||||
{
|
||||
'1': 'kyber_prekey',
|
||||
'3': 5,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.KyberPreKey',
|
||||
'10': 'kyberPrekey'
|
||||
},
|
||||
{
|
||||
'1': 'one_time_prekey',
|
||||
'3': 6,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.OneTimePreKey',
|
||||
'10': 'oneTimePrekey'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PreKeyBundle`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List preKeyBundleDescriptor = $convert.base64Decode(
|
||||
'CgxQcmVLZXlCdW5kbGUSJwoPcmVnaXN0cmF0aW9uX2lkGAEgASgNUg5yZWdpc3RyYXRpb25JZB'
|
||||
'IbCglkZXZpY2VfaWQYAiABKA1SCGRldmljZUlkEiEKDGlkZW50aXR5X2tleRgDIAEoDFILaWRl'
|
||||
'bnRpdHlLZXkSOwoNc2lnbmVkX3ByZWtleRgEIAEoCzIWLnNlcG90LnYxLlNpZ25lZFByZUtleV'
|
||||
'IMc2lnbmVkUHJla2V5EjgKDGt5YmVyX3ByZWtleRgFIAEoCzIVLnNlcG90LnYxLkt5YmVyUHJl'
|
||||
'S2V5UgtreWJlclByZWtleRI/Cg9vbmVfdGltZV9wcmVrZXkYBiABKAsyFy5zZXBvdC52MS5Pbm'
|
||||
'VUaW1lUHJlS2V5Ug1vbmVUaW1lUHJla2V5');
|
||||
|
||||
@$core.Deprecated('Use preKeyBundlesDescriptor instead')
|
||||
const PreKeyBundles$json = {
|
||||
'1': 'PreKeyBundles',
|
||||
'2': [
|
||||
{
|
||||
'1': 'devices',
|
||||
'3': 1,
|
||||
'4': 3,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.PreKeyBundle',
|
||||
'10': 'devices'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PreKeyBundles`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List preKeyBundlesDescriptor = $convert.base64Decode(
|
||||
'Cg1QcmVLZXlCdW5kbGVzEjAKB2RldmljZXMYASADKAsyFi5zZXBvdC52MS5QcmVLZXlCdW5kbG'
|
||||
'VSB2RldmljZXM=');
|
||||
|
||||
@$core.Deprecated('Use clientFrameDescriptor instead')
|
||||
const ClientFrame$json = {
|
||||
'1': 'ClientFrame',
|
||||
'2': [
|
||||
{
|
||||
'1': 'auth',
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Auth',
|
||||
'9': 0,
|
||||
'10': 'auth'
|
||||
},
|
||||
{
|
||||
'1': 'auth_proof',
|
||||
'3': 2,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.AuthProof',
|
||||
'9': 0,
|
||||
'10': 'authProof'
|
||||
},
|
||||
{
|
||||
'1': 'envelope',
|
||||
'3': 3,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Envelope',
|
||||
'9': 0,
|
||||
'10': 'envelope'
|
||||
},
|
||||
{
|
||||
'1': 'ack',
|
||||
'3': 4,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Ack',
|
||||
'9': 0,
|
||||
'10': 'ack'
|
||||
},
|
||||
{
|
||||
'1': 'pong',
|
||||
'3': 5,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Pong',
|
||||
'9': 0,
|
||||
'10': 'pong'
|
||||
},
|
||||
{
|
||||
'1': 'register_push',
|
||||
'3': 6,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.RegisterPush',
|
||||
'9': 0,
|
||||
'10': 'registerPush'
|
||||
},
|
||||
],
|
||||
'8': [
|
||||
{'1': 'frame'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `ClientFrame`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List clientFrameDescriptor = $convert.base64Decode(
|
||||
'CgtDbGllbnRGcmFtZRIkCgRhdXRoGAEgASgLMg4uc2Vwb3QudjEuQXV0aEgAUgRhdXRoEjQKCm'
|
||||
'F1dGhfcHJvb2YYAiABKAsyEy5zZXBvdC52MS5BdXRoUHJvb2ZIAFIJYXV0aFByb29mEjAKCGVu'
|
||||
'dmVsb3BlGAMgASgLMhIuc2Vwb3QudjEuRW52ZWxvcGVIAFIIZW52ZWxvcGUSIQoDYWNrGAQgAS'
|
||||
'gLMg0uc2Vwb3QudjEuQWNrSABSA2FjaxIkCgRwb25nGAUgASgLMg4uc2Vwb3QudjEuUG9uZ0gA'
|
||||
'UgRwb25nEj0KDXJlZ2lzdGVyX3B1c2gYBiABKAsyFi5zZXBvdC52MS5SZWdpc3RlclB1c2hIAF'
|
||||
'IMcmVnaXN0ZXJQdXNoQgcKBWZyYW1l');
|
||||
|
||||
@$core.Deprecated('Use serverFrameDescriptor instead')
|
||||
const ServerFrame$json = {
|
||||
'1': 'ServerFrame',
|
||||
'2': [
|
||||
{
|
||||
'1': 'challenge',
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Challenge',
|
||||
'9': 0,
|
||||
'10': 'challenge'
|
||||
},
|
||||
{
|
||||
'1': 'receipt',
|
||||
'3': 2,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Receipt',
|
||||
'9': 0,
|
||||
'10': 'receipt'
|
||||
},
|
||||
{
|
||||
'1': 'deliver',
|
||||
'3': 3,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Envelope',
|
||||
'9': 0,
|
||||
'10': 'deliver'
|
||||
},
|
||||
{
|
||||
'1': 'prekeys_low',
|
||||
'3': 4,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.PrekeysLow',
|
||||
'9': 0,
|
||||
'10': 'prekeysLow'
|
||||
},
|
||||
{
|
||||
'1': 'ping',
|
||||
'3': 5,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Ping',
|
||||
'9': 0,
|
||||
'10': 'ping'
|
||||
},
|
||||
{
|
||||
'1': 'error',
|
||||
'3': 6,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.Error',
|
||||
'9': 0,
|
||||
'10': 'error'
|
||||
},
|
||||
{
|
||||
'1': 'auth_ok',
|
||||
'3': 7,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.sepot.v1.AuthOk',
|
||||
'9': 0,
|
||||
'10': 'authOk'
|
||||
},
|
||||
],
|
||||
'8': [
|
||||
{'1': 'frame'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `ServerFrame`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List serverFrameDescriptor = $convert.base64Decode(
|
||||
'CgtTZXJ2ZXJGcmFtZRIzCgljaGFsbGVuZ2UYASABKAsyEy5zZXBvdC52MS5DaGFsbGVuZ2VIAF'
|
||||
'IJY2hhbGxlbmdlEi0KB3JlY2VpcHQYAiABKAsyES5zZXBvdC52MS5SZWNlaXB0SABSB3JlY2Vp'
|
||||
'cHQSLgoHZGVsaXZlchgDIAEoCzISLnNlcG90LnYxLkVudmVsb3BlSABSB2RlbGl2ZXISNwoLcH'
|
||||
'Jla2V5c19sb3cYBCABKAsyFC5zZXBvdC52MS5QcmVrZXlzTG93SABSCnByZWtleXNMb3cSJAoE'
|
||||
'cGluZxgFIAEoCzIOLnNlcG90LnYxLlBpbmdIAFIEcGluZxInCgVlcnJvchgGIAEoCzIPLnNlcG'
|
||||
'90LnYxLkVycm9ySABSBWVycm9yEisKB2F1dGhfb2sYByABKAsyEC5zZXBvdC52MS5BdXRoT2tI'
|
||||
'AFIGYXV0aE9rQgcKBWZyYW1l');
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/errors.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Stav seznamu kontaktů
|
||||
class ContactsNotifier extends AsyncNotifier<List<Contact>> {
|
||||
@override
|
||||
Future<List<Contact>> build() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return storage.loadContacts();
|
||||
}
|
||||
|
||||
/// Přidej kontakt z veřejné identity
|
||||
Future<Contact> addContact(PublicIdentity identity, {bool verified = false}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final contacts = await future;
|
||||
|
||||
// Kontrola duplicity
|
||||
if (contacts.any((c) => c.publicIdentity.id == identity.id)) {
|
||||
throw const ContactAlreadyExistsError();
|
||||
}
|
||||
|
||||
final contact = Contact.fromPublicIdentity(identity, verified: verified);
|
||||
await storage.saveContact(contact);
|
||||
|
||||
state = AsyncData([...contacts, contact]);
|
||||
return contact;
|
||||
}
|
||||
|
||||
/// Smaž kontakt
|
||||
Future<void> removeContact(String contactId) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
await storage.deleteContact(contactId);
|
||||
|
||||
final contacts = await future;
|
||||
state = AsyncData(contacts.where((c) => c.id != contactId).toList());
|
||||
}
|
||||
|
||||
// Pozn.: dřívější verifyContact() (slepé přepnutí flagu) odstraněna —
|
||||
// ověření jde VÝHRADNĚ přes safety number (ContactVerifyScreen).
|
||||
|
||||
/// Zablokuj / odblokuj kontakt. Blokovaný: příchozí zprávy se zahodí,
|
||||
/// odesílání je v UI zakázané.
|
||||
Future<void> setBlocked(String contactId, bool blocked) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final contacts = await future;
|
||||
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||
if (contact == null) return;
|
||||
contact.blocked = blocked;
|
||||
await storage.saveContact(contact);
|
||||
state = AsyncData([...contacts]);
|
||||
}
|
||||
|
||||
/// Najdi kontakt podle identity ID
|
||||
Contact? findByIdentityId(String identityId) {
|
||||
final contacts = state.valueOrNull;
|
||||
if (contacts == null) return null;
|
||||
return contacts.where((c) => c.publicIdentity.id == identityId).firstOrNull;
|
||||
}
|
||||
}
|
||||
|
||||
final contactsProvider =
|
||||
AsyncNotifierProvider<ContactsNotifier, List<Contact>>(
|
||||
ContactsNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
|
||||
/// Stav konverzací
|
||||
class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
@override
|
||||
Future<List<Conversation>> build() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return storage.loadConversations();
|
||||
}
|
||||
|
||||
/// Odešli zprávu — E2E šifrovaně přes relay. `replyToId` = odpověď.
|
||||
/// `contactId` může být i gid skupiny (fanout členům).
|
||||
Future<void> sendMessage(String contactId, String text,
|
||||
{String? replyToId}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupText(group, text, replyToId: replyToId);
|
||||
} else {
|
||||
final contact = await _contact(contactId);
|
||||
// sendText zprávu po odeslání sám uloží (s reálným delivery stavem)
|
||||
await service.sendText(contact, text, replyToId: replyToId);
|
||||
}
|
||||
|
||||
final conversations = await storage.loadConversations();
|
||||
state = AsyncData(conversations);
|
||||
}
|
||||
|
||||
Future<Contact> _contact(String contactId) async {
|
||||
final contacts = await ref.read(storageProvider).loadContacts();
|
||||
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||
if (contact == null) throw StateError('Contact not found');
|
||||
return contact;
|
||||
}
|
||||
|
||||
/// Skupina, pokud id konverzace patří skupině (a neopustil jsem ji).
|
||||
Future<Group?> _groupOrNull(String id) async {
|
||||
final group = await ref.read(storageProvider).loadGroup(id);
|
||||
if (group == null || group.left) return null;
|
||||
return group;
|
||||
}
|
||||
|
||||
/// Reakce emotikonem na zprávu (null = odebrat moji reakci).
|
||||
Future<void> reactToMessage(
|
||||
String contactId, String messageId, String? emoji) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupReaction(group, messageId, emoji);
|
||||
} else {
|
||||
await service.sendReaction(await _contact(contactId), messageId, emoji);
|
||||
}
|
||||
state = AsyncData(await ref.read(storageProvider).loadConversations());
|
||||
}
|
||||
|
||||
/// Úprava textu odeslané zprávy.
|
||||
Future<void> editMessage(
|
||||
String contactId, String messageId, String newText) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.editGroupMessage(group, messageId, newText);
|
||||
} else {
|
||||
await service.editMessage(await _contact(contactId), messageId, newText);
|
||||
}
|
||||
state = AsyncData(await ref.read(storageProvider).loadConversations());
|
||||
}
|
||||
|
||||
/// Smazání zprávy pro všechny.
|
||||
Future<void> deleteMessage(String contactId, String messageId) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.deleteGroupMessage(group, messageId);
|
||||
} else {
|
||||
await service.deleteMessage(await _contact(contactId), messageId);
|
||||
}
|
||||
state = AsyncData(await ref.read(storageProvider).loadConversations());
|
||||
}
|
||||
|
||||
/// Ztlumit / odtlumit konverzaci (lokální). `duration == null` = navždy.
|
||||
Future<void> setMuted(String contactId, bool muted, {Duration? duration}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(contactId);
|
||||
if (conv == null) return;
|
||||
conv.muted = muted;
|
||||
conv.mutedUntil = (muted && duration != null)
|
||||
? DateTime.now().add(duration)
|
||||
: null;
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Archivovat / odarchivovat konverzaci (lokální).
|
||||
Future<void> setArchived(String contactId, bool archived) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(contactId);
|
||||
if (conv == null) return;
|
||||
conv.archived = archived;
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Smazání zprávy jen u mě (lokálně) — protistraně se nic neposílá.
|
||||
Future<void> deleteLocalMessage(String contactId, String messageId) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(contactId);
|
||||
if (conv == null) return;
|
||||
conv.messages.removeWhere((m) => m.id == messageId);
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Odešli přílohu (fotku/dokument) — zpracování, šifrování a upload
|
||||
/// řeší MessagingService.sendAttachment.
|
||||
Future<void> sendAttachment(
|
||||
String contactId, {
|
||||
required Uint8List bytes,
|
||||
required String fileName,
|
||||
String? mime,
|
||||
}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupAttachment(
|
||||
group,
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
mime: mime,
|
||||
);
|
||||
} else {
|
||||
final contact = await _contact(contactId);
|
||||
await service.sendAttachment(
|
||||
contact,
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
mime: mime,
|
||||
);
|
||||
}
|
||||
|
||||
final conversations = await storage.loadConversations();
|
||||
state = AsyncData(conversations);
|
||||
}
|
||||
|
||||
/// Odešli hlasovou zprávu (audio příloha s délkou nahrávky).
|
||||
Future<void> sendVoiceMessage(
|
||||
String contactId, {
|
||||
required Uint8List bytes,
|
||||
required int durationMs,
|
||||
String mime = 'audio/mp4',
|
||||
}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final group = await _groupOrNull(contactId);
|
||||
if (group != null) {
|
||||
await service.sendGroupAttachment(
|
||||
group,
|
||||
bytes: bytes,
|
||||
fileName: 'voice-message.m4a',
|
||||
mime: mime,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
} else {
|
||||
await service.sendAttachment(
|
||||
await _contact(contactId),
|
||||
bytes: bytes,
|
||||
fileName: 'voice-message.m4a',
|
||||
mime: mime,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
}
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
}
|
||||
|
||||
/// Ruční opakování downloadu blobu (stav failed).
|
||||
Future<void> retryAttachmentDownload(
|
||||
String contactId, Message message) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
await service.downloadAttachment(contactId, message);
|
||||
final conversations = await ref.read(storageProvider).loadConversations();
|
||||
state = AsyncData(conversations);
|
||||
}
|
||||
|
||||
/// Označ konverzaci jako přečtenou
|
||||
Future<void> markAsRead(String contactId) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
|
||||
final conv = await storage.loadConversation(contactId);
|
||||
if (conv == null) return;
|
||||
|
||||
// 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á).
|
||||
final justRead = conv.messages
|
||||
.where((m) => !m.isMine(identity.id) && !m.isRead)
|
||||
.map((m) => m.id)
|
||||
.toList();
|
||||
|
||||
conv.markAllRead(identity.id);
|
||||
await storage.saveConversation(conv);
|
||||
state = AsyncData(await storage.loadConversations());
|
||||
|
||||
// Potvrzení přečtení ve skupině v1 neposíláme (fanout N receipts
|
||||
// za každé otevření chatu; per-member matice až v2).
|
||||
if (justRead.isNotEmpty && await _groupOrNull(contactId) == null) {
|
||||
final contacts = await storage.loadContacts();
|
||||
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||
if (contact != null) {
|
||||
await ref
|
||||
.read(messagingServiceProvider)
|
||||
.sendReadReceipts(contact, justRead);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Celkový počet nepřečtených zpráv
|
||||
int get totalUnread {
|
||||
final conversations = state.valueOrNull ?? [];
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return 0;
|
||||
|
||||
return conversations.fold(
|
||||
0,
|
||||
(sum, conv) => sum + conv.unreadCount(identity.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final conversationsProvider =
|
||||
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
|
||||
ConversationsNotifier.new,
|
||||
);
|
||||
|
||||
/// Provider pro jednu konverzaci
|
||||
final conversationProvider =
|
||||
FutureProvider.family<Conversation?, String>((ref, contactId) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return storage.loadConversation(contactId);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Stav skupin. Zápisové operace jdou přes
|
||||
/// MessagingService (fanout členům), tady se jen drží seznam pro UI.
|
||||
class GroupsNotifier extends AsyncNotifier<List<Group>> {
|
||||
@override
|
||||
Future<List<Group>> build() async {
|
||||
return ref.read(storageProvider).loadGroups();
|
||||
}
|
||||
|
||||
Future<Group> createGroup(String name, List<Contact> members) async {
|
||||
final group =
|
||||
await ref.read(messagingServiceProvider).createGroup(name, members);
|
||||
await _refresh();
|
||||
return group;
|
||||
}
|
||||
|
||||
Future<void> addMembers(Group group, List<Contact> added) async {
|
||||
await ref.read(messagingServiceProvider).addGroupMembers(group, added);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> rename(Group group, String newName) async {
|
||||
await ref.read(messagingServiceProvider).renameGroup(group, newName);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> leave(Group group) async {
|
||||
await ref.read(messagingServiceProvider).leaveGroup(group);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
state = AsyncData(await ref.read(storageProvider).loadGroups());
|
||||
ref.invalidate(conversationsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
final groupsProvider = AsyncNotifierProvider<GroupsNotifier, List<Group>>(
|
||||
GroupsNotifier.new,
|
||||
);
|
||||
|
||||
/// Jedna skupina podle id (null = id nepatří skupině → 1:1 konverzace).
|
||||
final groupProvider = FutureProvider.family<Group?, String>((ref, id) async {
|
||||
// Závislost na seznamu — invalidace groupsProvider obnoví i family.
|
||||
ref.watch(groupsProvider);
|
||||
return ref.read(storageProvider).loadGroup(id);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Stav identity uživatele
|
||||
class IdentityNotifier extends AsyncNotifier<LocalIdentity?> {
|
||||
@override
|
||||
Future<LocalIdentity?> build() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return storage.loadIdentity();
|
||||
}
|
||||
|
||||
/// Vytvoř novou identitu
|
||||
Future<LocalIdentity> createIdentity(String displayName) async {
|
||||
final identity = await LocalIdentity.create(displayName);
|
||||
final storage = ref.read(storageProvider);
|
||||
await storage.saveIdentity(identity);
|
||||
state = AsyncData(identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
/// Obnov identitu z 24slovné zálohovací fráze (recovery flow).
|
||||
///
|
||||
/// Obnoví IDENTITU (stejný klíč → stejný SEP kód a safety number, kontakty
|
||||
/// tě dál poznají), ne historii zpráv. `LocalIdentity.recover` vyhodí
|
||||
/// [FormatException] při neplatné frázi. Po uložení messaging_provider
|
||||
/// sám spustí registraci u relaye (nová delivery adresa + bundle).
|
||||
Future<LocalIdentity> recoverIdentity(
|
||||
List<String> words, String displayName) async {
|
||||
final identity = await LocalIdentity.recover(
|
||||
mnemonicWords: words,
|
||||
displayName: displayName,
|
||||
);
|
||||
final storage = ref.read(storageProvider);
|
||||
await storage.saveIdentity(identity);
|
||||
await storage.saveSeedPhrase(identity.mnemonic);
|
||||
state = AsyncData(identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
/// Znovu načti z úložiště
|
||||
Future<void> reload() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
state = AsyncData(await storage.loadIdentity());
|
||||
}
|
||||
}
|
||||
|
||||
final identityProvider =
|
||||
AsyncNotifierProvider<IdentityNotifier, LocalIdentity?>(
|
||||
IdentityNotifier.new,
|
||||
);
|
||||
|
||||
/// Zkontroluj, zda je onboarding hotový
|
||||
final isOnboardingCompleteProvider = FutureProvider<bool>((ref) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return storage.isOnboardingComplete();
|
||||
});
|
||||
|
||||
/// Zkontroluj, zda existuje PIN
|
||||
final hasPinProvider = FutureProvider<bool>((ref) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return storage.hasPin();
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Ruční volba jazyka aplikace. `null` = řídit se systémem.
|
||||
///
|
||||
/// Hodnota žije v OS keystore (NE v SQLCipher DB) — PIN lock a onboarding
|
||||
/// se zobrazují před odemčením úložiště a musí už být ve zvoleném jazyce.
|
||||
/// Jazyk není citlivý údaj; keystore je tu jen kvůli dostupnosti před
|
||||
/// unlockem (stejný vzor jako režim pushů v PushBootstrap).
|
||||
class LocaleNotifier extends AsyncNotifier<Locale?> {
|
||||
static const _key = 'app_locale';
|
||||
|
||||
static const _store = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Locale?> build() => readStored();
|
||||
|
||||
/// Přečte uloženou volbu i bez Riverpodu — pro headless start (push
|
||||
/// budíček skládá notifikaci před postavením widget tree).
|
||||
static Future<Locale?> readStored() async {
|
||||
try {
|
||||
final code = await _store.read(key: _key);
|
||||
return (code == null || code.isEmpty) ? null : Locale(code);
|
||||
} catch (_) {
|
||||
// Keystore nedostupný (např. těsně po restartu) → systémový jazyk.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Nastaví jazyk aplikace; `null` vrátí řízení systému.
|
||||
Future<void> set(Locale? locale) async {
|
||||
if (locale == null) {
|
||||
await _store.delete(key: _key);
|
||||
} else {
|
||||
await _store.write(key: _key, value: locale.languageCode);
|
||||
}
|
||||
state = AsyncData(locale);
|
||||
}
|
||||
}
|
||||
|
||||
final localeProvider =
|
||||
AsyncNotifierProvider<LocaleNotifier, Locale?>(LocaleNotifier.new);
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/network/relay_client.dart';
|
||||
import 'package:sepot/core/push/push_notifier.dart';
|
||||
import 'package:sepot/core/services/messaging_service.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Jediná instance messaging služby (krypto + relay + storage).
|
||||
final messagingServiceProvider = Provider<MessagingService>((ref) {
|
||||
final service = MessagingService(storage: ref.read(storageProvider));
|
||||
ref.onDispose(service.stop);
|
||||
return service;
|
||||
});
|
||||
|
||||
/// Nastartuje službu, jakmile existuje identita. Watchovat z první
|
||||
/// obrazovky po odemčení (chat list) — do té doby se nic nespojuje.
|
||||
/// Předpokládá odemčený storage — na /chats se jde vždy až po unlocku.
|
||||
final messagingStartProvider = FutureProvider<void>((ref) async {
|
||||
final identity = await ref.watch(identityProvider.future);
|
||||
if (identity == null) return;
|
||||
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
if (service.isStarted) return;
|
||||
|
||||
service.onUpdate = () {
|
||||
ref.invalidate(conversationsProvider);
|
||||
// celá family — jinak se otevřená konverzace nedozví o nové zprávě
|
||||
// ani o změně stavu přílohy
|
||||
ref.invalidate(conversationProvider);
|
||||
ref.invalidate(contactsProvider);
|
||||
// příchozí ginv/gleave/gmeta mění skupiny
|
||||
ref.invalidate(groupsProvider);
|
||||
};
|
||||
|
||||
// Appka běží, ale není v popředí (jiná appka / zhasnutý displej se
|
||||
// živým procesem) → systémová notifikace. Jen jméno, žádný obsah.
|
||||
// Ztlumená konverzace notifikaci potlačí (unread badge běží dál).
|
||||
service.onMessageReceived = (contact, message) async {
|
||||
final visible = WidgetsBinding.instance.lifecycleState ==
|
||||
AppLifecycleState.resumed;
|
||||
if (visible) return;
|
||||
final conv = await ref.read(storageProvider).loadConversation(contact.id);
|
||||
if (conv?.isMuted == true) return;
|
||||
PushNotifier.showMessageNotification(contact.displayName);
|
||||
};
|
||||
|
||||
await service.start(identity);
|
||||
});
|
||||
|
||||
/// Stav připojení k relayi pro UI indikátor.
|
||||
final relayConnectionProvider = StreamProvider<RelayConnectionState>((ref) {
|
||||
ref.watch(messagingStartProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
return service.connectionStream ?? const Stream.empty();
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/push/push_service.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
|
||||
/// Push vrstva nad messagingem.
|
||||
final pushServiceProvider = Provider<PushService>((ref) {
|
||||
return PushService(ref.read(messagingServiceProvider));
|
||||
});
|
||||
|
||||
/// Zaregistruje push callbacky, jakmile messaging běží. Push se sám
|
||||
/// NEzapíná — jen připraví handlery. Pokud už uživatel dřív distributora
|
||||
/// zvolil, obnoví registraci (endpoint se mohl změnit; relay se re-registruje
|
||||
/// po AUTH). Watchovat z chat listu vedle messagingStartProvider.
|
||||
final pushInitProvider = FutureProvider<void>((ref) async {
|
||||
await ref.watch(messagingStartProvider.future);
|
||||
final push = ref.read(pushServiceProvider);
|
||||
// Obnoví uložený režim (off/UnifiedPush/FCM) — endpoint/token se
|
||||
// re-registruje u relaye po každém startu.
|
||||
await push.restoreMode();
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:screen_protector/screen_protector.dart';
|
||||
import 'package:sepot/core/backup/backup_service.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Služba šifrovaných záloh historie.
|
||||
final backupServiceProvider = Provider<BackupService>(
|
||||
(ref) => BackupService(ref.read(storageProvider)));
|
||||
|
||||
/// Je nastavený nátlakový (decoy) PIN?
|
||||
final hasDecoyPinProvider = FutureProvider<bool>(
|
||||
(ref) => ref.read(storageProvider).hasDecoyPin());
|
||||
|
||||
/// Potvrzení přečtení (read receipts). Default VYPNUTO — soukromí.
|
||||
/// Je reciproční: když je vypnuté, appka je neposílá ani nezobrazuje.
|
||||
class ReadReceiptsNotifier extends AsyncNotifier<bool> {
|
||||
static const key = 'read_receipts_enabled';
|
||||
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
return (await storage.getSetting<bool>(key)) ?? false;
|
||||
}
|
||||
|
||||
Future<void> set(bool value) async {
|
||||
await ref.read(storageProvider).setSetting(key, value);
|
||||
state = AsyncData(value);
|
||||
}
|
||||
}
|
||||
|
||||
final readReceiptsProvider =
|
||||
AsyncNotifierProvider<ReadReceiptsNotifier, bool>(ReadReceiptsNotifier.new);
|
||||
|
||||
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
|
||||
/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní
|
||||
/// screenshoty, tak ať se uživatel rozhodne sám.
|
||||
class ScreenSecurityNotifier extends AsyncNotifier<bool> {
|
||||
static const key = 'screen_security_enabled';
|
||||
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
final v = (await ref.read(storageProvider).getSetting<bool>(key)) ?? false;
|
||||
await _apply(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
Future<void> set(bool value) async {
|
||||
await ref.read(storageProvider).setSetting(key, value);
|
||||
await _apply(value);
|
||||
state = AsyncData(value);
|
||||
}
|
||||
|
||||
Future<void> _apply(bool on) async {
|
||||
try {
|
||||
if (on) {
|
||||
await ScreenProtector.preventScreenshotOn();
|
||||
await ScreenProtector.protectDataLeakageOn();
|
||||
} else {
|
||||
await ScreenProtector.preventScreenshotOff();
|
||||
await ScreenProtector.protectDataLeakageOff();
|
||||
}
|
||||
} catch (_) {
|
||||
// Nepodporovaná platforma / kanál — bezpečně ignoruj.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final screenSecurityProvider =
|
||||
AsyncNotifierProvider<ScreenSecurityNotifier, bool>(
|
||||
ScreenSecurityNotifier.new);
|
||||
|
||||
/// Automatický zámek: po kolika sekundách na pozadí se appka zamkne.
|
||||
/// `0` = nikdy. Do 0.3.0 dlaždice tvrdila „5 minut", ale nezamykalo se
|
||||
/// vůbec (jen studený start) — což je horší než žádná funkce.
|
||||
///
|
||||
/// Hodnota žije v šifrované DB, takže se čte jen s odemčeným úložištěm.
|
||||
class AutoLockNotifier extends AsyncNotifier<int> {
|
||||
static const key = 'auto_lock_seconds';
|
||||
static const vychozi = 300;
|
||||
|
||||
/// Nabídka v UI (sekundy). Popisky skládá obrazovka z lokalizace
|
||||
/// (settingsAutoLockNever/1min/5min/15min) — provider texty nemá.
|
||||
static const moznostiSekundy = [0, 60, 300, 900];
|
||||
|
||||
@override
|
||||
Future<int> build() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
if (!storage.isUnlocked) return vychozi;
|
||||
return (await storage.getSetting<int>(key)) ?? vychozi;
|
||||
}
|
||||
|
||||
Future<void> set(int seconds) async {
|
||||
await ref.read(storageProvider).setSetting(key, seconds);
|
||||
state = AsyncData(seconds);
|
||||
}
|
||||
}
|
||||
|
||||
final autoLockProvider =
|
||||
AsyncNotifierProvider<AutoLockNotifier, int>(AutoLockNotifier.new);
|
||||
|
||||
/// Routování přes Tor (SOCKS5/Orbot na `.onion`). Default VYPNUTO —
|
||||
/// přímé WSS je rychlejší a nevyžaduje Orbot. Zapnutí skryje IP klienta
|
||||
/// před relayem. Hodnota žije v šifrované DB.
|
||||
class TorNotifier extends AsyncNotifier<bool> {
|
||||
static const key = 'tor_enabled';
|
||||
|
||||
@override
|
||||
Future<bool> build() async {
|
||||
final storage = ref.read(storageProvider);
|
||||
if (!storage.isUnlocked) return false;
|
||||
return (await storage.getSetting<bool>(key)) ?? false;
|
||||
}
|
||||
|
||||
Future<void> set(bool value) async {
|
||||
// Uloží volbu a přestaví běžící spojení na .onion / zpět.
|
||||
await ref.read(messagingServiceProvider).setTorEnabled(value);
|
||||
state = AsyncData(value);
|
||||
}
|
||||
}
|
||||
|
||||
final torProvider = AsyncNotifierProvider<TorNotifier, bool>(TorNotifier.new);
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
|
||||
/// Globální instance úložiště
|
||||
final storageProvider = Provider<SecureStorageService>((ref) {
|
||||
return SecureStorageService();
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
|
||||
/// Generovaný avatar z iniciál
|
||||
class SepotAvatar extends StatelessWidget {
|
||||
final String name;
|
||||
final double size;
|
||||
final bool isOnline;
|
||||
|
||||
const SepotAvatar({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.size = 48,
|
||||
this.isOnline = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initials = _getInitials(name);
|
||||
final color = _colorFromName(name);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// Nokturno: tichý tmavý podklad, barva jen v iniciálách
|
||||
color: SepotColors.surfaceVariant,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: size * 0.35,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isOnline)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: size * 0.28,
|
||||
height: size * 0.28,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.success,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: SepotColors.background,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getInitials(String name) {
|
||||
final parts = name.trim().split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
|
||||
}
|
||||
return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase();
|
||||
}
|
||||
|
||||
Color _colorFromName(String name) {
|
||||
// Jen tlumené tóny palety — žádné syté cizí barvy
|
||||
final colors = [
|
||||
SepotColors.ember,
|
||||
SepotColors.mist,
|
||||
SepotColors.sage,
|
||||
SepotColors.warning,
|
||||
SepotColors.textSecondary,
|
||||
];
|
||||
return colors[name.hashCode.abs() % colors.length];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
|
||||
/// Badge indikující E2E šifrování
|
||||
class E2EBadge extends StatelessWidget {
|
||||
final bool compact;
|
||||
|
||||
const E2EBadge({super.key, this.compact = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (compact) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.lock_rounded, size: 12, color: SepotColors.ember),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.commonE2eBadgeCompact,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: SepotColors.ember,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.ember.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: SepotColors.ember.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.lock_rounded, size: 14, color: SepotColors.ember),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.commonE2eBadgeFull,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: SepotColors.ember,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/// Numerická klávesnice pro PIN + řádek teček.
|
||||
///
|
||||
/// Vytaženo z `pin_setup_screen`, aby ji mohla použít i změna PINu.
|
||||
/// Systémovou klávesnici záměrně nepoužíváme — nechceme, aby PIN prošel
|
||||
/// IME (predikce, schránka, klávesnice třetích stran).
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
|
||||
class PinDots extends StatelessWidget {
|
||||
const PinDots({super.key, required this.filled, this.length = 6});
|
||||
|
||||
final int filled;
|
||||
final int length;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(length, (i) {
|
||||
final on = i < filled;
|
||||
return Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: on ? SepotColors.ember : SepotColors.border,
|
||||
border: Border.all(
|
||||
color: on ? SepotColors.ember : SepotColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PinKeypad extends StatelessWidget {
|
||||
const PinKeypad({super.key, required this.onKey, this.enabled = true});
|
||||
|
||||
/// Dostane číslici '0'–'9' nebo 'DEL'.
|
||||
final void Function(String key) onKey;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
for (final row in const [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9'],
|
||||
['', '0', 'DEL'],
|
||||
])
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: row
|
||||
.map((k) => k.isEmpty
|
||||
? const SizedBox(width: 88, height: 64)
|
||||
: _klavesa(k))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _klavesa(String key) {
|
||||
final delete = key == 'DEL';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: InkWell(
|
||||
onTap: enabled
|
||||
? () {
|
||||
HapticFeedback.lightImpact();
|
||||
onKey(key);
|
||||
}
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: delete ? Colors.transparent : SepotColors.surfaceVariant,
|
||||
border: delete ? null : Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: Center(
|
||||
child: delete
|
||||
? const Icon(Icons.backspace_outlined,
|
||||
color: SepotColors.textSecondary, size: 24)
|
||||
: Text(
|
||||
key,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
|
||||
/// Logo Šepot — brand mark „Š s tečkami" (nokturno-ikona-B2).
|
||||
///
|
||||
/// Používá STEJNÝ obrázek jako launcher ikona
|
||||
/// (`assets/icon/icon-1024.png`), aby logo na ploše, na splash i uvnitř
|
||||
/// appky bylo jednotné.
|
||||
class SepotLogo extends StatelessWidget {
|
||||
final double size;
|
||||
final bool showText;
|
||||
|
||||
const SepotLogo({
|
||||
super.key,
|
||||
this.size = 80,
|
||||
this.showText = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(size * 0.24),
|
||||
child: Image.asset(
|
||||
'assets/icon/icon-1024.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
if (showText) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.commonAppName,
|
||||
style: TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: size * 0.35,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.commonAppTagline,
|
||||
style: TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: size * 0.15,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user