Š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,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();
|
||||
});
|
||||
Reference in New Issue
Block a user