Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f903e21e3a | |||
| 923a6a01bd | |||
| 9985b79aa6 | |||
| b82f50f888 | |||
| 4f2cbdbdc4 | |||
| 90a1390716 | |||
| 7d7f589fc5 | |||
| 641d70b76e | |||
| a60aaca326 | |||
| 92eb316a83 |
+87
-1
@@ -4,7 +4,93 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||
verzování dle [ajtak standardu](#verzování).
|
||||
|
||||
## [Nevydáno]
|
||||
## [1.1.2] – 2026-08-13
|
||||
|
||||
### Opraveno
|
||||
- **Zprávy už nečekají na otevření aplikace.** Když mobilní síť tiše
|
||||
zahodila nečinné spojení, Šepot o tom nevěděl: tvářil se připojeně, ale
|
||||
odeslaná zpráva skončila chybou a příchozí nedorazily — a všechny se pak
|
||||
vysypaly naráz po restartu aplikace. Spojení se teď hlídá pravidelným
|
||||
pingem na obou stranách, takže výpadek se pozná a obnoví v řádu sekund.
|
||||
- **Neodeslaná zpráva se pošle sama.** Dosud zůstala označená jako
|
||||
neodeslaná (u příloh dokonce natrvalo na „odesílá se") a poslat znovu ji
|
||||
musel člověk ručně. Nově se po obnovení spojení odešle sama; opakované
|
||||
doručení téže zprávy druhá strana pozná a nezobrazí ji dvakrát.
|
||||
- **Zprávy, které nešly doručit, se přestaly hromadit.** Když se cestou
|
||||
ztratilo potvrzení o doručení, obálka na serveru zůstala a znovu a znovu
|
||||
se posílala, aniž by ji šlo přečíst. Server teď takové zprávy po několika
|
||||
marných pokusech zahodí, místo aby jimi zdržoval doručování ostatních.
|
||||
|
||||
### Přidáno
|
||||
- **Upozornění, když nechodí notifikace.** Bez nich Android aplikaci na
|
||||
pozadí uspí a zprávy uvidíš, až ji otevřeš. Šepot to teď řekne rovnou v
|
||||
seznamu chatů a nabídne nastavení; hlášku jde odmítnout. Selhání
|
||||
registrace u distributora se už nezamlčí.
|
||||
|
||||
## [1.1.1] – 2026-07-18
|
||||
|
||||
### Opraveno
|
||||
- **Odeslání první zprávy hned po instalaci už neselže.** Krátce po
|
||||
založení účtu, než se aplikace stihla připojit k relayi, mohla zpráva
|
||||
spadnout s hláškou „Odeslání selhalo". Nově se v tu chvíli ukáže
|
||||
srozumitelné „Šepot se ještě připojuje — zkus to za chvíli" a připojení
|
||||
proběhne na pozadí.
|
||||
- **QR kód a odkaz se ukážou až s doručovací adresou.** Když sis chtěl
|
||||
ukázat vlastní QR dřív, než se aplikace poprvé připojila, vygeneroval se
|
||||
neúplný odkaz — protistrana pak nemohla poslat první zprávu („Kontakt
|
||||
nemá doručovací adresu"). Nově se místo neúplného kódu ukáže stav
|
||||
připojování a QR se objeví, jakmile je hotové.
|
||||
- **Neúplný odkaz kontaktu se odmítne při přidání**, ne až při prvním
|
||||
odeslání — s jasnou výzvou požádat protistranu o nový odkaz.
|
||||
- **Zamčení po nečinnosti už neumlčí aplikaci.** Po automatickém zamknutí
|
||||
a opětovném odemčení se zprávy zase přijímají i odesílají — dřív mohl
|
||||
messaging zůstat po odemčení vypnutý až do restartu aplikace.
|
||||
- **Skupinová zpráva se při chybě neodeslání označí jako neodeslaná**
|
||||
místo aby zůstala navždy ve stavu „odesílá se".
|
||||
- Ošetřeno vzácné zdvojení spojení s relayem při rychlém přepnutí Toru
|
||||
nebo zamčení během připojování.
|
||||
|
||||
### Změněno
|
||||
- Interní úklid závislostí notifikací (odstraněna nepoužívaná větev, která
|
||||
komplikovala sestavení verze pro iOS).
|
||||
|
||||
## [1.1.0] – 2026-07-13
|
||||
|
||||
### Přidáno
|
||||
- **Náhled fotky před odesláním.** Vyfocená nebo vybraná fotka už
|
||||
neodchází okamžitě — otevře se náhled, kde jde fotku otočit, kreslit
|
||||
po ní prstem (7 barev, krok zpět), přidat na ni text (tažením se
|
||||
přesouvá, klepnutím upraví) a připojit popisek. Odejde až po klepnutí
|
||||
na Odeslat; zavření náhledu nic nepošle.
|
||||
|
||||
### Opraveno
|
||||
- **Nastavení se nevykreslilo** — obrazovka místo obsahu ukazovala
|
||||
chybovou hlášku „Tahle obrazovka se nevykreslila". Profilová karta
|
||||
předávala do překladu otisk klíče jako objekt místo textu.
|
||||
- Avatar s iniciálami spadl na prázdném jménu nebo jménu s dvojitou
|
||||
mezerou; iniciály se nově skládají po grafémech, takže nerozpůlí emoji.
|
||||
- **Písma se dodávají v aplikaci** (Inter, JetBrains Mono — OFL).
|
||||
Aplikace dřív stahovala fonty za běhu z Google CDN — síťový požadavek
|
||||
na cizí server navíc a binárka odlišná od zveřejněného zdroje.
|
||||
- **Trvale vadná příchozí zpráva už neotravuje frontu.** Zpráva, jejíž
|
||||
obsah nejde po dešifrování zpracovat (poškozená pole od upraveného
|
||||
klienta), se potvrdí a zahodí — dřív se doručovala donekonečna při
|
||||
každém připojení. Nedešifrovatelné obálky fronta dál drží.
|
||||
- Příloha s poškozeným náhledem od protistrany shodila vykreslení
|
||||
bubliny; vadný náhled se nově zahodí při příjmu a base64 krypto polí
|
||||
příloh se validuje.
|
||||
- Zachycení výměny důvěryhodního kořene relaye (signál MITM) na
|
||||
pozadí — dřív tichá neošetřená výjimka, teď příznak čitelný pro UI.
|
||||
- PNG bez průhlednosti (screenshoty, upravené fotky) se posílají jako
|
||||
JPEG — několikanásobně menší přenos; PNG zůstává jen pro obrázky se
|
||||
skutečnou průhledností.
|
||||
|
||||
## [1.0.0] – 2026-07-11
|
||||
|
||||
První ostrá verze. Šepot je od 11. 7. 2026 open source — kompletní
|
||||
zdrojový kód aplikace i serveru je veřejný na
|
||||
[gitea.ajtak.it/julius.joska/sepot](https://gitea.ajtak.it/julius.joska/sepot)
|
||||
(zrcadlo [github.com/juliusjoska/sepot](https://github.com/juliusjoska/sepot)).
|
||||
|
||||
### Přidáno
|
||||
- **Aplikace mluví pěti jazyky: česky, anglicky, německy, rusky
|
||||
|
||||
@@ -42,6 +42,14 @@ android {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
// Lint na release buildu vynechán: jeho síťové kontroly (novější verze
|
||||
// závislostí) na omezené build síti visí donekonečna a pro Flutter appku
|
||||
// nemají přínos. Analýzu Dartu dělá `flutter analyze`, Kotlin je minimální.
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
abortOnError = false
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cz.sepot.sepot"
|
||||
// libsignal binárky vyžadují Android 7.0+ (API 24)
|
||||
|
||||
+5
-1
@@ -196,8 +196,12 @@ class _AutoLockGateState extends ConsumerState<_AutoLockGate>
|
||||
final kde = _router.state.uri.path;
|
||||
if (_bezZamku.contains(kde)) return;
|
||||
|
||||
// Zastavit službu a zamknout DB. messagingStartProvider ZÁMĚRNĚ
|
||||
// neinvalidujeme tady — invalidace by ho eager-rebuildla ještě nad
|
||||
// odemčeným storage (těsně před lock) a messaging by nastartoval nad DB,
|
||||
// kterou hned zavíráme. Restart řeší odemykací obrazovky přes
|
||||
// ref.invalidate(messagingStartProvider) až po úspěšném unlocku.
|
||||
await ref.read(messagingServiceProvider).stop();
|
||||
ref.invalidate(messagingStartProvider);
|
||||
storage.lock();
|
||||
_router.go('/pin-lock');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ class 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 appVersion = '1.1.0';
|
||||
static const String appAuthor = 'ajtak.it';
|
||||
|
||||
// Relay server (REST + WS na jednom portu). Přepsatelné při buildu:
|
||||
|
||||
@@ -26,6 +26,24 @@ class RelayUnavailableError extends SepotUserError {
|
||||
String toString() => 'relay unavailable';
|
||||
}
|
||||
|
||||
/// Vlastní registrace na relay ještě nedoběhla — nemáme sender adresu.
|
||||
/// Přechodný stav po čerstvé instalaci; registrace běží na pozadí.
|
||||
class NotYetRegisteredError extends SepotUserError {
|
||||
const NotYetRegisteredError();
|
||||
|
||||
@override
|
||||
String toString() => 'not registered on relay yet';
|
||||
}
|
||||
|
||||
/// Odkaz/QR kontaktu je neúplný (chybí routing aid+da) — vygenerovaný
|
||||
/// dřív, než měla protistrana dokončenou registraci na relay.
|
||||
class ContactLinkIncompleteError extends SepotUserError {
|
||||
const ContactLinkIncompleteError();
|
||||
|
||||
@override
|
||||
String toString() => 'contact link is missing routing (incomplete)';
|
||||
}
|
||||
|
||||
/// Příloha přesahuje serverový limit.
|
||||
class AttachmentTooLargeError extends SepotUserError {
|
||||
const AttachmentTooLargeError(this.maxBytes);
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
String describeError(AppLocalizations l10n, Object error) => switch (error) {
|
||||
TrustRootChangedError() => l10n.errorTrustRootChanged,
|
||||
RelayUnavailableError() => l10n.errorRelayUnavailable,
|
||||
NotYetRegisteredError() => l10n.errorNotYetRegistered,
|
||||
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
|
||||
AttachmentTooLargeError(:final maxMb) =>
|
||||
l10n.errorAttachmentTooLarge(maxMb),
|
||||
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
|
||||
|
||||
@@ -75,6 +75,9 @@ class RelayClient {
|
||||
|
||||
void _setState(RelayConnectionState s) {
|
||||
_state = s;
|
||||
// Po dispose() je controller zavřený — pozdní přechod z doběhnutého
|
||||
// _connect (zombie) by jinak hodil StateError do listenerů.
|
||||
if (_stateController.isClosed) return;
|
||||
_stateController.add(s);
|
||||
}
|
||||
|
||||
@@ -292,6 +295,14 @@ class RelayClient {
|
||||
try {
|
||||
final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws'));
|
||||
await channel.ready;
|
||||
// Během `ready` (přes Tor může viset dlouho) mohl přijít stop()/dispose()
|
||||
// — Tor toggle nebo auto-lock. Bez téhle kontroly by vzniklo zombie
|
||||
// autentizované spojení (relay má 2× jedno zařízení, DELIVERy padají do
|
||||
// zavřených controllerů). Nezakládat ho, jen zavřít.
|
||||
if (!_shouldRun) {
|
||||
await channel.sink.close();
|
||||
return;
|
||||
}
|
||||
_channel = channel;
|
||||
|
||||
// AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA)
|
||||
@@ -322,9 +333,13 @@ class RelayClient {
|
||||
return;
|
||||
}
|
||||
if (frame.hasDeliver()) {
|
||||
_deliverController.add(frame.deliver);
|
||||
if (!_deliverController.isClosed) {
|
||||
_deliverController.add(frame.deliver);
|
||||
}
|
||||
} else if (frame.hasReceipt()) {
|
||||
_receiptController.add(frame.receipt);
|
||||
if (!_receiptController.isClosed) {
|
||||
_receiptController.add(frame.receipt);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: _onDisconnected,
|
||||
|
||||
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// Jak často posílat WS ping a jak dlouho čekat na pong.
|
||||
///
|
||||
/// Bez pingu se mrtvé spojení tváří jako živé: mobilní NAT zahodí nečinné TCP
|
||||
/// tiše, appka dál hlásí „připojeno" a píše do prázdna — zprávy pak odejdou až
|
||||
/// po restartu appky, všechny naráz. `dart:io` při nastaveném intervalu ping
|
||||
/// posílá a když pong nedorazí včas, spojení zavře → [RelayClient] se
|
||||
/// reconnectne sám. Relay pinguje protisměrně po 30 s (ws.rs PING_INTERVAL).
|
||||
const kWsPingInterval = Duration(seconds: 30);
|
||||
|
||||
/// 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…).
|
||||
@@ -41,7 +50,8 @@ class DirectTransport implements RelayTransport {
|
||||
http.Client httpClient() => _client ??= http.Client();
|
||||
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url);
|
||||
WebSocketChannel connectWs(Uri url) =>
|
||||
IOWebSocketChannel.connect(url, pingInterval: kWsPingInterval);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
@@ -84,7 +94,7 @@ class TorTransport implements RelayTransport {
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) =>
|
||||
IOWebSocketChannel.connect(_withExplicitPort(url),
|
||||
customClient: _sharedHttpClient());
|
||||
customClient: _sharedHttpClient(), pingInterval: kWsPingInterval);
|
||||
|
||||
/// `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
|
||||
|
||||
@@ -45,6 +45,7 @@ class PushBootstrap {
|
||||
|
||||
static const _pendingEndpointKey = 'pending_push_endpoint';
|
||||
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
|
||||
static const _registrationFailedKey = 'push_registration_failed';
|
||||
static const _store = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
@@ -57,6 +58,16 @@ class PushBootstrap {
|
||||
static Future<void> writeMode(String mode) =>
|
||||
_store.write(key: _modeKey, value: mode);
|
||||
|
||||
/// Odmítl distributor registraci? Selhání se dřív jen spolklo, takže
|
||||
/// uživatel měl v Nastavení „push zapnutý", ale budíčky nechodily a
|
||||
/// zprávy čekaly, než appku otevře. Nastavení si to přečte a řekne to.
|
||||
static Future<bool> registrationFailed() async =>
|
||||
await _store.read(key: _registrationFailedKey) == '1';
|
||||
|
||||
static Future<void> _setRegistrationFailed(bool failed) => failed
|
||||
? _store.write(key: _registrationFailedKey, value: '1')
|
||||
: _store.delete(key: _registrationFailedKey);
|
||||
|
||||
static Future<void> init(List<String> args) async {
|
||||
backgroundStart = args.contains('--unifiedpush-bg');
|
||||
|
||||
@@ -69,7 +80,8 @@ class PushBootstrap {
|
||||
} else {
|
||||
await UnifiedPush.initialize(
|
||||
onNewEndpoint: _onNewEndpoint,
|
||||
onRegistrationFailed: (_, _) {},
|
||||
onRegistrationFailed: (_, _) =>
|
||||
unawaited(_setRegistrationFailed(true)),
|
||||
onUnregistered: _onUnregistered,
|
||||
onMessage: _onMessage,
|
||||
);
|
||||
@@ -114,6 +126,7 @@ class PushBootstrap {
|
||||
}
|
||||
|
||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
||||
final s = _service;
|
||||
if (s != null) {
|
||||
s.handleNewEndpoint(endpoint.url);
|
||||
|
||||
@@ -144,6 +144,10 @@ class PushService {
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
/// Odmítl distributor poslední registraci? Nastavení to ukáže — jinak by
|
||||
/// push vypadal zapnutý, ale budíčky by nechodily.
|
||||
Future<bool> registrationFailed() => PushBootstrap.registrationFailed();
|
||||
|
||||
Future<void> setModeOff() async {
|
||||
await _teardownFcm(deleteToken: true);
|
||||
try {
|
||||
|
||||
@@ -72,9 +72,11 @@ class ImageProcessor {
|
||||
: 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;
|
||||
// PNG s REÁLNOU průhledností zůstává PNG (JPEG alfa kanál neumí,
|
||||
// a re-encode PNG→PNG metadata stejně zahodí); vše ostatní JPEG q80.
|
||||
// Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot,
|
||||
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
||||
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
||||
final bytes = keepPng
|
||||
? Uint8List.fromList(img.encodePng(image))
|
||||
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
||||
@@ -89,6 +91,16 @@ class ImageProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
/// Má obraz aspoň jeden ne-plně-krytý pixel? Běží v izolátu spolu se
|
||||
/// zbytkem zpracování, O(n) sken je proti dekódu/enkódu zanedbatelný.
|
||||
static bool _hasTransparency(img.Image image) {
|
||||
if (image.numChannels < 4) return false;
|
||||
for (final pixel in image) {
|
||||
if (pixel.a < pixel.maxChannelValue) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Uint8List _thumbnail(img.Image source) {
|
||||
var thumb = max(source.width, source.height) > thumbDimension
|
||||
? (source.width >= source.height
|
||||
|
||||
@@ -78,9 +78,15 @@ class MessagingService {
|
||||
bool _trustRootMismatch = false;
|
||||
bool get trustRootMismatch => _trustRootMismatch;
|
||||
StreamSubscription<dynamic>? _deliverSub;
|
||||
StreamSubscription<RelayConnectionState>? _stateSub;
|
||||
Timer? _registerRetryTimer;
|
||||
Timer? _purgeTimer;
|
||||
|
||||
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
|
||||
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
|
||||
/// dvakrát.
|
||||
bool _outboxRunning = false;
|
||||
|
||||
/// Transport relaye: false = přímé WSS, true = přes Tor (SOCKS5/Orbot na
|
||||
/// `.onion`). Čte se z nastavení při startu, přepíná se za běhu
|
||||
/// ([setTorEnabled]) přestavením [RelayClient].
|
||||
@@ -155,6 +161,10 @@ class MessagingService {
|
||||
final relay = _buildRelay();
|
||||
_relay = relay;
|
||||
_deliverSub = relay.deliveries.listen(_onDeliver);
|
||||
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
||||
_stateSub = relay.stateStream.listen((state) {
|
||||
if (state == RelayConnectionState.connected) unawaited(_flushOutbox());
|
||||
});
|
||||
|
||||
if (existingAccount != null) {
|
||||
relay.restoreSession(
|
||||
@@ -211,6 +221,7 @@ class MessagingService {
|
||||
/// push registrace se znovu odešle. Trust root zůstává pinovaný.
|
||||
Future<void> _rebuildRelay() async {
|
||||
await _deliverSub?.cancel();
|
||||
await _stateSub?.cancel();
|
||||
await _relay?.dispose();
|
||||
_senderCert = null;
|
||||
_certFetchedAt = null;
|
||||
@@ -218,6 +229,10 @@ class MessagingService {
|
||||
final relay = _buildRelay();
|
||||
_relay = relay;
|
||||
_deliverSub = relay.deliveries.listen(_onDeliver);
|
||||
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
||||
_stateSub = relay.stateStream.listen((state) {
|
||||
if (state == RelayConnectionState.connected) unawaited(_flushOutbox());
|
||||
});
|
||||
|
||||
final existingAccount = accountId;
|
||||
if (existingAccount != null) {
|
||||
@@ -357,6 +372,7 @@ class MessagingService {
|
||||
'v': 1,
|
||||
't': 'txt',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
'txt': text,
|
||||
're': ?replyToId,
|
||||
'ttl': ?ttl,
|
||||
@@ -409,23 +425,35 @@ class MessagingService {
|
||||
await storage.addMessage(contact.id, message);
|
||||
onUpdate?.call();
|
||||
|
||||
final delivered = await _sendContent(contact, {
|
||||
'v': 1,
|
||||
't': 'att',
|
||||
'id': message.id,
|
||||
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
||||
'ttl': ?ttl,
|
||||
'att': meta.toWire(),
|
||||
'p': _profile(),
|
||||
});
|
||||
// Stejné ošetření jako u textu: bez něj zůstala příloha po výpadku
|
||||
// spojení viset na „odesílá se" navždy. Označit jako failed ji dostane
|
||||
// do fronty odchozích ([_flushOutbox]) — blob je na relayi nahraný,
|
||||
// dosílá se jen meta.
|
||||
try {
|
||||
final delivered = await _sendContent(contact, {
|
||||
'v': 1,
|
||||
't': 'att',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
||||
'ttl': ?ttl,
|
||||
'att': meta.toWire(),
|
||||
'p': _profile(),
|
||||
});
|
||||
|
||||
final sent = message.copyWith(
|
||||
isDelivered: delivered,
|
||||
status: delivered ? MessageStatus.delivered : MessageStatus.sent,
|
||||
);
|
||||
await storage.updateMessage(contact.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
final sent = message.copyWith(
|
||||
isDelivered: delivered,
|
||||
status: delivered ? MessageStatus.delivered : MessageStatus.sent,
|
||||
);
|
||||
await storage.updateMessage(contact.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
} catch (_) {
|
||||
await storage.updateMessage(
|
||||
contact.id, message.copyWith(status: MessageStatus.failed));
|
||||
onUpdate?.call();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zpracuje a nahraje přílohu: obrázky re-enkóduje (EXIF/GPS pryč,
|
||||
@@ -496,10 +524,14 @@ class MessagingService {
|
||||
|
||||
Map<String, dynamic> _profile() {
|
||||
final identity = _identity!;
|
||||
final da = myDeliveryAddress;
|
||||
return {
|
||||
'n': identity.displayName,
|
||||
'aid': accountId,
|
||||
'da': base64Encode(myDeliveryAddress!),
|
||||
'aid': ?accountId,
|
||||
// Do doběhnutí vlastní registrace na relay ještě routing nemáme —
|
||||
// pole vynecháme (protistrana ho doplní z pozdějšího profilu), NIKDY
|
||||
// `da!` (null-check pád = „Null check operator used on a null value").
|
||||
'da': ?(da == null ? null : base64Encode(da)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -694,21 +726,31 @@ class MessagingService {
|
||||
await storage.addMessage(group.id, message);
|
||||
onUpdate?.call();
|
||||
|
||||
final anyOk = await _fanoutToGroup(group, {
|
||||
'v': 1,
|
||||
't': 'txt',
|
||||
'id': message.id,
|
||||
'gid': group.id,
|
||||
'txt': text,
|
||||
're': ?replyToId,
|
||||
'ttl': ?ttl,
|
||||
'p': _profile(),
|
||||
});
|
||||
final sent = message.copyWith(
|
||||
status: anyOk ? MessageStatus.sent : MessageStatus.failed);
|
||||
await storage.updateMessage(group.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
try {
|
||||
final anyOk = await _fanoutToGroup(group, {
|
||||
'v': 1,
|
||||
't': 'txt',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
'gid': group.id,
|
||||
'txt': text,
|
||||
're': ?replyToId,
|
||||
'ttl': ?ttl,
|
||||
'p': _profile(),
|
||||
});
|
||||
final sent = message.copyWith(
|
||||
status: anyOk ? MessageStatus.sent : MessageStatus.failed);
|
||||
await storage.updateMessage(group.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
} catch (e) {
|
||||
// Výjimka mimo per-member cestu (např. build obsahu) by jinak nechala
|
||||
// zprávu navždy ve stavu „odesílá se" (⏱). Označit jako selhalou.
|
||||
await storage.updateMessage(
|
||||
group.id, message.copyWith(status: MessageStatus.failed));
|
||||
onUpdate?.call();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Odešle přílohu do skupiny — blob se šifruje a nahrává JEDNOU,
|
||||
@@ -741,21 +783,29 @@ class MessagingService {
|
||||
await storage.addMessage(group.id, message);
|
||||
onUpdate?.call();
|
||||
|
||||
final anyOk = await _fanoutToGroup(group, {
|
||||
'v': 1,
|
||||
't': 'att',
|
||||
'id': message.id,
|
||||
'gid': group.id,
|
||||
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
||||
'ttl': ?ttl,
|
||||
'att': meta.toWire(),
|
||||
'p': _profile(),
|
||||
});
|
||||
final sent = message.copyWith(
|
||||
status: anyOk ? MessageStatus.sent : MessageStatus.failed);
|
||||
await storage.updateMessage(group.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
try {
|
||||
final anyOk = await _fanoutToGroup(group, {
|
||||
'v': 1,
|
||||
't': 'att',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
'gid': group.id,
|
||||
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
||||
'ttl': ?ttl,
|
||||
'att': meta.toWire(),
|
||||
'p': _profile(),
|
||||
});
|
||||
final sent = message.copyWith(
|
||||
status: anyOk ? MessageStatus.sent : MessageStatus.failed);
|
||||
await storage.updateMessage(group.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
} catch (e) {
|
||||
await storage.updateMessage(
|
||||
group.id, message.copyWith(status: MessageStatus.failed));
|
||||
onUpdate?.call();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reakce ve skupině (emoji == null = odebrat).
|
||||
@@ -1002,6 +1052,115 @@ class MessagingService {
|
||||
/// Společná odesílací cesta: session bootstrap (X3DH s anti-MITM
|
||||
/// kontrolou identity klíče), sealed encrypt, obálka na relay.
|
||||
/// Vrací true = doručeno online, false = uloženo do offline fronty.
|
||||
/// Kolik zaseklých zpráv poslat na jeden průchod. Po delším výpadku sítě
|
||||
/// jich může být hodně; dávka drží start spojení svižný a zbytek dojde
|
||||
/// při dalším ticku.
|
||||
static const _outboxBatch = 30;
|
||||
|
||||
/// Jak staré zaseklé zprávy ještě dosílat. Co leží dýl, patří uživateli
|
||||
/// do rukou — poslat týden starou zprávu bez zeptání by překvapilo.
|
||||
static const _outboxMaxAge = Duration(hours: 24);
|
||||
|
||||
/// Došle zprávy, které zůstaly viset po výpadku spojení.
|
||||
///
|
||||
/// Bez tohohle skončila každá zpráva odeslaná do mrtvého socketu jako
|
||||
/// `failed` (nebo věčné `sending` u příloh) a znovu ji poslal jedině
|
||||
/// uživatel ručně. Volá se po každém úspěšném (re)connectu; id zprávy
|
||||
/// zůstává stejné, takže protistrana duplicitu zahodí
|
||||
/// ([Conversation.addMessage]).
|
||||
Future<void> _flushOutbox() async {
|
||||
if (_outboxRunning || !isStarted) return;
|
||||
_outboxRunning = true;
|
||||
try {
|
||||
final identity = _identity;
|
||||
if (identity == null) return;
|
||||
final cutoff = DateTime.now().subtract(_outboxMaxAge);
|
||||
|
||||
final stuck = <(String convId, Message message)>[];
|
||||
for (final conv in await storage.loadConversations()) {
|
||||
for (final m in conv.messages) {
|
||||
if (m.senderId != identity.id) continue;
|
||||
if (m.status != MessageStatus.failed &&
|
||||
m.status != MessageStatus.sending) {
|
||||
continue;
|
||||
}
|
||||
if (m.timestamp.isBefore(cutoff) || m.isDeleted) continue;
|
||||
stuck.add((conv.contactId, m));
|
||||
}
|
||||
}
|
||||
if (stuck.isEmpty) return;
|
||||
|
||||
stuck.sort((a, b) => a.$2.timestamp.compareTo(b.$2.timestamp));
|
||||
for (final (convId, message) in stuck.take(_outboxBatch)) {
|
||||
if (connectionState != RelayConnectionState.connected) break;
|
||||
await _resend(convId, message);
|
||||
}
|
||||
onUpdate?.call();
|
||||
} finally {
|
||||
_outboxRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Jeden pokus o dodatečné odeslání. Trvalé chyby (chybný klíč protistrany,
|
||||
/// kontakt bez doručovací adresy) nechají zprávu `failed` a dál se
|
||||
/// neopakují — opakovat má smysl jen to, co shodila síť.
|
||||
Future<void> _resend(String convId, Message message) async {
|
||||
try {
|
||||
final group = await storage.loadGroup(convId);
|
||||
final bool delivered;
|
||||
if (group != null) {
|
||||
delivered = await _fanoutToGroup(group, _outboxWire(message, gid: convId));
|
||||
} else {
|
||||
final contact = (await storage.loadContacts())
|
||||
.where((c) => c.id == convId)
|
||||
.firstOrNull;
|
||||
if (contact == null) return;
|
||||
delivered = await _sendContent(contact, _outboxWire(message));
|
||||
}
|
||||
await storage.updateMessage(
|
||||
convId,
|
||||
message.copyWith(
|
||||
isDelivered: delivered,
|
||||
status: delivered ? MessageStatus.delivered : MessageStatus.sent,
|
||||
),
|
||||
);
|
||||
} on SepotUserError {
|
||||
// Srozumitelná chyba = stav, který se opakováním nespraví (chybí
|
||||
// doručovací adresa, nesedí identity klíč, podvržený relay).
|
||||
await storage.updateMessage(
|
||||
convId,
|
||||
message.copyWith(status: MessageStatus.failed),
|
||||
);
|
||||
} on Object {
|
||||
// Síť/timeout — nechat viset a zkusit po příštím reconnectu.
|
||||
await storage.updateMessage(
|
||||
convId,
|
||||
message.copyWith(status: MessageStatus.failed),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obsah zprávy pro drát při dodatečném odeslání. Drží se tvaru, který
|
||||
/// posílá [sendText]/[sendAttachment] — včetně `id`, aby šlo o TUTÉŽ
|
||||
/// zprávu, ne novou.
|
||||
Map<String, dynamic> _outboxWire(Message message, {String? gid}) {
|
||||
final isAttachment = message.attachment != null;
|
||||
final caption = message.content;
|
||||
return {
|
||||
'v': 1,
|
||||
't': isAttachment ? 'att' : 'txt',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
'gid': ?gid,
|
||||
if (isAttachment) 'att': message.attachment!.toWire(),
|
||||
if (!isAttachment || (caption != null && caption.isNotEmpty))
|
||||
'txt': caption ?? '',
|
||||
're': ?message.replyToId,
|
||||
'ttl': ?message.disappearAfterSeconds,
|
||||
'p': _profile(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> _sendContent(
|
||||
Contact contact,
|
||||
Map<String, dynamic> content,
|
||||
@@ -1009,6 +1168,13 @@ class MessagingService {
|
||||
final crypto = _crypto ?? (throw StateError('Service not started'));
|
||||
final relay = _relay!;
|
||||
|
||||
// Vlastní registrace na relay ještě nedoběhla (čerstvá instalace,
|
||||
// pomalá síť, Tor bez Orbotu) → nemáme sender adresu ani sender cert.
|
||||
// Srozumitelná chyba místo null-check pádu; registrace běží na pozadí.
|
||||
if (accountId == null || myDeliveryAddress == null) {
|
||||
throw const NotYetRegisteredError();
|
||||
}
|
||||
|
||||
final peer = contact.publicIdentity;
|
||||
final peerAid = peer.accountId;
|
||||
final peerDa = peer.deliveryAddress;
|
||||
@@ -1045,13 +1211,24 @@ class MessagingService {
|
||||
final trustRoot = _trustRoot;
|
||||
if (crypto == null || trustRoot == null) return;
|
||||
|
||||
final SepotSealedMessage opened;
|
||||
try {
|
||||
final opened = await crypto.sealedDecrypt(
|
||||
opened = await crypto.sealedDecrypt(
|
||||
ciphertext: Uint8List.fromList(envelope.payload),
|
||||
trustRoot: trustRoot,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
} catch (_) {
|
||||
// Nedešifrovatelná/cizí obálka: NEACKovat, ať ji fronta podrží
|
||||
// (může jít o dočasný stav — např. cert refresh).
|
||||
return;
|
||||
}
|
||||
|
||||
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
|
||||
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
|
||||
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
|
||||
// trvale otravovala frontu při každém reconnectu.
|
||||
try {
|
||||
final content =
|
||||
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
|
||||
final type = content['t'] as String?;
|
||||
@@ -1059,7 +1236,12 @@ class MessagingService {
|
||||
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', // 1:1 + skupiny
|
||||
'ginv', 'gleave', 'gmeta', // správa skupin
|
||||
};
|
||||
if (content['v'] != 1 || !known.contains(type)) return;
|
||||
if (content['v'] != 1 || !known.contains(type)) {
|
||||
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
||||
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
final contact = await _findOrCreateContact(opened, content);
|
||||
final senderFp = contact.id;
|
||||
@@ -1188,7 +1370,7 @@ class MessagingService {
|
||||
type: MessageType.text,
|
||||
content: text,
|
||||
replyToId: replyTo,
|
||||
timestamp: DateTime.now(),
|
||||
timestamp: wireSentAt(content['ts']),
|
||||
isDelivered: true,
|
||||
disappearAfterSeconds: ttl,
|
||||
);
|
||||
@@ -1214,18 +1396,31 @@ class MessagingService {
|
||||
fileName: meta.name,
|
||||
fileSize: meta.size,
|
||||
replyToId: replyTo,
|
||||
timestamp: DateTime.now(),
|
||||
timestamp: wireSentAt(content['ts']),
|
||||
isDelivered: true,
|
||||
attachment: meta,
|
||||
disappearAfterSeconds: ttl,
|
||||
);
|
||||
}
|
||||
await storage.addMessage(convId, message);
|
||||
final isNew = await storage.addMessage(convId, message);
|
||||
|
||||
// Uloženo na disku → teprve teď smí relay obálku zahodit.
|
||||
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
|
||||
// neúspěchu má UI retry, ACK na něj nečeká.)
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
|
||||
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
|
||||
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
|
||||
// doručenku pošli — protistrana ji nejspíš právě proto nedostala.
|
||||
if (!isNew) {
|
||||
unawaited(_sendControl(contact, {
|
||||
't': 'dlv',
|
||||
'ref': message.id,
|
||||
'gid': ?gid,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate?.call();
|
||||
onMessageReceived?.call(contact, message);
|
||||
|
||||
@@ -1242,12 +1437,39 @@ class MessagingService {
|
||||
if (message.attachment != null) {
|
||||
unawaited(downloadAttachment(convId, message));
|
||||
}
|
||||
} on FormatException {
|
||||
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
|
||||
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
} on TypeError {
|
||||
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
|
||||
// trvale vadné, ACK a zahodit.
|
||||
_relay?.ack(envelope.msgUuid);
|
||||
} catch (_) {
|
||||
// Nedešifrovatelná/cizí obálka: NEACKovat, ať ji fronta podrží
|
||||
// (může jít o dočasný stav — např. cert refresh)
|
||||
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
|
||||
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
|
||||
// pravděpodobně selže. Trvalé chyby obsahu řeší větve výše.
|
||||
}
|
||||
}
|
||||
|
||||
/// Čas odeslání z drátu (ms). Bez něj by zpráva dostala čas doručení —
|
||||
/// po vyzvednutí offline fronty by celá konverzace uskočila na „teď"
|
||||
/// a každý člen skupiny by u téže zprávy viděl jiný čas.
|
||||
///
|
||||
/// Klienti ≤1.1.1 `ts` neposílají → fallback na čas doručení (chová se
|
||||
/// jako dřív). Odesílatel si čas diktuje sám (jde E2E kanálem), proto
|
||||
/// strop: hodiny zařízení se běžně rozcházejí o minuty, ale zpráva
|
||||
/// nesmí uskočit mimo konverzaci.
|
||||
@visibleForTesting
|
||||
static DateTime wireSentAt(Object? raw) {
|
||||
final now = DateTime.now();
|
||||
if (raw is! int) return now;
|
||||
final sent = DateTime.fromMillisecondsSinceEpoch(raw);
|
||||
if (sent.isAfter(now.add(const Duration(minutes: 5)))) return now;
|
||||
if (sent.isBefore(DateTime(2020))) return now;
|
||||
return sent;
|
||||
}
|
||||
|
||||
/// Aplikuje příchozí reakci/edit/delete na cílovou zprávu v dané
|
||||
/// konverzaci (1:1 = kontakt, skupina = gid). Klíč reakce = fingerprint
|
||||
/// odesílatele (kryptograficky ověřený sealed senderem, ne
|
||||
@@ -1462,6 +1684,7 @@ class MessagingService {
|
||||
_purgeTimer?.cancel();
|
||||
_purgeTimer = null;
|
||||
await _deliverSub?.cancel();
|
||||
await _stateSub?.cancel();
|
||||
await _relay?.dispose();
|
||||
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
|
||||
_db = null;
|
||||
|
||||
@@ -442,11 +442,15 @@ class SecureStorageService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addMessage(String contactId, Message message) async {
|
||||
/// Přidá zprávu do konverzace. Vrací false, když už tam zpráva s tímhle id
|
||||
/// je (opakované doručení) — volající pak přeskočí navazující kroky jako
|
||||
/// doručenku nebo stahování přílohy.
|
||||
Future<bool> addMessage(String contactId, Message message) async {
|
||||
var conv = await loadConversation(contactId);
|
||||
conv ??= Conversation(contactId: contactId);
|
||||
conv.addMessage(message);
|
||||
if (!conv.addMessage(message)) return false;
|
||||
await saveConversation(conv);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
|
||||
|
||||
@@ -6,6 +6,8 @@ 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/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/sepot_logo.dart';
|
||||
|
||||
@@ -43,6 +45,7 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
);
|
||||
if (!ok) return; // uživatel zrušil → zůstává PIN
|
||||
await storage.unlockWithBiometrics();
|
||||
_restartMessagingAfterUnlock();
|
||||
if (mounted) context.go('/chats');
|
||||
} catch (_) {
|
||||
// biometrie nedostupná/selhala — PIN je vždy fallback
|
||||
@@ -211,6 +214,16 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Auto-lock službu zastavil a storage zamkl; po odemčení je nutné
|
||||
/// messaging znovu nastartovat. messagingStartProvider drží starou
|
||||
/// „hotovou" hodnotu (start se sám neprovede) → invalidace ho přepočítá
|
||||
/// nad odemčeným storage a spustí start() znovu (jinak žádný příjem ani
|
||||
/// odesílání až do restartu appky).
|
||||
void _restartMessagingAfterUnlock() {
|
||||
ref.invalidate(messagingStartProvider);
|
||||
ref.invalidate(pushInitProvider);
|
||||
}
|
||||
|
||||
Future<void> _verifyPin() async {
|
||||
setState(() => _isVerifying = true);
|
||||
|
||||
@@ -218,6 +231,7 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
// „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);
|
||||
_restartMessagingAfterUnlock();
|
||||
if (mounted) context.go('/chats');
|
||||
} on WrongPinException {
|
||||
setState(() {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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/features/chat_list/push_hint_banner.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
@@ -62,7 +63,13 @@ class ChatListScreen extends ConsumerWidget {
|
||||
}
|
||||
final hasArchive = archived.isNotEmpty;
|
||||
|
||||
return ListView.builder(
|
||||
return Column(
|
||||
children: [
|
||||
// Bez budíčků appka na pozadí spí — ať uživatel ví, proč
|
||||
// mu zprávy chodí, až když Šepot otevře.
|
||||
const PushHintBanner(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: active.length + (hasArchive ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -95,7 +102,10 @@ class ChatListScreen extends ConsumerWidget {
|
||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||
onLongPress: () => _showChatActions(context, ref, conv, name),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Upozornění nad seznamem chatů, když budíčky nefungují.
|
||||
///
|
||||
/// Bez pushe Android appku na pozadí uspí a zprávy dorazí, až ji uživatel
|
||||
/// sám otevře — vypadá to jako výpadek doručování, i když relay má všechno
|
||||
/// připravené ve frontě. Proužek je proto informace o stavu, ne reklama:
|
||||
/// zmizí, jakmile si push nastaví, a jde odmítnout natrvalo.
|
||||
class PushHintBanner extends ConsumerWidget {
|
||||
const PushHintBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final health = ref.watch(pushHealthProvider).valueOrNull;
|
||||
final dismissed = ref.watch(pushHintDismissedProvider).valueOrNull ?? true;
|
||||
if (dismissed || health == null || health == PushHealth.ok) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final text = health == PushHealth.failed
|
||||
? context.l10n.pushHintFailed
|
||||
: context.l10n.pushHintOff;
|
||||
|
||||
return Material(
|
||||
color: SepotColors.surfaceVariant,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/settings'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 18,
|
||||
color: health == PushHealth.failed
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
color: SepotColors.textMuted,
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(storageProvider)
|
||||
.setSetting('push_hint_dismissed', true);
|
||||
ref.invalidate(pushHintDismissedProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import 'package:sepot/features/conversation/widgets/attachment_content.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/features/conversation/photo_preview_screen.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
@@ -83,7 +85,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
.firstOrNull;
|
||||
final conversationAsync = ref.watch(conversationProvider(widget.contactId));
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
// Skupinová konverzace: contactId nese id skupiny (gid)
|
||||
// Skupinová konverzace: contactId == gid
|
||||
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull;
|
||||
|
||||
if (group != null) {
|
||||
@@ -266,14 +268,14 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Skupinová konverzace: jména autorů nad cizími
|
||||
/// Skupinová konverzace : jména autorů nad cizími
|
||||
/// bublinami, menu Členové / Přejmenovat / Opustit, žádný verify badge
|
||||
/// (ověřují se jednotliví členové v Kontaktech).
|
||||
Widget _buildGroupScaffold(
|
||||
BuildContext context,
|
||||
Group group,
|
||||
AsyncValue<Conversation?> conversationAsync,
|
||||
dynamic identity, {
|
||||
LocalIdentity? identity, {
|
||||
required List<Contact> contacts,
|
||||
}) {
|
||||
// Fingerprint → jméno pro popisky autorů. Členy, kteří ještě nejsou
|
||||
@@ -698,7 +700,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
Text(
|
||||
context.l10n.chatEmptyEncrypted,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
@@ -707,7 +709,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
Text(
|
||||
context.l10n.chatEmptyNoOneElse,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
@@ -953,6 +955,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
try {
|
||||
// Moc krátké klepnutí — zahoď (uživatel to nechtěl poslat)
|
||||
if (dur.inMilliseconds < 800) {
|
||||
await file.delete();
|
||||
return;
|
||||
}
|
||||
final bytes = await file.readAsBytes();
|
||||
@@ -962,6 +965,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
durationMs: dur.inMilliseconds,
|
||||
);
|
||||
ref.invalidate(conversationProvider(widget.contactId));
|
||||
await file.delete();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -970,12 +974,6 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
.chatVoiceSendFailed(describeError(context.l10n, e)))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Dočasný audio soubor leží v cache nezašifrovaný — smaž ho ve všech
|
||||
// větvích (odesláno, zahozeno i chyba), ať v ní po pádu nezůstane.
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1252,19 +1250,40 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
Uint8List? bytes;
|
||||
var fileName = '';
|
||||
String? mime;
|
||||
String? caption;
|
||||
try {
|
||||
switch (action) {
|
||||
case _AttachAction.gallery:
|
||||
case _AttachAction.camera:
|
||||
// maxWidth/Height 2048 = strop ImageProcessoru; menší vstup šetří
|
||||
// RAM při náhledu i finálním renderu úprav.
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: action == _AttachAction.gallery
|
||||
? ImageSource.gallery
|
||||
: ImageSource.camera,
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
imageQuality: 92,
|
||||
);
|
||||
if (picked == null) return;
|
||||
bytes = await picked.readAsBytes();
|
||||
fileName = picked.name;
|
||||
mime = picked.mimeType;
|
||||
final pickedBytes = await picked.readAsBytes();
|
||||
if (!mounted) return;
|
||||
// Nic neodchází rovnou z kamery — náhled s úpravami a potvrzením.
|
||||
final preview = await Navigator.of(context).push<PhotoPreviewResult>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => PhotoPreviewScreen(
|
||||
bytes: pickedBytes,
|
||||
fileName: picked.name,
|
||||
mime: picked.mimeType,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (preview == null || !mounted) return;
|
||||
bytes = preview.bytes;
|
||||
fileName = preview.fileName;
|
||||
mime = preview.mime;
|
||||
caption = preview.caption;
|
||||
case _AttachAction.document:
|
||||
final result = await FilePicker.pickFiles(withData: true);
|
||||
final file = result?.files.firstOrNull;
|
||||
@@ -1295,6 +1314,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
mime: mime,
|
||||
caption: caption,
|
||||
);
|
||||
ref.invalidate(conversationProvider(widget.contactId));
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/services/image_processor.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
|
||||
/// Výsledek náhledu: co poslat. `null` z Navigatoru = uživatel zrušil.
|
||||
class PhotoPreviewResult {
|
||||
const PhotoPreviewResult({
|
||||
required this.bytes,
|
||||
required this.fileName,
|
||||
required this.mime,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
final Uint8List bytes;
|
||||
final String fileName;
|
||||
final String? mime;
|
||||
final String? caption;
|
||||
}
|
||||
|
||||
/// Náhled fotky před odesláním — nic neodchází bez potvrzení.
|
||||
///
|
||||
/// Úpravy: kreslení prstem, text na fotce (tažení prstem, tap = upravit),
|
||||
/// otočení o 90°. Tahy a texty žijí v relativních souřadnicích [0..1]
|
||||
/// zobrazeného (already otočeného) obrazu, takže finální render v plném
|
||||
/// rozlišení je jen přeškálování. Bez úprav odchází původní bajty —
|
||||
/// EXIF/GPS z nich stáhne ImageProcessor při odeslání.
|
||||
class PhotoPreviewScreen extends StatefulWidget {
|
||||
const PhotoPreviewScreen({
|
||||
super.key,
|
||||
required this.bytes,
|
||||
required this.fileName,
|
||||
this.mime,
|
||||
});
|
||||
|
||||
final Uint8List bytes;
|
||||
final String fileName;
|
||||
final String? mime;
|
||||
|
||||
@override
|
||||
State<PhotoPreviewScreen> createState() => _PhotoPreviewScreenState();
|
||||
}
|
||||
|
||||
enum _EditMode { none, draw }
|
||||
|
||||
class _Stroke {
|
||||
_Stroke({required this.color, required this.width, required this.points});
|
||||
|
||||
final Color color;
|
||||
|
||||
/// Šířka tahu jako podíl šířky obrazu (přežije škálování).
|
||||
final double width;
|
||||
final List<Offset> points; // relativní [0..1]
|
||||
}
|
||||
|
||||
class _OverlayText {
|
||||
_OverlayText({
|
||||
required this.text,
|
||||
required this.color,
|
||||
required this.position,
|
||||
});
|
||||
|
||||
String text;
|
||||
Color color;
|
||||
|
||||
/// Střed textu, relativně [0..1] k zobrazenému obrazu.
|
||||
Offset position;
|
||||
|
||||
/// Výška fontu jako podíl šířky obrazu.
|
||||
static const double fontScale = 0.05;
|
||||
}
|
||||
|
||||
class _PhotoPreviewScreenState extends State<PhotoPreviewScreen> {
|
||||
static const _palette = <Color>[
|
||||
Colors.white,
|
||||
Colors.black,
|
||||
SepotColors.ember,
|
||||
SepotColors.sage,
|
||||
SepotColors.mist,
|
||||
SepotColors.warning,
|
||||
SepotColors.error,
|
||||
];
|
||||
|
||||
final _captionController = TextEditingController();
|
||||
ui.Image? _image;
|
||||
_EditMode _mode = _EditMode.none;
|
||||
Color _color = Colors.white;
|
||||
int _quarterTurns = 0;
|
||||
final List<_Stroke> _strokes = [];
|
||||
final List<_OverlayText> _texts = [];
|
||||
bool _rendering = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_decode();
|
||||
}
|
||||
|
||||
Future<void> _decode() async {
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(widget.bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
if (mounted) {
|
||||
setState(() => _image = frame.image);
|
||||
} else {
|
||||
frame.image.dispose();
|
||||
}
|
||||
} catch (_) {
|
||||
// Nedekódovatelné bajty (picker by je dát neměl) — zpátky bez
|
||||
// odeslání, ať uživatel nezůstane viset na věčném spinneru.
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.photoPreviewDecodeFailed)),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_captionController.dispose();
|
||||
_image?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _edited =>
|
||||
_strokes.isNotEmpty || _texts.isNotEmpty || _quarterTurns % 4 != 0;
|
||||
|
||||
/// Otočení o 90° po směru: relativní bod (x, y) → (1 − y, x).
|
||||
void _rotate() {
|
||||
setState(() {
|
||||
_quarterTurns = (_quarterTurns + 1) % 4;
|
||||
for (final s in _strokes) {
|
||||
for (var i = 0; i < s.points.length; i++) {
|
||||
s.points[i] = Offset(1 - s.points[i].dy, s.points[i].dx);
|
||||
}
|
||||
}
|
||||
for (final t in _texts) {
|
||||
t.position = Offset(1 - t.position.dy, t.position.dx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _undo() {
|
||||
setState(() {
|
||||
if (_strokes.isNotEmpty) _strokes.removeLast();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addText() async {
|
||||
final text = await _editTextDialog(initial: '');
|
||||
if (text == null || text.trim().isEmpty) return;
|
||||
setState(() {
|
||||
_texts.add(_OverlayText(
|
||||
text: text.trim(),
|
||||
color: _color,
|
||||
position: const Offset(0.5, 0.5),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _editText(_OverlayText item) async {
|
||||
final text = await _editTextDialog(initial: item.text, removable: true);
|
||||
if (text == null) return; // zrušeno
|
||||
setState(() {
|
||||
if (text.trim().isEmpty) {
|
||||
_texts.remove(item); // „Odebrat" vrací prázdný řetězec
|
||||
} else {
|
||||
item.text = text.trim();
|
||||
item.color = _color;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _editTextDialog({
|
||||
required String initial,
|
||||
bool removable = false,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => _TextEditDialog(initial: initial, removable: removable),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
if (_rendering) return;
|
||||
setState(() => _rendering = true);
|
||||
try {
|
||||
final caption = _captionController.text.trim();
|
||||
final result = _edited
|
||||
? PhotoPreviewResult(
|
||||
// Render je vždy PNG — mime říká pravdu o bajtech. Finální
|
||||
// formát rozhodne ImageProcessor (bez průhlednosti → JPEG).
|
||||
bytes: await _renderEdited(),
|
||||
fileName: 'photo.png',
|
||||
mime: 'image/png',
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
)
|
||||
: PhotoPreviewResult(
|
||||
bytes: widget.bytes,
|
||||
fileName: widget.fileName,
|
||||
mime: widget.mime,
|
||||
caption: caption.isEmpty ? null : caption,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, result);
|
||||
} finally {
|
||||
if (mounted) setState(() => _rendering = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vykreslí otočený obraz + tahy + texty v plném rozlišení (strop 2048 px
|
||||
/// po delší straně — dál by to ImageProcessor stejně zmenšil).
|
||||
Future<Uint8List> _renderEdited() async {
|
||||
final src = _image!;
|
||||
final rotated = _quarterTurns.isOdd;
|
||||
final srcW = src.width.toDouble();
|
||||
final srcH = src.height.toDouble();
|
||||
final scale = math.min(
|
||||
1.0,
|
||||
ImageProcessor.maxDimension / math.max(srcW, srcH),
|
||||
);
|
||||
final outW = (rotated ? srcH : srcW) * scale;
|
||||
final outH = (rotated ? srcW : srcH) * scale;
|
||||
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
canvas.save();
|
||||
switch (_quarterTurns % 4) {
|
||||
case 1:
|
||||
canvas.translate(outW, 0);
|
||||
canvas.rotate(math.pi / 2);
|
||||
case 2:
|
||||
canvas.translate(outW, outH);
|
||||
canvas.rotate(math.pi);
|
||||
case 3:
|
||||
canvas.translate(0, outH);
|
||||
canvas.rotate(3 * math.pi / 2);
|
||||
}
|
||||
canvas.drawImageRect(
|
||||
src,
|
||||
Rect.fromLTWH(0, 0, srcW, srcH),
|
||||
Rect.fromLTWH(0, 0, srcW * scale, srcH * scale),
|
||||
Paint()..filterQuality = FilterQuality.high,
|
||||
);
|
||||
canvas.restore();
|
||||
|
||||
_paintOverlay(canvas, Size(outW, outH));
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(outW.round(), outH.round());
|
||||
picture.dispose();
|
||||
try {
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return data!.buffer.asUint8List();
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tahy a texty — společná cesta pro živý náhled i finální render,
|
||||
/// aby odeslaná fotka vypadala přesně jako náhled.
|
||||
void _paintOverlay(Canvas canvas, Size size) {
|
||||
for (final stroke in _strokes) {
|
||||
final paint = Paint()
|
||||
..color = stroke.color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke.width * size.width
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
final points =
|
||||
stroke.points.map((p) => Offset(p.dx * size.width, p.dy * size.height));
|
||||
final path = Path()..addPolygon(points.toList(), false);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
for (final item in _texts) {
|
||||
final painter = _textPainter(item, size);
|
||||
final center = Offset(
|
||||
item.position.dx * size.width,
|
||||
item.position.dy * size.height,
|
||||
);
|
||||
painter.paint(
|
||||
canvas,
|
||||
center - Offset(painter.width / 2, painter.height / 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextPainter _textPainter(_OverlayText item, Size size) {
|
||||
return TextPainter(
|
||||
text: TextSpan(
|
||||
text: item.text,
|
||||
style: TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
color: item.color,
|
||||
fontSize: _OverlayText.fontScale * size.width,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 4, color: Colors.black54),
|
||||
],
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
)..layout(maxWidth: size.width);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final image = _image;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildToolbar(l10n),
|
||||
Expanded(
|
||||
child: image == null
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
_buildCanvas(image, constraints),
|
||||
),
|
||||
),
|
||||
if (_mode == _EditMode.draw || _texts.isNotEmpty)
|
||||
_buildPalette(),
|
||||
_buildBottomBar(l10n),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolbar(AppLocalizations l10n) {
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
tooltip: l10n.commonCancel,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_strokes.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo, color: Colors.white),
|
||||
tooltip: l10n.photoPreviewUndo,
|
||||
onPressed: _undo,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.rotate_90_degrees_cw_outlined,
|
||||
color: Colors.white),
|
||||
tooltip: l10n.photoPreviewRotate,
|
||||
onPressed: _image == null ? null : _rotate,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.gesture,
|
||||
color: _mode == _EditMode.draw ? SepotColors.ember : Colors.white,
|
||||
),
|
||||
tooltip: l10n.photoPreviewDraw,
|
||||
onPressed: _image == null
|
||||
? null
|
||||
: () => setState(() {
|
||||
_mode = _mode == _EditMode.draw
|
||||
? _EditMode.none
|
||||
: _EditMode.draw;
|
||||
}),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.title, color: Colors.white),
|
||||
tooltip: l10n.photoPreviewAddText,
|
||||
onPressed: _image == null ? null : _addText,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCanvas(ui.Image image, BoxConstraints constraints) {
|
||||
final rotated = _quarterTurns.isOdd;
|
||||
final iw = (rotated ? image.height : image.width).toDouble();
|
||||
final ih = (rotated ? image.width : image.height).toDouble();
|
||||
final scale = math.min(
|
||||
constraints.maxWidth / iw,
|
||||
constraints.maxHeight / ih,
|
||||
);
|
||||
final size = Size(iw * scale, ih * scale);
|
||||
|
||||
Offset toRelative(Offset local) =>
|
||||
Offset(local.dx / size.width, local.dy / size.height);
|
||||
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
// RawImage kreslí už dekódovaný _image — Image.memory by tytéž
|
||||
// bajty dekódoval podruhé (u 2048² ~16 MB RGBA navíc).
|
||||
child: RotatedBox(
|
||||
quarterTurns: _quarterTurns,
|
||||
child: RawImage(image: image, fit: BoxFit.fill),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: _mode == _EditMode.draw
|
||||
? (d) => setState(() {
|
||||
_strokes.add(_Stroke(
|
||||
color: _color,
|
||||
width: 0.008,
|
||||
points: [toRelative(d.localPosition)],
|
||||
));
|
||||
})
|
||||
: null,
|
||||
onPanUpdate: _mode == _EditMode.draw
|
||||
? (d) => setState(() {
|
||||
_strokes.last.points.add(toRelative(d.localPosition));
|
||||
})
|
||||
: null,
|
||||
child: CustomPaint(
|
||||
painter: _OverlayPainter(
|
||||
strokes: List.of(_strokes),
|
||||
paintAll: _paintOverlay,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Texty mají vlastní gesta (drag + tap), proto jedou nad
|
||||
// kreslicí vrstvou jako neviditelné hit-oblasti.
|
||||
for (final item in _texts)
|
||||
Positioned(
|
||||
left: item.position.dx * size.width - size.width / 2,
|
||||
top: item.position.dy * size.height - 24,
|
||||
width: size.width,
|
||||
height: 48,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onTap: () => _editText(item),
|
||||
onPanUpdate: (d) => setState(() {
|
||||
final next = item.position + toRelative(d.delta);
|
||||
item.position = Offset(
|
||||
next.dx.clamp(0.02, 0.98),
|
||||
next.dy.clamp(0.02, 0.98),
|
||||
);
|
||||
}),
|
||||
child: Center(
|
||||
child: Container(
|
||||
// Neviditelný úchop kolem textu, ať se dá chytit prstem
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
color: Colors.transparent,
|
||||
child: Opacity(
|
||||
opacity: 0,
|
||||
child: Text(
|
||||
item.text,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: _OverlayText.fontScale * size.width,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPalette() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (final color in _palette)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _color = color),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: _color == color
|
||||
? SepotColors.textPrimary
|
||||
: SepotColors.border,
|
||||
width: _color == color ? 2.5 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _captionController,
|
||||
maxLength: 500,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
textInputAction: TextInputAction.newline,
|
||||
style: const TextStyle(color: SepotColors.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: false,
|
||||
hintText: l10n.photoPreviewCaptionHint,
|
||||
hintStyle: const TextStyle(color: SepotColors.textMuted),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: const BorderSide(color: SepotColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: const BorderSide(color: SepotColors.ember),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: IconButton.filled(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: SepotColors.ember,
|
||||
foregroundColor: SepotColors.emberInk,
|
||||
),
|
||||
tooltip: l10n.photoPreviewSend,
|
||||
onPressed: _image == null || _rendering ? null : _send,
|
||||
icon: _rendering
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.emberInk,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog textu na fotce — vlastní widget kvůli životnímu cyklu
|
||||
/// TextEditingControlleru (dispose patří do State, ne za showDialog).
|
||||
class _TextEditDialog extends StatefulWidget {
|
||||
const _TextEditDialog({required this.initial, required this.removable});
|
||||
|
||||
final String initial;
|
||||
final bool removable;
|
||||
|
||||
@override
|
||||
State<_TextEditDialog> createState() => _TextEditDialogState();
|
||||
}
|
||||
|
||||
class _TextEditDialogState extends State<_TextEditDialog> {
|
||||
late final TextEditingController _controller =
|
||||
TextEditingController(text: widget.initial);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.photoPreviewTextDialogTitle),
|
||||
content: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
maxLength: 100,
|
||||
decoration: const InputDecoration(counterText: ''),
|
||||
),
|
||||
actions: [
|
||||
if (widget.removable)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, ''),
|
||||
child: Text(
|
||||
context.l10n.photoPreviewRemoveText,
|
||||
style: const TextStyle(color: SepotColors.error),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _controller.text),
|
||||
child: Text(context.l10n.commonOk),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Živý náhled úprav — deleguje na tutéž kreslicí rutinu jako finální
|
||||
/// render, jen v rozlišení obrazovky.
|
||||
class _OverlayPainter extends CustomPainter {
|
||||
_OverlayPainter({required this.strokes, required this.paintAll});
|
||||
|
||||
final List<_Stroke> strokes;
|
||||
final void Function(Canvas canvas, Size size) paintAll;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) => paintAll(canvas, size);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _OverlayPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class _QrScanScreenState extends ConsumerState<QrScanScreen> {
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red, // Jakákoliv neprůhledná barva
|
||||
color: Colors.red, // Jakákoliv nepůhledná barva
|
||||
backgroundBlendMode: BlendMode.dstOut,
|
||||
),
|
||||
),
|
||||
@@ -198,11 +198,9 @@ class _QrScanScreenState extends ConsumerState<QrScanScreen> {
|
||||
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()
|
||||
: '?',
|
||||
identity.displayName.isEmpty
|
||||
? '?'
|
||||
: identity.displayName.characters.first.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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/models/identity.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
|
||||
@@ -27,12 +28,24 @@ class QrShareScreen extends ConsumerWidget {
|
||||
return Center(child: Text(context.l10n.qrIdentityNotFound));
|
||||
}
|
||||
|
||||
// Sdílet s routingem (accountId + delivery address z relaye),
|
||||
// jinak protistrana nemá kam poslat první zprávu
|
||||
// Sledovat stav připojení: po dokončení vlastní registrace se
|
||||
// routing (aid+da) zapíše do DB a relay se připojí → tenhle watch
|
||||
// obrazovku překreslí a QR se z „připojuji" přepne na hotový kód.
|
||||
ref.watch(relayConnectionProvider);
|
||||
final service = ref.watch(messagingServiceProvider);
|
||||
final shareString = (service.shareableIdentity ??
|
||||
identity.publicIdentity)
|
||||
.toShareString();
|
||||
final shareable = service.shareableIdentity;
|
||||
final hasRouting = shareable != null &&
|
||||
shareable.accountId != null &&
|
||||
shareable.deliveryAddress != null;
|
||||
|
||||
// Bez routingu by QR/odkaz byl neúplný (protistrana by neměla kam
|
||||
// poslat první zprávu → „Kontakt nemá doručovací adresu"). Radši
|
||||
// než mrtvý kód ukázat stav připojování.
|
||||
if (!hasRouting) {
|
||||
return _ConnectingPlaceholder(identity: identity);
|
||||
}
|
||||
|
||||
final shareString = shareable.toShareString();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -157,3 +170,56 @@ class QrShareScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stav obrazovky, dokud vlastní registrace na relay nedoběhne — místo
|
||||
/// neúplného QR (bez routingu) ukáže identitu a hlášku o připojování.
|
||||
class _ConnectingPlaceholder extends StatelessWidget {
|
||||
const _ConnectingPlaceholder({required this.identity});
|
||||
|
||||
final LocalIdentity identity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: SepotColors.ember),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
context.l10n.qrShareConnectingTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.qrShareConnectingBody,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
identity.sepotId,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
String _mode = 'off';
|
||||
String? _distributor;
|
||||
bool _busy = false;
|
||||
bool _registrationFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -31,10 +32,12 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
final push = ref.read(pushServiceProvider);
|
||||
final mode = await push.currentMode();
|
||||
final d = await push.currentDistributor();
|
||||
final failed = await push.registrationFailed();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mode = mode;
|
||||
_distributor = d;
|
||||
_registrationFailed = failed;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -160,12 +163,17 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = switch (_mode) {
|
||||
'up' => context.l10n.pushSubtitleUnifiedPush(
|
||||
_distributor ?? context.l10n.pushNoDistributorFallback),
|
||||
'fcm' => context.l10n.pushSubtitleFcm,
|
||||
_ => context.l10n.commonOff,
|
||||
};
|
||||
// Selhaná registrace přebíjí popis režimu — „zapnuto" bez budíčků je
|
||||
// horší než vypnuto, protože uživatel čeká zprávy, které nepřijdou.
|
||||
final failed = _registrationFailed && _mode != 'off';
|
||||
final subtitle = failed
|
||||
? context.l10n.pushRegistrationFailed
|
||||
: switch (_mode) {
|
||||
'up' => context.l10n.pushSubtitleUnifiedPush(
|
||||
_distributor ?? context.l10n.pushNoDistributorFallback),
|
||||
'fcm' => context.l10n.pushSubtitleFcm,
|
||||
_ => context.l10n.commonOff,
|
||||
};
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 40,
|
||||
@@ -184,7 +192,10 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: failed ? SepotColors.ember : SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
trailing: _busy
|
||||
? const SizedBox(
|
||||
|
||||
@@ -12,6 +12,7 @@ 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/models/identity.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
@@ -296,7 +297,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileCard(BuildContext context, dynamic identity) {
|
||||
Widget _buildProfileCard(BuildContext context, LocalIdentity identity) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -311,7 +312,9 @@ class SettingsScreen extends ConsumerWidget {
|
||||
radius: 36,
|
||||
backgroundColor: SepotColors.ember.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
identity.displayName.substring(0, 1).toUpperCase(),
|
||||
identity.displayName.isEmpty
|
||||
? '?'
|
||||
: identity.displayName.characters.first.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 28,
|
||||
@@ -324,7 +327,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
Text(
|
||||
identity.displayName,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
@@ -352,7 +355,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.settingsFingerprint(identity.fingerprint),
|
||||
context.l10n.settingsFingerprint(identity.fingerprint.display),
|
||||
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
|
||||
),
|
||||
],
|
||||
@@ -368,7 +371,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textMuted,
|
||||
|
||||
+65
-1
@@ -375,6 +375,14 @@
|
||||
"@errorRelayUnavailable": {
|
||||
"description": "StateError z _senderCertificate, když se nepodařilo získat sender certificate (offline relay); propaguje se do odesílací cesty, zobrazuje chat UI při selhání odeslání."
|
||||
},
|
||||
"errorNotYetRegistered": "Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.",
|
||||
"@errorNotYetRegistered": {
|
||||
"description": "NotYetRegisteredError z _sendContent, když vlastní registrace na relay ještě nedoběhla (čerstvá instalace). 'Šepot' je značka, nepřekládat."
|
||||
},
|
||||
"errorContactLinkIncomplete": "Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.",
|
||||
"@errorContactLinkIncomplete": {
|
||||
"description": "ContactLinkIncompleteError při přidání kontaktu z odkazu/QR bez routingu (aid+da). 'sepot://' je schéma odkazu, zachovat."
|
||||
},
|
||||
"errorAttachmentTooLarge": "Příloha je příliš velká (max {maxMb} MB)",
|
||||
"@errorAttachmentTooLarge": {
|
||||
"description": "StateError z _prepareAttachment při překročení limitu velikosti přílohy; zobrazuje UI výběru/odeslání přílohy. POZOR: v kódu jde o dva adjacentní literály 'Příloha je příliš velká ' (ř. 448) + '(max ${…} MB)' (ř. 449) — sloučit do jednoho klíče.",
|
||||
@@ -1287,6 +1295,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pushHintOff": "Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.",
|
||||
"@pushHintOff": {
|
||||
"description": "Proužek nad seznamem chatů, když push není vůbec zapnutý."
|
||||
},
|
||||
"pushHintFailed": "Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.",
|
||||
"@pushHintFailed": {
|
||||
"description": "Proužek nad seznamem chatů, když distributor registraci odmítl."
|
||||
},
|
||||
"pushRegistrationFailed": "Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.",
|
||||
"@pushRegistrationFailed": {
|
||||
"description": "Podtitulek dlaždice Push v Nastavení, když distributor odmítl registraci. Bez téhle hlášky uživatel vidí push jako zapnutý, ale zprávy mu chodí až po otevření appky."
|
||||
},
|
||||
"pushNoDistributorTitle": "Žádný distributor",
|
||||
"@pushNoDistributorTitle": {
|
||||
"description": "Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor"
|
||||
@@ -1419,6 +1439,14 @@
|
||||
"@qrShareTitle": {
|
||||
"description": "AppBar titulek obrazovky sdílení vlastního QR/Šepot ID"
|
||||
},
|
||||
"qrShareConnectingTitle": "Připojuji k relayi…",
|
||||
"@qrShareConnectingTitle": {
|
||||
"description": "Titulek na QR obrazovce, když vlastní registrace na relay ještě nedoběhla — routing (aid+da) není hotový, QR by byl neúplný."
|
||||
},
|
||||
"qrShareConnectingBody": "Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.",
|
||||
"@qrShareConnectingBody": {
|
||||
"description": "Vysvětlení pod titulkem qrShareConnectingTitle. 'Šepot' je značka, nepřekládat."
|
||||
},
|
||||
"qrIdentityNotFound": "Identita nebyla nalezena",
|
||||
"@qrIdentityNotFound": {
|
||||
"description": "Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)"
|
||||
@@ -1698,5 +1726,41 @@
|
||||
"commonE2eBadgeFull": "End-to-end šifrováno",
|
||||
"@commonE2eBadgeFull": {
|
||||
"description": "Plná varianta badge šifrování (ikona zámku + text), zobrazuje se např. v hlavičce chatu / onboardingu."
|
||||
},
|
||||
"photoPreviewCaptionHint": "Přidat popisek…",
|
||||
"@photoPreviewCaptionHint": {
|
||||
"description": "hintText pole popisku na obrazovce náhledu fotky před odesláním"
|
||||
},
|
||||
"photoPreviewDraw": "Kreslit",
|
||||
"@photoPreviewDraw": {
|
||||
"description": "Tooltip nástroje kreslení prstem na náhledu fotky"
|
||||
},
|
||||
"photoPreviewAddText": "Přidat text",
|
||||
"@photoPreviewAddText": {
|
||||
"description": "Tooltip nástroje přidání textu na fotku"
|
||||
},
|
||||
"photoPreviewRotate": "Otočit",
|
||||
"@photoPreviewRotate": {
|
||||
"description": "Tooltip otočení fotky o 90°"
|
||||
},
|
||||
"photoPreviewUndo": "Zpět",
|
||||
"@photoPreviewUndo": {
|
||||
"description": "Tooltip vrácení posledního tahu kreslení"
|
||||
},
|
||||
"photoPreviewSend": "Poslat",
|
||||
"@photoPreviewSend": {
|
||||
"description": "Tooltip tlačítka odeslání fotky z náhledu"
|
||||
},
|
||||
"photoPreviewTextDialogTitle": "Text na fotce",
|
||||
"@photoPreviewTextDialogTitle": {
|
||||
"description": "Titulek dialogu pro zadání/úpravu textu na fotce"
|
||||
},
|
||||
"photoPreviewRemoveText": "Odebrat",
|
||||
"@photoPreviewRemoveText": {
|
||||
"description": "Tlačítko odebrání textu z fotky v dialogu úpravy"
|
||||
},
|
||||
"photoPreviewDecodeFailed": "Fotku se nepodařilo načíst",
|
||||
"@photoPreviewDecodeFailed": {
|
||||
"description": "SnackBar, když se vybraná fotka nedá dekódovat — náhled se zavře bez odeslání"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -75,6 +75,8 @@
|
||||
"chatEdited": "bearbeitet",
|
||||
"errorTrustRootChanged": "Der Trust Root des Relays hat sich geändert! Kommunikation wird verweigert.",
|
||||
"errorRelayUnavailable": "Relay nicht erreichbar (Sender-Zertifikat)",
|
||||
"errorNotYetRegistered": "Šepot verbindet sich noch mit dem Relay — versuch es gleich noch einmal.",
|
||||
"errorContactLinkIncomplete": "Dieser Link ist unvollständig — die andere Person hat ihn erstellt, bevor sie verbunden war. Bitte sie um einen neuen sepot://-Link.",
|
||||
"errorAttachmentTooLarge": "Anhang ist zu groß (max. {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "Die Gruppe braucht mindestens 1 Mitglied",
|
||||
"errorGroupMaxMembers": "Die Gruppe kann höchstens {maxMembers} Mitglieder haben",
|
||||
@@ -269,6 +271,9 @@
|
||||
"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}",
|
||||
"pushHintOff": "Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.",
|
||||
"pushHintFailed": "Weckrufe funktionieren nicht; Nachrichten siehst du erst beim Öffnen von Šepot.",
|
||||
"pushRegistrationFailed": "Es kommen keine Weckrufe an — der Distributor hat die Registrierung abgelehnt. Schalte Push aus und wieder ein.",
|
||||
"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",
|
||||
@@ -294,6 +299,8 @@
|
||||
"chatListArchive": "Archivieren",
|
||||
"qrShareTitle": "Meine Šepot-ID",
|
||||
"qrIdentityNotFound": "Identität nicht gefunden",
|
||||
"qrShareConnectingTitle": "Verbinde mit dem Relay…",
|
||||
"qrShareConnectingBody": "Dein QR-Code und Link erscheinen, sobald sich Šepot zum ersten Mal verbindet. Einen Moment — es dauert nur ein paar Sekunden.",
|
||||
"commonCopiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"qrShareInviteMessage": "Füg mich auf Šepot hinzu!\n\n{shareString}",
|
||||
"commonShare": "Teilen",
|
||||
@@ -348,5 +355,14 @@
|
||||
"contactsErrorAlreadyExists": "Kontakt existiert bereits",
|
||||
"commonAppTagline": "Privater Messenger",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Ende-zu-Ende-verschlüsselt"
|
||||
"commonE2eBadgeFull": "Ende-zu-Ende-verschlüsselt",
|
||||
"photoPreviewCaptionHint": "Bildunterschrift hinzufügen…",
|
||||
"photoPreviewDraw": "Zeichnen",
|
||||
"photoPreviewAddText": "Text hinzufügen",
|
||||
"photoPreviewRotate": "Drehen",
|
||||
"photoPreviewUndo": "Rückgängig",
|
||||
"photoPreviewSend": "Senden",
|
||||
"photoPreviewTextDialogTitle": "Text auf dem Foto",
|
||||
"photoPreviewRemoveText": "Entfernen",
|
||||
"photoPreviewDecodeFailed": "Foto konnte nicht geladen werden"
|
||||
}
|
||||
|
||||
+17
-1
@@ -75,6 +75,8 @@
|
||||
"chatEdited": "edited",
|
||||
"errorTrustRootChanged": "The relay's trust root has changed! Refusing to communicate.",
|
||||
"errorRelayUnavailable": "Relay unavailable (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot is still connecting to the relay — try sending again in a moment.",
|
||||
"errorContactLinkIncomplete": "This link is incomplete — the other person created it before connecting. Ask them for a new sepot:// link.",
|
||||
"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",
|
||||
@@ -269,6 +271,9 @@
|
||||
"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}",
|
||||
"pushHintOff": "Messages arrive only when you open Šepot — wake-ups aren’t set up.",
|
||||
"pushHintFailed": "Wake-ups aren’t working; you’ll see messages only after opening Šepot.",
|
||||
"pushRegistrationFailed": "Wake-ups aren’t arriving — the distributor refused registration. Try turning push off and on again.",
|
||||
"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",
|
||||
@@ -294,6 +299,8 @@
|
||||
"chatListArchive": "Archive",
|
||||
"qrShareTitle": "My Šepot ID",
|
||||
"qrIdentityNotFound": "Identity not found",
|
||||
"qrShareConnectingTitle": "Connecting to the relay…",
|
||||
"qrShareConnectingBody": "Your QR code and link will appear once Šepot connects for the first time. Just a moment — it only takes a few seconds.",
|
||||
"commonCopiedToClipboard": "Copied to clipboard",
|
||||
"qrShareInviteMessage": "Add me on Šepot!\n\n{shareString}",
|
||||
"commonShare": "Share",
|
||||
@@ -348,5 +355,14 @@
|
||||
"contactsErrorAlreadyExists": "Contact already exists",
|
||||
"commonAppTagline": "Private messenger",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "End-to-end encrypted"
|
||||
"commonE2eBadgeFull": "End-to-end encrypted",
|
||||
"photoPreviewCaptionHint": "Add a caption…",
|
||||
"photoPreviewDraw": "Draw",
|
||||
"photoPreviewAddText": "Add text",
|
||||
"photoPreviewRotate": "Rotate",
|
||||
"photoPreviewUndo": "Undo",
|
||||
"photoPreviewSend": "Send",
|
||||
"photoPreviewTextDialogTitle": "Text on photo",
|
||||
"photoPreviewRemoveText": "Remove",
|
||||
"photoPreviewDecodeFailed": "Couldn't load the photo"
|
||||
}
|
||||
|
||||
+17
-1
@@ -75,6 +75,8 @@
|
||||
"chatEdited": "modificato",
|
||||
"errorTrustRootChanged": "Il trust root del relay è cambiato! Comunicazione rifiutata.",
|
||||
"errorRelayUnavailable": "Relay non disponibile (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot si sta ancora connettendo al relay — riprova a inviare tra un istante.",
|
||||
"errorContactLinkIncomplete": "Questo link è incompleto — l'altra persona l'ha creato prima di connettersi. Chiedile un nuovo link sepot://.",
|
||||
"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",
|
||||
@@ -269,6 +271,9 @@
|
||||
"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}",
|
||||
"pushHintOff": "I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.",
|
||||
"pushHintFailed": "Le notifiche non funzionano: vedrai i messaggi solo aprendo Šepot.",
|
||||
"pushRegistrationFailed": "Le notifiche non arrivano — il distributore ha rifiutato la registrazione. Prova a disattivare e riattivare il push.",
|
||||
"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",
|
||||
@@ -294,6 +299,8 @@
|
||||
"chatListArchive": "Archivia",
|
||||
"qrShareTitle": "Il mio Šepot ID",
|
||||
"qrIdentityNotFound": "Identità non trovata",
|
||||
"qrShareConnectingTitle": "Connessione al relay…",
|
||||
"qrShareConnectingBody": "Il tuo codice QR e il link appariranno appena Šepot si connette per la prima volta. Un istante — bastano pochi secondi.",
|
||||
"commonCopiedToClipboard": "Copiato negli appunti",
|
||||
"qrShareInviteMessage": "Aggiungimi su Šepot!\n\n{shareString}",
|
||||
"commonShare": "Condividi",
|
||||
@@ -348,5 +355,14 @@
|
||||
"contactsErrorAlreadyExists": "Il contatto esiste già",
|
||||
"commonAppTagline": "Messenger privato",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Cifrato end-to-end"
|
||||
"commonE2eBadgeFull": "Cifrato end-to-end",
|
||||
"photoPreviewCaptionHint": "Aggiungi una didascalia…",
|
||||
"photoPreviewDraw": "Disegna",
|
||||
"photoPreviewAddText": "Aggiungi testo",
|
||||
"photoPreviewRotate": "Ruota",
|
||||
"photoPreviewUndo": "Annulla",
|
||||
"photoPreviewSend": "Invia",
|
||||
"photoPreviewTextDialogTitle": "Testo sulla foto",
|
||||
"photoPreviewRemoveText": "Rimuovi",
|
||||
"photoPreviewDecodeFailed": "Impossibile caricare la foto"
|
||||
}
|
||||
|
||||
+17
-1
@@ -75,6 +75,8 @@
|
||||
"chatEdited": "изменено",
|
||||
"errorTrustRootChanged": "Trust root релея изменился! Соединение отклонено.",
|
||||
"errorRelayUnavailable": "Релей недоступен (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.",
|
||||
"errorContactLinkIncomplete": "Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.",
|
||||
"errorAttachmentTooLarge": "Вложение слишком большое (макс. {maxMb} МБ)",
|
||||
"errorGroupNeedsMember": "В группе должен быть хотя бы 1 участник",
|
||||
"errorGroupMaxMembers": "Максимальное число участников группы — {maxMembers}",
|
||||
@@ -269,6 +271,9 @@
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Надёжно и без дополнительных приложений. Push-токен устройства передаётся Google; содержимое сообщений — никогда.",
|
||||
"pushSetupFailed": "Не удалось настроить push: {error}",
|
||||
"pushHintOff": "Сообщения придут, только когда откроешь Šepot — сигналы не настроены.",
|
||||
"pushHintFailed": "Сигналы не работают: сообщения увидишь только после открытия Šepot.",
|
||||
"pushRegistrationFailed": "Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.",
|
||||
"pushNoDistributorTitle": "Нет дистрибьютора",
|
||||
"pushNoDistributorBody": "Для уведомлений без сервисов Google установите дистрибьютор UnifiedPush, например ntfy (F-Droid или Play). Затем включите push снова.",
|
||||
"commonOk": "OK",
|
||||
@@ -294,6 +299,8 @@
|
||||
"chatListArchive": "Архивировать",
|
||||
"qrShareTitle": "Мой Šepot ID",
|
||||
"qrIdentityNotFound": "Идентичность не найдена",
|
||||
"qrShareConnectingTitle": "Подключение к релею…",
|
||||
"qrShareConnectingBody": "Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.",
|
||||
"commonCopiedToClipboard": "Скопировано в буфер обмена",
|
||||
"qrShareInviteMessage": "Добавьте меня в Šepot!\n\n{shareString}",
|
||||
"commonShare": "Поделиться",
|
||||
@@ -348,5 +355,14 @@
|
||||
"contactsErrorAlreadyExists": "Контакт уже существует",
|
||||
"commonAppTagline": "Приватный мессенджер",
|
||||
"commonE2eBadgeCompact": "E2E",
|
||||
"commonE2eBadgeFull": "Сквозное шифрование"
|
||||
"commonE2eBadgeFull": "Сквозное шифрование",
|
||||
"photoPreviewCaptionHint": "Добавить подпись…",
|
||||
"photoPreviewDraw": "Рисовать",
|
||||
"photoPreviewAddText": "Добавить текст",
|
||||
"photoPreviewRotate": "Повернуть",
|
||||
"photoPreviewUndo": "Отменить",
|
||||
"photoPreviewSend": "Отправить",
|
||||
"photoPreviewTextDialogTitle": "Текст на фото",
|
||||
"photoPreviewRemoveText": "Удалить",
|
||||
"photoPreviewDecodeFailed": "Не удалось загрузить фото"
|
||||
}
|
||||
|
||||
@@ -554,6 +554,18 @@ abstract class AppLocalizations {
|
||||
/// **'Relay nedostupný (sender certificate)'**
|
||||
String get errorRelayUnavailable;
|
||||
|
||||
/// NotYetRegisteredError z _sendContent, když vlastní registrace na relay ještě nedoběhla (čerstvá instalace). 'Šepot' je značka, nepřekládat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.'**
|
||||
String get errorNotYetRegistered;
|
||||
|
||||
/// ContactLinkIncompleteError při přidání kontaktu z odkazu/QR bez routingu (aid+da). 'sepot://' je schéma odkazu, zachovat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.'**
|
||||
String get errorContactLinkIncomplete;
|
||||
|
||||
/// StateError z _prepareAttachment při překročení limitu velikosti přílohy; zobrazuje UI výběru/odeslání přílohy. POZOR: v kódu jde o dva adjacentní literály 'Příloha je příliš velká ' (ř. 448) + '(max ${…} MB)' (ř. 449) — sloučit do jednoho klíče.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -1718,6 +1730,24 @@ abstract class AppLocalizations {
|
||||
/// **'Nastavení pushů selhalo: {error}'**
|
||||
String pushSetupFailed(String error);
|
||||
|
||||
/// Proužek nad seznamem chatů, když push není vůbec zapnutý.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.'**
|
||||
String get pushHintOff;
|
||||
|
||||
/// Proužek nad seznamem chatů, když distributor registraci odmítl.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.'**
|
||||
String get pushHintFailed;
|
||||
|
||||
/// Podtitulek dlaždice Push v Nastavení, když distributor odmítl registraci. Bez téhle hlášky uživatel vidí push jako zapnutý, ale zprávy mu chodí až po otevření appky.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.'**
|
||||
String get pushRegistrationFailed;
|
||||
|
||||
/// Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -1862,6 +1892,18 @@ abstract class AppLocalizations {
|
||||
/// **'Můj Šepot ID'**
|
||||
String get qrShareTitle;
|
||||
|
||||
/// Titulek na QR obrazovce, když vlastní registrace na relay ještě nedoběhla — routing (aid+da) není hotový, QR by byl neúplný.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Připojuji k relayi…'**
|
||||
String get qrShareConnectingTitle;
|
||||
|
||||
/// Vysvětlení pod titulkem qrShareConnectingTitle. 'Šepot' je značka, nepřekládat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.'**
|
||||
String get qrShareConnectingBody;
|
||||
|
||||
/// Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -2197,6 +2239,60 @@ abstract class AppLocalizations {
|
||||
/// In cs, this message translates to:
|
||||
/// **'End-to-end šifrováno'**
|
||||
String get commonE2eBadgeFull;
|
||||
|
||||
/// hintText pole popisku na obrazovce náhledu fotky před odesláním
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidat popisek…'**
|
||||
String get photoPreviewCaptionHint;
|
||||
|
||||
/// Tooltip nástroje kreslení prstem na náhledu fotky
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Kreslit'**
|
||||
String get photoPreviewDraw;
|
||||
|
||||
/// Tooltip nástroje přidání textu na fotku
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Přidat text'**
|
||||
String get photoPreviewAddText;
|
||||
|
||||
/// Tooltip otočení fotky o 90°
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Otočit'**
|
||||
String get photoPreviewRotate;
|
||||
|
||||
/// Tooltip vrácení posledního tahu kreslení
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zpět'**
|
||||
String get photoPreviewUndo;
|
||||
|
||||
/// Tooltip tlačítka odeslání fotky z náhledu
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Poslat'**
|
||||
String get photoPreviewSend;
|
||||
|
||||
/// Titulek dialogu pro zadání/úpravu textu na fotce
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Text na fotce'**
|
||||
String get photoPreviewTextDialogTitle;
|
||||
|
||||
/// Tlačítko odebrání textu z fotky v dialogu úpravy
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odebrat'**
|
||||
String get photoPreviewRemoveText;
|
||||
|
||||
/// SnackBar, když se vybraná fotka nedá dekódovat — náhled se zavře bez odeslání
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Fotku se nepodařilo načíst'**
|
||||
String get photoPreviewDecodeFailed;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -276,6 +276,14 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Relay nedostupný (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Příloha je příliš velká (max $maxMb MB)';
|
||||
@@ -953,6 +961,18 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
return 'Nastavení pushů selhalo: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Žádný distributor';
|
||||
|
||||
@@ -1048,6 +1068,13 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Můj Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Připojuji k relayi…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identita nebyla nalezena';
|
||||
|
||||
@@ -1248,4 +1275,31 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'End-to-end šifrováno';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Přidat popisek…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Kreslit';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Přidat text';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Otočit';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Zpět';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Poslat';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Text na fotce';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Odebrat';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Fotku se nepodařilo načíst';
|
||||
}
|
||||
|
||||
@@ -277,6 +277,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get errorRelayUnavailable =>
|
||||
'Relay nicht erreichbar (Sender-Zertifikat)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot verbindet sich noch mit dem Relay — versuch es gleich noch einmal.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Dieser Link ist unvollständig — die andere Person hat ihn erstellt, bevor sie verbunden war. Bitte sie um einen neuen sepot://-Link.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Anhang ist zu groß (max. $maxMb MB)';
|
||||
@@ -972,6 +980,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Einrichtung der Push-Benachrichtigungen fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Weckrufe funktionieren nicht; Nachrichten siehst du erst beim Öffnen von Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Es kommen keine Weckrufe an — der Distributor hat die Registrierung abgelehnt. Schalte Push aus und wieder ein.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Kein Distributor';
|
||||
|
||||
@@ -1067,6 +1087,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Meine Šepot-ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Verbinde mit dem Relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Dein QR-Code und Link erscheinen, sobald sich Šepot zum ersten Mal verbindet. Einen Moment — es dauert nur ein paar Sekunden.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identität nicht gefunden';
|
||||
|
||||
@@ -1270,4 +1297,31 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'Ende-zu-Ende-verschlüsselt';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Bildunterschrift hinzufügen…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Zeichnen';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Text hinzufügen';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Drehen';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Rückgängig';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Senden';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Text auf dem Foto';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Entfernen';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Foto konnte nicht geladen werden';
|
||||
}
|
||||
|
||||
@@ -274,6 +274,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Relay unavailable (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot is still connecting to the relay — try sending again in a moment.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'This link is incomplete — the other person created it before connecting. Ask them for a new sepot:// link.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Attachment is too large (max $maxMb MB)';
|
||||
@@ -951,6 +959,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Setting up push failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Messages arrive only when you open Šepot — wake-ups aren’t set up.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Wake-ups aren’t working; you’ll see messages only after opening Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Wake-ups aren’t arriving — the distributor refused registration. Try turning push off and on again.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'No distributor';
|
||||
|
||||
@@ -1044,6 +1064,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'My Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Connecting to the relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Your QR code and link will appear once Šepot connects for the first time. Just a moment — it only takes a few seconds.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identity not found';
|
||||
|
||||
@@ -1243,4 +1270,31 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'End-to-end encrypted';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Add a caption…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Draw';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Add text';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Rotate';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Undo';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Send';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Text on photo';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Remove';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Couldn\'t load the photo';
|
||||
}
|
||||
|
||||
@@ -276,6 +276,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String get errorRelayUnavailable =>
|
||||
'Relay non disponibile (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot si sta ancora connettendo al relay — riprova a inviare tra un istante.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Questo link è incompleto — l\'altra persona l\'ha creato prima di connettersi. Chiedile un nuovo link sepot://.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'L\'allegato è troppo grande (max $maxMb MB)';
|
||||
@@ -961,6 +969,18 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return 'Configurazione delle notifiche push non riuscita: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Le notifiche non funzionano: vedrai i messaggi solo aprendo Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Le notifiche non arrivano — il distributore ha rifiutato la registrazione. Prova a disattivare e riattivare il push.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Nessun distributore';
|
||||
|
||||
@@ -1055,6 +1075,13 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Il mio Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Connessione al relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Il tuo codice QR e il link appariranno appena Šepot si connette per la prima volta. Un istante — bastano pochi secondi.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identità non trovata';
|
||||
|
||||
@@ -1257,4 +1284,31 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'Cifrato end-to-end';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Aggiungi una didascalia…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Disegna';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Aggiungi testo';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Ruota';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Annulla';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Invia';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Testo sulla foto';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Rimuovi';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Impossibile caricare la foto';
|
||||
}
|
||||
|
||||
@@ -276,6 +276,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Релей недоступен (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Вложение слишком большое (макс. $maxMb МБ)';
|
||||
@@ -968,6 +976,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Не удалось настроить push: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Сообщения придут, только когда откроешь Šepot — сигналы не настроены.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Сигналы не работают: сообщения увидишь только после открытия Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Нет дистрибьютора';
|
||||
|
||||
@@ -1072,6 +1092,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Мой Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Подключение к релею…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Идентичность не найдена';
|
||||
|
||||
@@ -1282,4 +1309,31 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get commonE2eBadgeFull => 'Сквозное шифрование';
|
||||
|
||||
@override
|
||||
String get photoPreviewCaptionHint => 'Добавить подпись…';
|
||||
|
||||
@override
|
||||
String get photoPreviewDraw => 'Рисовать';
|
||||
|
||||
@override
|
||||
String get photoPreviewAddText => 'Добавить текст';
|
||||
|
||||
@override
|
||||
String get photoPreviewRotate => 'Повернуть';
|
||||
|
||||
@override
|
||||
String get photoPreviewUndo => 'Отменить';
|
||||
|
||||
@override
|
||||
String get photoPreviewSend => 'Отправить';
|
||||
|
||||
@override
|
||||
String get photoPreviewTextDialogTitle => 'Текст на фото';
|
||||
|
||||
@override
|
||||
String get photoPreviewRemoveText => 'Удалить';
|
||||
|
||||
@override
|
||||
String get photoPreviewDecodeFailed => 'Не удалось загрузить фото';
|
||||
}
|
||||
|
||||
@@ -97,6 +97,21 @@ class AttachmentMeta {
|
||||
if (size <= 0 || size > 512 * 1024 * 1024) {
|
||||
throw const FormatException('implausible attachment size');
|
||||
}
|
||||
// Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu);
|
||||
// vyhodí FormatException → zpráva se zahodí jako vadná.
|
||||
base64Decode(wire['k'] as String);
|
||||
base64Decode(wire['n'] as String);
|
||||
base64Decode(wire['d'] as String);
|
||||
// Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka
|
||||
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
|
||||
var thumb = wire['th'] as String?;
|
||||
if (thumb != null) {
|
||||
try {
|
||||
base64Decode(thumb);
|
||||
} on FormatException {
|
||||
thumb = null;
|
||||
}
|
||||
}
|
||||
return AttachmentMeta(
|
||||
blobId: wire['bid'] as String,
|
||||
keyB64: wire['k'] as String,
|
||||
@@ -107,7 +122,7 @@ class AttachmentMeta {
|
||||
name: wire['name'] as String?,
|
||||
width: wire['w'] as int?,
|
||||
height: wire['h'] as int?,
|
||||
thumbB64: wire['th'] as String?,
|
||||
thumbB64: thumb,
|
||||
durationMs: wire['dur'] as int?,
|
||||
state: AttachmentTransferState.pending,
|
||||
);
|
||||
|
||||
@@ -39,10 +39,16 @@ class Conversation {
|
||||
.length;
|
||||
}
|
||||
|
||||
/// Přidej zprávu
|
||||
void addMessage(Message message) {
|
||||
/// Přidej zprávu. Vrací false, když už zpráva s tímhle id v konverzaci je.
|
||||
///
|
||||
/// Id zprávy nese odesílatel (pole `id` na drátě), takže opakované doručení
|
||||
/// téhož obsahu — retry odesílatele po výpadku spojení nebo redelivery
|
||||
/// z fronty relaye — nesmí založit druhou bublinu.
|
||||
bool addMessage(Message message) {
|
||||
if (messages.any((m) => m.id == message.id)) return false;
|
||||
messages.add(message);
|
||||
lastActivity = DateTime.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
|
||||
|
||||
@@ -17,6 +17,15 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final contacts = await future;
|
||||
|
||||
// Odkaz bez routingu (aid+da) = protistrana ho vytvořila dřív, než měla
|
||||
// hotovou registraci. Přidat takový kontakt = časovaná bomba: první
|
||||
// odeslání spadne na „nemá doručovací adresu". Odmítnout hned s
|
||||
// jasnou hláškou. (Nullable routing má smysl jen na interních příjmových
|
||||
// cestách — roster/_findOrCreateContact —, ne při ručním importu.)
|
||||
if (identity.accountId == null || identity.deliveryAddress == null) {
|
||||
throw const ContactLinkIncompleteError();
|
||||
}
|
||||
|
||||
// Kontrola duplicity
|
||||
if (contacts.any((c) => c.publicIdentity.id == identity.id)) {
|
||||
throw const ContactAlreadyExistsError();
|
||||
|
||||
@@ -129,6 +129,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
required Uint8List bytes,
|
||||
required String fileName,
|
||||
String? mime,
|
||||
String? caption,
|
||||
}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
@@ -140,6 +141,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
mime: mime,
|
||||
caption: caption,
|
||||
);
|
||||
} else {
|
||||
final contact = await _contact(contactId);
|
||||
@@ -148,6 +150,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
mime: mime,
|
||||
caption: caption,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
||||
final identity = await ref.watch(identityProvider.future);
|
||||
if (identity == null) return;
|
||||
|
||||
// Po auto-locku se tenhle provider eager-rebuildne (drží ho pushInitProvider)
|
||||
// ještě NAD zamčeným storage → service.start() by spadl na „Storage is
|
||||
// locked" a provider by uvázl v AsyncError (žádný příjem/odeslání až do
|
||||
// killu procesu). Nad zamčeným storage tiše nic nestartuj; po odemčení
|
||||
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
|
||||
final storage = ref.read(storageProvider);
|
||||
if (!storage.isUnlocked) return;
|
||||
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
if (service.isStarted) return;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/push/push_service.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Push vrstva nad messagingem.
|
||||
final pushServiceProvider = Provider<PushService>((ref) {
|
||||
@@ -18,3 +19,31 @@ final pushInitProvider = FutureProvider<void>((ref) async {
|
||||
// re-registruje u relaye po každém startu.
|
||||
await push.restoreMode();
|
||||
});
|
||||
|
||||
/// Stav budíčků pro chat list. Bez funkčního pushe appka na pozadí spí a
|
||||
/// zprávy dorazí, až ji uživatel otevře — o tom má vědět dřív, než mu
|
||||
/// někdo vyčte, že neodpovídá.
|
||||
enum PushHealth {
|
||||
/// Budíčky nastavené a bez chyby.
|
||||
ok,
|
||||
|
||||
/// Push vůbec nezapnutý (výchozí stav po instalaci).
|
||||
off,
|
||||
|
||||
/// Distributor registraci odmítl — zapnuto, ale nefunguje.
|
||||
failed,
|
||||
}
|
||||
|
||||
final pushHealthProvider = FutureProvider<PushHealth>((ref) async {
|
||||
await ref.watch(pushInitProvider.future);
|
||||
final push = ref.read(pushServiceProvider);
|
||||
if (await push.registrationFailed()) return PushHealth.failed;
|
||||
return await push.currentMode() == 'off' ? PushHealth.off : PushHealth.ok;
|
||||
});
|
||||
|
||||
/// Skryl si uživatel upozornění na chat listu? Rozhodnutí se respektuje —
|
||||
/// připomínat donekonečna by bylo otravné.
|
||||
final pushHintDismissedProvider = FutureProvider<bool>((ref) async {
|
||||
final storage = ref.watch(storageProvider);
|
||||
return await storage.getSetting<bool>('push_hint_dismissed') ?? false;
|
||||
});
|
||||
|
||||
@@ -63,11 +63,17 @@ class SepotAvatar extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _getInitials(String name) {
|
||||
final parts = name.trim().split(' ');
|
||||
// Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render.
|
||||
// Grafémy (characters), ne code units — jméno začínající emoji by se
|
||||
// jinak rozpůlilo na rozbitý surrogate.
|
||||
final parts =
|
||||
name.trim().split(' ').where((p) => p.isNotEmpty).toList();
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
|
||||
return '${parts[0].characters.first}${parts[1].characters.first}'
|
||||
.toUpperCase();
|
||||
}
|
||||
return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase();
|
||||
if (parts.isEmpty) return '?';
|
||||
return parts[0].characters.take(2).toString().toUpperCase();
|
||||
}
|
||||
|
||||
Color _colorFromName(String name) {
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
@@ -27,13 +25,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
record_linux
|
||||
screen_retriever_linux
|
||||
url_launcher_linux
|
||||
window_manager
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
+6
-102
@@ -193,14 +193,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.3"
|
||||
change_case:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: change_case
|
||||
sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -924,14 +916,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
native_toolchain_cmake:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_cmake
|
||||
sha256: "9960e1e926ec210a3ba6fe61720d48e1dd6cbab00d05890a6e147ee45b7e47c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.6"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1196,46 +1180,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever
|
||||
sha256: "42cc3b402a0f67d2455a0d067553d0f13453f6a008d98eababf8b63958d506bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_linux
|
||||
sha256: "2a476f1a5538065bc5badf376cfdc83d6ecf07d77eb2391b9c2bff5a76970048"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_macos
|
||||
sha256: b5abb900fcb86614ff10b738b34e37b9e1d03b0447280668e2bc8a98bdc7bd59
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_platform_interface
|
||||
sha256: "3af22d926bedf20c2caa308eea376776451a3af125919ce072e56525fded8901"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_windows
|
||||
sha256: c44b38a4c4bab34af259180a70a4eee1e29384e7b82e627c9faa68afcdab2e73
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1413,42 +1357,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: unifiedpush
|
||||
sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e"
|
||||
sha256: b52a0eff848d0446b9d521e0ad2f7bc76c4dda88d0f6ac0a79f1765ccfe37939
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
version: "6.1.0"
|
||||
unifiedpush_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_android
|
||||
sha256: "2e6684e0a1a39ad8d6c7bc0d197954d50686acb1b1a7614b18ab0e0126f5492a"
|
||||
sha256: "2af6a09a5fb9fdd97b61e704a587e88abcdb5f7ee2aea9a2c35e80159f9d586d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
unifiedpush_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_linux
|
||||
sha256: c062d5eedd1cec70bcd33270cc4e01ae0ff6501f33d471167c06b34a968adfeb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "3.3.1"
|
||||
unifiedpush_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_platform_interface
|
||||
sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880"
|
||||
sha256: "5aa3eefc2699e4d8db6d723f0cfdfb56ac850487549a0b3b83574f4a89f33aab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
unifiedpush_storage_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_storage_interface
|
||||
sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "3.1.0"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1585,14 +1513,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webcrypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webcrypto
|
||||
sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.1"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1601,14 +1521,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
webpush_encryption:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webpush_encryption
|
||||
sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1617,14 +1529,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
window_manager:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: window_manager
|
||||
sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
name: sepot
|
||||
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
||||
publish_to: 'none'
|
||||
version: 0.3.2+18
|
||||
version: 1.1.2+22
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.8
|
||||
@@ -37,7 +37,10 @@ dependencies:
|
||||
pointycastle: ^3.9.1
|
||||
protobuf: ^6.0.0
|
||||
sqlite3: ^3.3.4
|
||||
unifiedpush: ^6.2.0
|
||||
# Pin pod 6.2.0: od 6.2.0 táhne unifiedpush_linux → webpush_encryption →
|
||||
# webcrypto, jehož native framework na iOS chybí LC_ENCRYPTION_INFO (blokuje
|
||||
# upload). Linux desktop neshipujeme, na Android/iOS je 6.1.x funkčně shodná.
|
||||
unifiedpush: ">=6.1.0 <6.2.0"
|
||||
image: ^4.8.0
|
||||
mime: ^2.0.0
|
||||
image_picker: ^1.2.3
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/models/attachment.dart';
|
||||
|
||||
/// Validace metadat přílohy z drátu — remote input od protistrany.
|
||||
/// Vadný náhled nesmí shodit render bubliny, vadná krypto pole musí
|
||||
/// zprávu zabít už při příjmu (ACK + zahodit), ne až při použití.
|
||||
void main() {
|
||||
Map<String, dynamic> validWire() => {
|
||||
'bid': 'a1b2c3d4e5f60718293a4b5c6d7e8f90',
|
||||
'k': base64Encode(List.filled(32, 1)),
|
||||
'n': base64Encode(List.filled(12, 2)),
|
||||
'd': base64Encode(List.filled(32, 3)),
|
||||
's': 1024,
|
||||
'mime': 'image/jpeg',
|
||||
};
|
||||
|
||||
test('validní wire projde a thumbnail dekóduje', () {
|
||||
final meta = AttachmentMeta.fromWire(
|
||||
{...validWire(), 'th': base64Encode([1, 2, 3])});
|
||||
expect(meta.thumbnail, [1, 2, 3]);
|
||||
expect(meta.key.length, 32);
|
||||
});
|
||||
|
||||
test('vadný base64 náhled se zahodí, zpráva žije', () {
|
||||
final meta = AttachmentMeta.fromWire({...validWire(), 'th': '@@@!!'});
|
||||
expect(meta.thumbB64, isNull);
|
||||
expect(meta.thumbnail, isNull);
|
||||
expect(meta.blobId, validWire()['bid']);
|
||||
});
|
||||
|
||||
test('vadná base64 krypto pole zprávu zabijí FormatException', () {
|
||||
for (final field in ['k', 'n', 'd']) {
|
||||
expect(
|
||||
() => AttachmentMeta.fromWire({...validWire(), field: 'ne base64!'}),
|
||||
throwsFormatException,
|
||||
reason: 'pole $field s nevalidní base64 musí hodit',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('nesmyslná velikost hodí FormatException', () {
|
||||
expect(
|
||||
() => AttachmentMeta.fromWire({...validWire(), 's': 0}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => AttachmentMeta.fromWire({...validWire(), 's': 600 * 1024 * 1024}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('špatný typ pole hodí TypeError (poison cesta ACKuje)', () {
|
||||
expect(
|
||||
() => AttachmentMeta.fromWire({...validWire(), 'k': 42}),
|
||||
throwsA(isA<TypeError>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Id zprávy nese odesílatel, takže tatáž zpráva může dorazit dvakrát:
|
||||
/// odesílatel ji po výpadku spojení pošle znovu (fronta odchozích) nebo ji
|
||||
/// relay doručí opakovaně, než dorazí ACK. Ani v jednom případě nesmí
|
||||
/// vzniknout druhá bublina.
|
||||
void main() {
|
||||
Message zprava(String id, {String text = 'ahoj'}) => Message(
|
||||
id: id,
|
||||
senderId: 'alice',
|
||||
recipientId: 'bob',
|
||||
type: MessageType.text,
|
||||
content: text,
|
||||
timestamp: DateTime.parse('2026-08-13T09:00:00.000'),
|
||||
);
|
||||
|
||||
test('zpráva se stejným id se podruhé nepřidá', () {
|
||||
final c = Conversation(contactId: 'bob');
|
||||
|
||||
expect(c.addMessage(zprava('m1')), isTrue, reason: 'první doručení');
|
||||
expect(c.addMessage(zprava('m1')), isFalse, reason: 'opakované doručení');
|
||||
expect(c.messages.length, 1);
|
||||
});
|
||||
|
||||
test('dedup se řídí id, ne obsahem — stejný text jinou zprávou projde', () {
|
||||
final c = Conversation(contactId: 'bob');
|
||||
|
||||
expect(c.addMessage(zprava('m1', text: 'ahoj')), isTrue);
|
||||
expect(c.addMessage(zprava('m2', text: 'ahoj')), isTrue);
|
||||
expect(c.messages.length, 2);
|
||||
});
|
||||
|
||||
test('duplicita nechá původní zprávu být', () {
|
||||
final c = Conversation(contactId: 'bob');
|
||||
c.addMessage(zprava('m1', text: 'původní'));
|
||||
|
||||
c.addMessage(zprava('m1', text: 'přepsaný'));
|
||||
|
||||
expect(c.messages.single.content, 'původní');
|
||||
});
|
||||
}
|
||||
@@ -12,7 +12,7 @@ void main() {
|
||||
await SepotCrypto.init();
|
||||
});
|
||||
|
||||
group('S1: fingerprint a ID z klíče', () {
|
||||
group('fingerprint a ID z klíče', () {
|
||||
test('fingerprint má 52 znaků Crockford base32, SEP- zkratka 14', () async {
|
||||
final identity = await LocalIdentity.create('Alice');
|
||||
expect(identity.fingerprint.display, hasLength(52));
|
||||
@@ -36,7 +36,7 @@ void main() {
|
||||
);
|
||||
final fp = SepotFingerprint.ofPublicKey(pubkey);
|
||||
expect(fp.display, hasLength(52));
|
||||
// KAT hodnota vygenerovaná při zavedení S1 (2026-07-03) — pokud se
|
||||
// KAT hodnota zafixovaná při zavedení fingerprintů — pokud se
|
||||
// změní, změnil se hash vstup/algoritmus a VŠECHNA ID uživatelů
|
||||
expect(
|
||||
fp.display,
|
||||
@@ -53,7 +53,8 @@ void main() {
|
||||
|
||||
test('serializovaný privátní klíč je clamping-stabilní', () {
|
||||
// deserialize→serialize musí být identita na už serializovaných
|
||||
// bajtech — na tom stojí mnemonic roundtrip (MASTER-DESIGN §3, F-3)
|
||||
// bajtech — mnemonic kóduje serializovaný (clampnutý) privátní klíč,
|
||||
// takže nestabilní serializace by rozbila obnovu účtu ze slov
|
||||
for (var i = 0; i < 20; i++) {
|
||||
final keys = SepotCrypto.generateIdentityKeys();
|
||||
final roundtrip = SepotCrypto.derivePublicKey(keys.privateKey);
|
||||
@@ -67,7 +68,7 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('S2: mnemonic recovery', () {
|
||||
group('mnemonic recovery', () {
|
||||
test('recover z mnemonic obnoví identický klíč i fingerprint', () async {
|
||||
final original = await LocalIdentity.create('Alice');
|
||||
final words = original.mnemonic;
|
||||
@@ -132,5 +133,28 @@ void main() {
|
||||
expect(PublicIdentity.fromShareString('sepot://add/%%%'), isNull);
|
||||
expect(PublicIdentity.fromShareString(''), isNull);
|
||||
});
|
||||
|
||||
test('share link s routingem: aid+da přežijí roundtrip', () async {
|
||||
final base = await LocalIdentity.create('Routovaná');
|
||||
final withRouting = base.publicIdentity.withRouting(
|
||||
accountId: 'deadbeef',
|
||||
deliveryAddress: Uint8List.fromList(List.generate(32, (i) => i)),
|
||||
);
|
||||
final parsed =
|
||||
PublicIdentity.fromShareString(withRouting.toShareString());
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.accountId, 'deadbeef');
|
||||
expect(parsed.deliveryAddress,
|
||||
Uint8List.fromList(List.generate(32, (i) => i)));
|
||||
});
|
||||
|
||||
test('fromShareString: da špatné délky → null (ne vadný kontakt)', () async {
|
||||
final base = await LocalIdentity.create('Krátká');
|
||||
final bad = base.publicIdentity.withRouting(
|
||||
accountId: 'deadbeef',
|
||||
deliveryAddress: Uint8List.fromList(List.generate(8, (i) => i)),
|
||||
);
|
||||
expect(PublicIdentity.fromShareString(bad.toShareString()), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
|
||||
/// Bezpečnostní pravidla skupin (GROUPS-V1.md §Bezpečnostní pravidla).
|
||||
/// Útočné scénáře: podvržený roster, cizí odesílatel, přepis identity
|
||||
/// klíče, pozvánka bez příjemce, roster přes strop.
|
||||
/// Bezpečnostní pravidla skupin — invarianty příjmové cesty: fingerprint
|
||||
/// v rosteru musí odpovídat identity klíči, odesílatel i já musíme být
|
||||
/// členy, roster smí měnit jen stávající člen a identity klíč známého
|
||||
/// kontaktu se nikdy nepřepisuje (TOFU). Útočné scénáře: podvržený roster,
|
||||
/// cizí odesílatel, přepis identity klíče, pozvánka bez příjemce, roster
|
||||
/// přes strop.
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
late CryptoDb db;
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Skupiny v1 (GROUPS-V1.md) — perzistence a model. Bezpečnostní pravidla
|
||||
/// Skupiny v1 — perzistence a model. Bezpečnostní pravidla
|
||||
/// příjmové cesty (roster anti-spoof, membership guard) mají vlastní test
|
||||
/// v `test/groups_security_test.dart`, protože nepotřebují DB.
|
||||
void main() {
|
||||
@@ -115,7 +115,7 @@ void main() {
|
||||
expect(g.isMember('MALLORY'), isFalse);
|
||||
});
|
||||
|
||||
test('strop členů je 128 (MASTER-DESIGN otázka 9)', () {
|
||||
test('strop členů je 128 (párový fanout = N obálek na zprávu)', () {
|
||||
expect(Group.maxMembers, 128);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sepot/features/conversation/photo_preview_screen.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
|
||||
/// Náhled fotky před odesláním: nic neodchází bez potvrzení, úpravy
|
||||
/// (rotace) se propíšou do odeslaných bajtů, popisek do výsledku.
|
||||
///
|
||||
/// Dekódování obrázku a render úprav jsou reálné async operace engine,
|
||||
/// proto testy běží v `tester.runAsync`.
|
||||
Uint8List _testPng({int width = 8, int height = 4}) =>
|
||||
Uint8List.fromList(img.encodePng(img.Image(width: width, height: height)));
|
||||
|
||||
void main() {
|
||||
Future<Future<PhotoPreviewResult?>> openPreview(
|
||||
WidgetTester tester,
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
late Future<PhotoPreviewResult?> pushed;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('cs'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () {
|
||||
pushed = Navigator.of(context).push<PhotoPreviewResult>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PhotoPreviewScreen(
|
||||
bytes: bytes,
|
||||
fileName: 'test.png',
|
||||
mime: 'image/png',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('otevřít'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('otevřít'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
// Reálný async decode obrázku — počkat, až zmizí spinner.
|
||||
for (var i = 0; i < 50; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await tester.pump();
|
||||
if (find.byType(CircularProgressIndicator).evaluate().isEmpty) break;
|
||||
}
|
||||
expect(
|
||||
find.byType(CircularProgressIndicator),
|
||||
findsNothing,
|
||||
reason: 'obrázek se nedekódoval',
|
||||
);
|
||||
return pushed;
|
||||
}
|
||||
|
||||
Future<PhotoPreviewResult?> settleResult(
|
||||
WidgetTester tester,
|
||||
Future<PhotoPreviewResult?> pushed,
|
||||
) async {
|
||||
// Render + pop jsou reálné async operace — pumpovat, dokud route žije.
|
||||
// pump s duration posouvá i pop animaci, jinak by route nikdy nezmizel.
|
||||
for (var i = 0; i < 100; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
if (find.byType(PhotoPreviewScreen).evaluate().isEmpty) break;
|
||||
}
|
||||
return pushed;
|
||||
}
|
||||
|
||||
testWidgets('zrušení nic nepošle', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
final pushed = await openPreview(tester, _testPng());
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
final result = await settleResult(tester, pushed);
|
||||
expect(find.byType(PhotoPreviewScreen), findsNothing);
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('odeslání bez úprav vrací původní bajty + popisek', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
final bytes = _testPng();
|
||||
final pushed = await openPreview(tester, bytes);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'ahoj z testu');
|
||||
await tester.tap(find.byTooltip('Poslat'));
|
||||
final result = await settleResult(tester, pushed);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.bytes, same(bytes)); // beze změn = žádný re-encode tady
|
||||
expect(result.caption, 'ahoj z testu');
|
||||
expect(result.fileName, 'test.png');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('rotace se propíše do odeslaných bajtů', (tester) async {
|
||||
await tester.runAsync(() async {
|
||||
final pushed = await openPreview(tester, _testPng(width: 8, height: 4));
|
||||
|
||||
await tester.tap(find.byTooltip('Otočit'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Poslat'));
|
||||
final result = await settleResult(tester, pushed);
|
||||
|
||||
expect(result, isNotNull);
|
||||
final decoded = img.decodeImage(result!.bytes)!;
|
||||
expect(decoded.width, 4); // 8×4 otočeno o 90° → 4×8
|
||||
expect(decoded.height, 8);
|
||||
expect(result.caption, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/features/chat_list/push_hint_banner.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
|
||||
/// Proužek říká uživateli, proč mu zprávy chodí až po otevření appky.
|
||||
/// Musí se ukázat jen tehdy, když budíčky opravdu nefungují — jinak by
|
||||
/// z informace o stavu byla otravná reklama.
|
||||
Widget _app({required PushHealth health, required bool dismissed}) =>
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
pushHealthProvider.overrideWith((ref) async => health),
|
||||
pushHintDismissedProvider.overrideWith((ref) async => dismissed),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: Locale('cs'),
|
||||
home: Scaffold(body: PushHintBanner()),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('push vypnutý → proužek se ukáže', (tester) async {
|
||||
await tester.pumpWidget(_app(health: PushHealth.off, dismissed: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.notifications_off_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('registrace selhala → proužek se ukáže', (tester) async {
|
||||
await tester.pumpWidget(_app(health: PushHealth.failed, dismissed: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.notifications_off_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('budíčky fungují → proužek mlčí', (tester) async {
|
||||
await tester.pumpWidget(_app(health: PushHealth.ok, dismissed: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.notifications_off_outlined), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('odmítnutý proužek se znovu neukáže', (tester) async {
|
||||
await tester.pumpWidget(_app(health: PushHealth.off, dismissed: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.notifications_off_outlined), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
|
||||
/// Obrazovka Nastavení se musí vykreslit bez výjimky v build —
|
||||
/// pád tady znamená pro uživatele „Tahle obrazovka se nevykreslila".
|
||||
class _FakeIdentityNotifier extends IdentityNotifier {
|
||||
_FakeIdentityNotifier(this._identity);
|
||||
final LocalIdentity? _identity;
|
||||
|
||||
@override
|
||||
Future<LocalIdentity?> build() async => _identity;
|
||||
}
|
||||
|
||||
LocalIdentity _identityWithName(String name) => LocalIdentity(
|
||||
displayName: name,
|
||||
identityPrivateKey: Uint8List(32),
|
||||
identityPublicKey: Uint8List(33),
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
Widget _app(LocalIdentity? identity) => ProviderScope(
|
||||
overrides: [
|
||||
identityProvider.overrideWith(() => _FakeIdentityNotifier(identity)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('cs'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const SettingsScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('vykreslí se s běžnou identitou', (tester) async {
|
||||
await tester.pumpWidget(_app(_identityWithName('Alice')));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('vykreslí se s prázdným jménem identity', (tester) async {
|
||||
await tester.pumpWidget(_app(_identityWithName('')));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('vykreslí se bez identity', (tester) async {
|
||||
await tester.pumpWidget(_app(null));
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -12,10 +12,8 @@
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <local_auth_windows/local_auth_plugin.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
@@ -30,12 +28,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
WindowManagerPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
local_auth_windows
|
||||
record_windows
|
||||
screen_retriever_windows
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
window_manager
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -22,6 +22,13 @@ services:
|
||||
DATABASE_URL: postgres://sepot:${POSTGRES_PASSWORD}@db:5432/sepot_relay
|
||||
SEPOT_BIND: 0.0.0.0:8470
|
||||
SEPOT_ATTACHMENTS_DIR: /var/lib/sepot/attachments
|
||||
# Sítě reverzní proxy, které smí určovat klientskou IP. Forwarded
|
||||
# hlavičky (X-Forwarded-For/X-Real-Ip) se uznají JEN od IP v této sadě,
|
||||
# jinak platí adresa protistrany — jinak by si anti-abuse klíč mohl
|
||||
# nastavit sám klient. Musí pokrýt síť, ze které proxy na relay doráží.
|
||||
# Síť Toru sem NEPATŘÍ: onion klienti mají sdílet jeden bucket.
|
||||
# Prázdné = fail-safe, vždy adresa protistrany.
|
||||
SEPOT_TRUSTED_PROXIES: ${SEPOT_TRUSTED_PROXIES:-}
|
||||
# FCM fast mode (opt-in budíčky přes Google) — SA key ŽIJE JEN NA
|
||||
# serveru (fcm-sa.json vedle .env, chmod 600, NIKDY do gitu)
|
||||
SEPOT_FCM_CREDENTIALS: /etc/sepot/fcm-sa.json
|
||||
|
||||
Generated
+1
@@ -2456,6 +2456,7 @@ dependencies = [
|
||||
"dashmap",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"ipnet",
|
||||
"jsonwebtoken",
|
||||
"libsignal-core",
|
||||
"libsignal-protocol",
|
||||
|
||||
@@ -22,6 +22,8 @@ sqlx = { version = "0.8", default-features = false, features = [
|
||||
"macros",
|
||||
] }
|
||||
dashmap = "6"
|
||||
# CIDR match trusted proxy sítí pro anti-abuse klíč (viz net.rs)
|
||||
ipnet = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -123,11 +123,13 @@ impl ApnsSender {
|
||||
.body(r#"{"aps":{"content-available":1}}"#)
|
||||
.send()
|
||||
.await;
|
||||
// warn, ne debug — viz shodný komentář ve fcm.rs: na RUST_LOG=info
|
||||
// bylo selhání pushe neviditelné.
|
||||
match resp {
|
||||
Ok(r) if !r.status().is_success() => {
|
||||
tracing::debug!(status = %r.status(), "APNs budíček odmítnut");
|
||||
tracing::warn!(status = %r.status(), "APNs budíček odmítnut");
|
||||
}
|
||||
Err(e) => tracing::debug!("APNs budíček selhal: {e}"),
|
||||
Err(e) => tracing::warn!("APNs budíček selhal: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ mod db_tests {
|
||||
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("sepot-att-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("tmp")).unwrap();
|
||||
let trusted = std::sync::Arc::new(crate::net::TrustedProxies::from_env());
|
||||
AppState::new(
|
||||
pool,
|
||||
certs,
|
||||
@@ -309,6 +310,7 @@ mod db_tests {
|
||||
dir,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
trusted,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,18 @@ pub struct CertAuthority {
|
||||
|
||||
impl CertAuthority {
|
||||
/// Načte klíče z DB; při prvním startu je vygeneruje a uloží.
|
||||
/// Trust root private v DB je dev pohodlí kostry — v produkci se
|
||||
/// vygeneruje offline a v DB bude jen server keypair + certifikát.
|
||||
///
|
||||
/// BEZPEČNOST (SECURITY audit #2): trust root private se do DB NEUKLÁDÁ.
|
||||
/// Runtime ho nepotřebuje (podpis SenderCertificate dělá server_private),
|
||||
/// slouží jen k jednorázovému podpisu server certifikátu při bootstrapu
|
||||
/// a hned se zahodí. Kompromitace DB tak neprozradí kotvu důvěry, kterou
|
||||
/// klienti pinnují.
|
||||
///
|
||||
/// ZBYTKOVÉ RIZIKO: server_private v DB zůstat MUSÍ (runtime podepisování)
|
||||
/// a sám stačí na výrobu platného SenderCertificate → kompromitace DB
|
||||
/// stále dovolí dočasnou impersonaci. Plné řešení = server keypair do
|
||||
/// KMS/HSM, mimo scope kostry. Rotace server klíče vyžaduje offline trust
|
||||
/// root (v dev se regeneruje celá kotva → mění se i pin klientů).
|
||||
pub async fn init(pool: &PgPool) -> anyhow::Result<Self> {
|
||||
let row: Option<(Vec<u8>, Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
||||
"SELECT trust_root_public, server_private, server_certificate FROM relay_keys WHERE id = 1",
|
||||
@@ -52,20 +62,21 @@ impl CertAuthority {
|
||||
.map_err(|e| anyhow::anyhow!("serialized: {e}"))?
|
||||
.to_vec();
|
||||
|
||||
// trust_root_private se ZÁMĚRNĚ neukládá (audit #2) — sloupec zůstane
|
||||
// NULL, private klíč kotvy důvěry žije jen po dobu tohoto bootstrapu.
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_keys
|
||||
(id, trust_root_public, trust_root_private, server_public, server_private, server_certificate)
|
||||
VALUES (1, $1, $2, $3, $4, $5)",
|
||||
(id, trust_root_public, server_public, server_private, server_certificate)
|
||||
VALUES (1, $1, $2, $3, $4)",
|
||||
)
|
||||
.bind(trust_root.public_key.serialize().to_vec())
|
||||
.bind(trust_root.private_key.serialize())
|
||||
.bind(server.public_key.serialize().to_vec())
|
||||
.bind(server.private_key.serialize())
|
||||
.bind(&server_cert_bytes)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!("vygenerován nový trust root + server certificate");
|
||||
tracing::info!("vygenerován nový trust root + server certificate (root private nezachován)");
|
||||
Ok(Self {
|
||||
trust_root_public: trust_root.public_key.serialize().to_vec(),
|
||||
server_private: server.private_key,
|
||||
|
||||
@@ -143,11 +143,15 @@ impl FcmSender {
|
||||
"android": { "priority": "HIGH" }
|
||||
}
|
||||
});
|
||||
// warn, ne debug: relay běží na RUST_LOG=info, takže na debugu bylo
|
||||
// selhání pushe neviditelné a tiše se hromadila fronta. Protistranou
|
||||
// je Google, ne telefon uživatele — chyba tu znamená mrtvý token nebo
|
||||
// rozbitou konfiguraci projektu, obojí je provozní problém.
|
||||
match http.post(&url).bearer_auth(access).json(&body).send().await {
|
||||
Ok(resp) if !resp.status().is_success() => {
|
||||
tracing::debug!(status = %resp.status(), "FCM budíček odmítnut");
|
||||
tracing::warn!(status = %resp.status(), "FCM budíček odmítnut");
|
||||
}
|
||||
Err(e) => tracing::debug!("FCM budíček selhal: {e}"),
|
||||
Err(e) => tracing::warn!("FCM budíček selhal: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod apns;
|
||||
mod attachments;
|
||||
mod certs;
|
||||
mod fcm;
|
||||
mod net;
|
||||
mod push;
|
||||
mod rest;
|
||||
mod state;
|
||||
@@ -24,8 +25,9 @@ use axum::routing::{delete, get, post};
|
||||
use axum::Router;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tower_governor::governor::GovernorConfigBuilder;
|
||||
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
||||
use tower_governor::GovernorLayer;
|
||||
|
||||
use crate::net::{TrustedProxies, TrustedProxyIp};
|
||||
use tower_http::timeout::TimeoutLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
@@ -34,7 +36,7 @@ const MAX_BODY_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Log formát bez IP adres a bez identifikátorů klientů.
|
||||
// Log formát bez IP adres a bez identifikátorů klientů — DoD sprintu.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
@@ -54,23 +56,29 @@ async fn main() -> anyhow::Result<()> {
|
||||
let certs = certs::CertAuthority::init(&pool).await?;
|
||||
let attachments_cfg = attachments::Config::from_env();
|
||||
tokio::fs::create_dir_all(attachments_cfg.dir.join("tmp")).await?;
|
||||
let state = Arc::new(state::AppState::new(pool, certs, attachments_cfg));
|
||||
// Trusted proxy sada je sdílená mezi WS conn-cap (AppState) a všemi
|
||||
// governor limity — jeden zdroj pravdy o tom, komu se věří XFF.
|
||||
let trusted = Arc::new(TrustedProxies::from_env());
|
||||
let state = Arc::new(state::AppState::new(
|
||||
pool,
|
||||
certs,
|
||||
attachments_cfg,
|
||||
trusted.clone(),
|
||||
));
|
||||
attachments::spawn_cleanup(state.clone());
|
||||
|
||||
// ── Rate limiting (základní minimum; plný PoW/Privacy Pass později) ─
|
||||
// ── Rate limiting (minimální sada; plný PoW/Privacy Pass později) ──
|
||||
//
|
||||
// Klíč = klientská IP: za traefikem X-Forwarded-For/X-Real-Ip (na
|
||||
// relay dosáhne jen traefik z docker sítě), jinak peer adresa.
|
||||
// Klíč = klientská IP přes TrustedProxyIp: X-Forwarded-For/X-Real-Ip se
|
||||
// uzná JEN od trusted proxy (traefik), jinak peer adresa. Onion klienti
|
||||
// přicházejí z tor kontejneru (mimo trusted sadu) → sdílí jeden bucket
|
||||
// a nemohou si IP nastavit hlavičkou (SECURITY audit #1).
|
||||
// In-memory token bucket — nikdy se nezapisuje na disk (privacy).
|
||||
//
|
||||
// Známý limit: Tor onion klienti přicházejí z tor kontejneru bez XFF
|
||||
// → sdílí jeden bucket. Pro v1 (nízký provoz) přijatelné, řeší až
|
||||
// Privacy Pass — limity proto raději volnější.
|
||||
|
||||
// registrace: zápis do DB bez auth → nejpřísnější (burst 10, +1/5 min)
|
||||
let gov_register = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
||||
.burst_size(10)
|
||||
.period(Duration::from_secs(300))
|
||||
.finish()
|
||||
@@ -79,7 +87,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
// čtecí API (bundle žere OTP klíče → drain ochrana): burst 60, +2/s
|
||||
let gov_general = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
||||
.burst_size(60)
|
||||
.per_second(2)
|
||||
.finish()
|
||||
@@ -89,7 +97,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
// přílohy: upload je autentizovaný, ale drahý (disk) → burst 30, +1/s
|
||||
let gov_attachments = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
||||
.burst_size(30)
|
||||
.per_second(1)
|
||||
.finish()
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Určení klientské IP za reverse proxy — anti-abuse klíč.
|
||||
//!
|
||||
//! BEZPEČNOST (SECURITY audit #1): forwarded hlavičky (X-Forwarded-For,
|
||||
//! X-Real-Ip) smí nastavit kdokoli. Relay je dosažitelný dvěma cestami:
|
||||
//! clearnet přes traefik (síť `pangolin`), kde traefik klientské X-hlavičky
|
||||
//! přepisuje a jsou tedy důvěryhodné; a Tor onion přes tor kontejner (síť
|
||||
//! `tornet`) PŘÍMO na :8470, mimo traefik — tor forwarduje HTTP beze změny,
|
||||
//! takže hlavičky si nastaví sám klient.
|
||||
//!
|
||||
//! Kdybychom hlavičkám věřili vždy, onion klient by si zvolil libovolnou IP
|
||||
//! a obešel VŠECHNY rate-limity i WS conn-cap (registrace, fetch_bundle,
|
||||
//! upload, počet spojení). Proto: hlavičku uznej JEN když peer (skutečná
|
||||
//! TCP adresa spojení) padne do explicitně nakonfigurované sady trusted
|
||||
//! proxy. Onion peer (tor kontejner) tam není → použije se jeho IP a všichni
|
||||
//! onion klienti sdílí jeden bucket (přesně jak počítal původní komentář).
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{HeaderMap, Request};
|
||||
use ipnet::IpNet;
|
||||
use tower_governor::key_extractor::KeyExtractor;
|
||||
use tower_governor::GovernorError;
|
||||
|
||||
/// Sada sítí, jejichž forwarded hlavičkám se věří (traefik/pangolin).
|
||||
/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR).
|
||||
pub struct TrustedProxies {
|
||||
nets: Vec<IpNet>,
|
||||
}
|
||||
|
||||
impl TrustedProxies {
|
||||
/// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se
|
||||
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
|
||||
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
|
||||
pub fn from_env() -> Self {
|
||||
let raw = std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default();
|
||||
let nets: Vec<IpNet> = raw
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// "10.0.0.0/24" i holé "10.0.0.5" (bereme jako /32, resp. /128)
|
||||
s.parse::<IpNet>()
|
||||
.ok()
|
||||
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
||||
})
|
||||
.collect();
|
||||
if nets.is_empty() {
|
||||
tracing::warn!(
|
||||
"SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \
|
||||
anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě."
|
||||
);
|
||||
}
|
||||
Self { nets }
|
||||
}
|
||||
|
||||
fn trusts(&self, peer: IpAddr) -> bool {
|
||||
self.nets.iter().any(|n| n.contains(&peer))
|
||||
}
|
||||
|
||||
/// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted
|
||||
/// proxy; jinak (onion, dev, přímé spojení) peer IP. Pořadí X-Forwarded-For
|
||||
/// → X-Real-Ip drží konzistenci s governor extraktorem. Bere se PRVNÍ
|
||||
/// (nejlevější) záznam XFF = původní klient — platí jen díky tomu, že
|
||||
/// traefik pro netrusted klienty příchozí XFF přepisuje (nedopisuje).
|
||||
pub fn client_ip(&self, peer: IpAddr, headers: &HeaderMap) -> IpAddr {
|
||||
if !self.trusts(peer) {
|
||||
return peer;
|
||||
}
|
||||
let from_header = |name: &str| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.and_then(|v| v.trim().parse::<IpAddr>().ok())
|
||||
};
|
||||
from_header("x-forwarded-for")
|
||||
.or_else(|| from_header("x-real-ip"))
|
||||
.unwrap_or(peer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Governor key extractor s ověřením trusted proxy. Nahrazuje
|
||||
/// `SmartIpKeyExtractor`, který forwarded hlavičkám věří bezpodmínečně.
|
||||
#[derive(Clone)]
|
||||
pub struct TrustedProxyIp(pub Arc<TrustedProxies>);
|
||||
|
||||
impl KeyExtractor for TrustedProxyIp {
|
||||
type Key = IpAddr;
|
||||
|
||||
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0.ip())
|
||||
.ok_or(GovernorError::UnableToExtractKey)?;
|
||||
Ok(self.0.client_ip(peer, req.headers()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::header::{HeaderName, HeaderValue};
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
fn proxies(cfg: &[&str]) -> TrustedProxies {
|
||||
let nets = cfg
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
s.parse::<IpNet>()
|
||||
.ok()
|
||||
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
||||
})
|
||||
.collect();
|
||||
TrustedProxies { nets }
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(v).unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// JÁDRO auditu #1: onion peer (mimo trusted sadu) si NESMÍ nastavit IP
|
||||
/// hlavičkou — anti-abuse klíč = peer IP tor kontejneru, ne spoof.
|
||||
#[test]
|
||||
fn onion_peer_nesmi_spoofnout_ip() {
|
||||
let tp = proxies(&["10.20.0.0/16"]); // jen pangolin síť
|
||||
let onion_peer = ip("172.30.0.5"); // tor kontejner na jiné síti
|
||||
let spoof = headers(&[("x-real-ip", "1.2.3.4"), ("x-forwarded-for", "5.6.7.8")]);
|
||||
assert_eq!(tp.client_ip(onion_peer, &spoof), onion_peer);
|
||||
}
|
||||
|
||||
/// Trusted proxy (traefik) → forwarded hlavička se uzná, klíč = klient.
|
||||
#[test]
|
||||
fn trusted_proxy_uzna_forwarded() {
|
||||
let tp = proxies(&["10.20.0.0/16"]);
|
||||
let traefik = ip("10.20.0.3");
|
||||
let h = headers(&[("x-forwarded-for", "203.0.113.9")]);
|
||||
assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9"));
|
||||
}
|
||||
|
||||
/// XFF má přednost před X-Real-Ip (konzistence s governor extraktorem);
|
||||
/// z řetězce se bere nejlevější (původní klient).
|
||||
#[test]
|
||||
fn xff_prednost_a_nejlevejsi() {
|
||||
let tp = proxies(&["10.20.0.3"]); // holá IP = /32
|
||||
let traefik = ip("10.20.0.3");
|
||||
let h = headers(&[
|
||||
("x-forwarded-for", "203.0.113.9, 10.20.0.3"),
|
||||
("x-real-ip", "198.51.100.1"),
|
||||
]);
|
||||
assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9"));
|
||||
}
|
||||
|
||||
/// Trusted proxy bez hlaviček → fallback na peer.
|
||||
#[test]
|
||||
fn trusted_bez_hlavicek_fallback_peer() {
|
||||
let tp = proxies(&["10.20.0.0/16"]);
|
||||
let traefik = ip("10.20.0.3");
|
||||
assert_eq!(tp.client_ip(traefik, &HeaderMap::new()), traefik);
|
||||
}
|
||||
|
||||
/// Prázdná konfigurace (fail-safe) → vždy peer, hlavičky se ignorují.
|
||||
#[test]
|
||||
fn prazdna_konfigurace_vzdy_peer() {
|
||||
let tp = proxies(&[]);
|
||||
let peer = ip("10.20.0.3");
|
||||
let spoof = headers(&[("x-forwarded-for", "1.2.3.4")]);
|
||||
assert_eq!(tp.client_ip(peer, &spoof), peer);
|
||||
}
|
||||
|
||||
/// IPv6 trusted CIDR taky funguje.
|
||||
#[test]
|
||||
fn ipv6_trusted_cidr() {
|
||||
let tp = proxies(&["fd00::/8"]);
|
||||
let peer = ip("fd00::1234");
|
||||
let h = headers(&[("x-forwarded-for", "2001:db8::1")]);
|
||||
assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1"));
|
||||
}
|
||||
}
|
||||
@@ -85,13 +85,23 @@ pub async fn send_wake(
|
||||
/// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli
|
||||
/// testovatelnosti proti loopback mocku (guard by loopback jinak odmítl).
|
||||
async fn post_wake(client: &Client, url: &str) {
|
||||
let _ = client
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json")
|
||||
.header("ttl", "2419200") // WebPush TTL (28 dní), ntfy ignoruje
|
||||
.body(WAKE_BODY)
|
||||
.send()
|
||||
.await;
|
||||
// Výsledek se dřív zahazoval (`let _ =`), takže nešlo poznat, jestli
|
||||
// budíčky vůbec odcházejí. Logujeme jen stavový kód — endpoint je
|
||||
// identifikátor uživatele a do logu nesmí.
|
||||
match resp {
|
||||
Ok(r) if !r.status().is_success() => {
|
||||
tracing::warn!(status = %r.status(), "UnifiedPush budíček odmítnut");
|
||||
}
|
||||
Err(e) => tracing::warn!("UnifiedPush budíček selhal: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Povolit jen bezpečné cíle. https vždy; http jen pro .onion (samo šifruje).
|
||||
@@ -191,7 +201,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn verejne_adresy_projdou() {
|
||||
for ip in ["1.1.1.1", "8.8.8.8", "203.0.113.10", "2606:4700:4700::1111"] {
|
||||
for ip in ["1.1.1.1", "8.8.8.8", "9.9.9.9", "2606:4700:4700::1111"] {
|
||||
assert!(!is_internal_ip(&ip.parse().unwrap()), "{ip} nemělo být interní");
|
||||
}
|
||||
}
|
||||
|
||||
+457
-22
@@ -1,4 +1,6 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use dashmap::DashMap;
|
||||
use prost::Message;
|
||||
use sepot_proto::v1;
|
||||
@@ -7,18 +9,80 @@ use sqlx::PgPool;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::certs::CertAuthority;
|
||||
use crate::net::TrustedProxies;
|
||||
|
||||
/// TTL offline fronty.
|
||||
pub const QUEUE_TTL_DAYS: i64 = 30;
|
||||
|
||||
/// Strop celkové velikosti fronty na příjemce (SECURITY audit #3). Bez něj
|
||||
/// autentizovaný útočník nasype neomezeně obálek (256 KiB × N) a při flushi
|
||||
/// z toho vznikne OOM, na disku neomezený růst. Přetečení = FIFO zahození
|
||||
/// nejstarších. 50 MiB ≈ 200 max obálek na příjemce.
|
||||
const MAX_QUEUE_BYTES_PER_RECIPIENT: i64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Velikost dávky při vylévání fronty (SECURITY audit #3). Strop paměti =
|
||||
/// FLUSH_BATCH × MAX_ENVELOPE_PAYLOAD (~16 MiB), ne celá fronta najednou.
|
||||
const FLUSH_BATCH: i64 = 64;
|
||||
|
||||
/// Po kolika marných pokusech o doručení obálku zahodit.
|
||||
///
|
||||
/// Ztracený ACK je jednosměrka: klient obálku dešifruje (ratchet postoupí),
|
||||
/// uloží a pošle ACK — když ten cestou zmizí (mrtvé spojení), relay obálku
|
||||
/// drží dál, jenže druhý pokus o dešifrování už libsignal odmítne. Klient ji
|
||||
/// tedy neACKuje a fronta se otravuje: tatáž obálka se posílá při každém
|
||||
/// připojení až do TTL 30 dní a příjemce ji nikdy nedostane. Strop tomu dá
|
||||
/// konec. Osm pokusů je velkoryse nad běžné „appka spadla před ACK".
|
||||
const MAX_DELIVERY_ATTEMPTS: i16 = 8;
|
||||
|
||||
/// Jak dlouho po posledním rámci od klienta se spojení ještě považuje za
|
||||
/// prokazatelně živé. Klient odpovídá na ping à 30 s (ws::PING_INTERVAL),
|
||||
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
||||
const FRESH_CONNECTION_MS: u64 = 70_000;
|
||||
|
||||
/// Připojené zařízení: kanál na jeho WS + kdy od něj naposled něco přišlo.
|
||||
///
|
||||
/// Samotný `tx` o životnosti spojení nic neříká — je to mpsc s bufferem, do
|
||||
/// kterého zápis projde i u dávno mrtvého socketu. Právě proto se dřív vracelo
|
||||
/// falešné DELIVERED_ONLINE a push budíček se vůbec nezkusil.
|
||||
pub struct OnlineDevice {
|
||||
pub tx: mpsc::Sender<v1::ServerFrame>,
|
||||
/// ms od startu procesu; monotónní, aby s ním neházel NTP.
|
||||
last_frame_ms: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl OnlineDevice {
|
||||
pub fn new(tx: mpsc::Sender<v1::ServerFrame>, now_ms: u64) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
last_frame_ms: std::sync::atomic::AtomicU64::new(now_ms),
|
||||
}
|
||||
}
|
||||
|
||||
/// Přišel rámec od klienta (Pong/ACK/Envelope) — spojení dýchá.
|
||||
pub fn touch(&self, now_ms: u64) {
|
||||
self.last_frame_ms
|
||||
.store(now_ms, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn is_fresh(&self, now_ms: u64) -> bool {
|
||||
let last = self.last_frame_ms.load(std::sync::atomic::Ordering::Relaxed);
|
||||
now_ms.saturating_sub(last) <= FRESH_CONNECTION_MS
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
/// delivery_address → kanál na živé WS spojení zařízení.
|
||||
pub online: DashMap<Vec<u8>, mpsc::Sender<v1::ServerFrame>>,
|
||||
/// Kdy proces nastartoval — základ monotónních značek v [`OnlineDevice`].
|
||||
pub started: std::time::Instant,
|
||||
/// delivery_address → živé WS spojení zařízení.
|
||||
pub online: DashMap<Vec<u8>, Arc<OnlineDevice>>,
|
||||
/// klientská IP → počet souběžných WS spojení (anti-flood cap).
|
||||
/// Jen v paměti, nikam se neloguje ani neukládá.
|
||||
pub ws_conns: DashMap<std::net::IpAddr, u32>,
|
||||
pub certs: CertAuthority,
|
||||
/// Trusted proxy sada pro určení klientské IP (WS conn-cap). Sdílená
|
||||
/// s governor limity — komu se věří forwarded hlavička.
|
||||
pub trusted_proxies: Arc<TrustedProxies>,
|
||||
/// HTTP klient na push budíčky (bez redirectů, krátký timeout).
|
||||
pub http: reqwest::Client,
|
||||
/// Úložiště šifrovaných příloh (adresář + strop velikosti).
|
||||
@@ -30,12 +94,19 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: PgPool, certs: CertAuthority, attachments: crate::attachments::Config) -> Self {
|
||||
pub fn new(
|
||||
pool: PgPool,
|
||||
certs: CertAuthority,
|
||||
attachments: crate::attachments::Config,
|
||||
trusted_proxies: Arc<TrustedProxies>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
started: std::time::Instant::now(),
|
||||
online: DashMap::new(),
|
||||
ws_conns: DashMap::new(),
|
||||
certs,
|
||||
trusted_proxies,
|
||||
http: crate::push::build_client(),
|
||||
attachments,
|
||||
fcm: crate::fcm::FcmSender::from_env(),
|
||||
@@ -71,20 +142,57 @@ impl AppState {
|
||||
.bind(expires)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
// FIFO strop na příjemce (audit #3). Nově vložená má nejmladší
|
||||
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
|
||||
self.trim_queue(&dst, MAX_QUEUE_BYTES_PER_RECIPIENT).await?;
|
||||
|
||||
if let Some(tx) = self.online.get(&dst) {
|
||||
if let Some(dev) = self.online.get(&dst).map(|d| d.clone()) {
|
||||
let deliver = v1::ServerFrame {
|
||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||
};
|
||||
if tx.send(deliver).await.is_ok() {
|
||||
let fresh = dev.is_fresh(self.now_ms());
|
||||
let queued = dev.tx.send(deliver).await.is_ok();
|
||||
// ONLINE hlásíme jen u spojení, které prokazatelně dýchá. Do
|
||||
// zatuchlého se sice pošle taky (kdyby to byl jen tichý klient),
|
||||
// ale propadne se i na push — jinak by mrtvé spojení budíček
|
||||
// spolklo a příjemce se o zprávě nedozvěděl.
|
||||
if fresh && queued {
|
||||
return Ok(v1::receipt::Status::DeliveredOnline);
|
||||
}
|
||||
}
|
||||
// Offline (nebo online kanál selhal) → zkusit push budíček.
|
||||
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
|
||||
self.wake(&dst).await;
|
||||
Ok(v1::receipt::Status::StoredOffline)
|
||||
}
|
||||
|
||||
/// FIFO strop fronty příjemce: nech nejnovější obálky do `cap_bytes`
|
||||
/// (běžný součet od nejnovější), starší přebytek zahoď. Vrací počet
|
||||
/// zahozených. Oddělené kvůli přímému testu bez vkládání 50 MiB.
|
||||
async fn trim_queue(&self, dst: &[u8], cap_bytes: i64) -> anyhow::Result<u64> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM message_queue
|
||||
WHERE dst_address = $1 AND msg_uuid IN (
|
||||
SELECT msg_uuid FROM (
|
||||
SELECT msg_uuid,
|
||||
sum(octet_length(envelope)) OVER (
|
||||
ORDER BY enqueued_at DESC, msg_uuid DESC
|
||||
ROWS UNBOUNDED PRECEDING) AS running
|
||||
FROM message_queue WHERE dst_address = $1
|
||||
) s WHERE running > $2
|
||||
)",
|
||||
)
|
||||
.bind(dst)
|
||||
.bind(cap_bytes)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
/// Monotónní značka pro [`OnlineDevice`] — ms od startu procesu.
|
||||
pub fn now_ms(&self) -> u64 {
|
||||
self.started.elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// Budíček pro offline příjemce: dohledá jeho push token a pošle prázdný
|
||||
/// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli.
|
||||
/// Endpoint se NELOGUJE.
|
||||
@@ -172,32 +280,118 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream).
|
||||
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream). Čte po
|
||||
/// dávkách keyset kurzorem přes (enqueued_at, msg_uuid) — paměť je
|
||||
/// omezená na FLUSH_BATCH obálek, ne celá fronta najednou (audit #3).
|
||||
/// Index idx_queue_dst(dst_address, enqueued_at) kurzor pokrývá.
|
||||
pub async fn flush_queue(
|
||||
&self,
|
||||
delivery_address: &[u8],
|
||||
tx: &mpsc::Sender<v1::ServerFrame>,
|
||||
) -> anyhow::Result<usize> {
|
||||
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
|
||||
"SELECT envelope FROM message_queue
|
||||
WHERE dst_address = $1 AND expires_at > now()
|
||||
ORDER BY enqueued_at",
|
||||
// Obálky, které strop pokusů vyčerpaly, jsou neprůchodné (skoro vždy
|
||||
// ztracený ACK po úspěšném dešifrování) — zahodit dřív, než se znovu
|
||||
// pošlou. Maže se až tady, ne hned po inkrementu: poslední pokus
|
||||
// dostane šanci doběhnout do ACK.
|
||||
let dropped = sqlx::query(
|
||||
"DELETE FROM message_queue WHERE dst_address = $1 AND attempt_count >= $2",
|
||||
)
|
||||
.bind(delivery_address)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if dropped > 0 {
|
||||
// Bez identifikátoru příjemce — jen že se fronta čistila.
|
||||
tracing::info!(dropped, "nedoručitelné obálky zahozeny (strop pokusů)");
|
||||
}
|
||||
|
||||
let count = rows.len();
|
||||
for (encoded,) in rows {
|
||||
let env = v1::Envelope::decode(encoded.as_slice())?;
|
||||
let frame = v1::ServerFrame {
|
||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||
let mut total = 0usize;
|
||||
// (enqueued_at, msg_uuid) poslední odeslané obálky = kurzor další dávky.
|
||||
let mut cursor: Option<(DateTime<Utc>, Vec<u8>)> = None;
|
||||
loop {
|
||||
let rows: Vec<(Vec<u8>, DateTime<Utc>, Vec<u8>)> = match &cursor {
|
||||
None => {
|
||||
sqlx::query_as(
|
||||
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
|
||||
WHERE dst_address = $1 AND expires_at > now()
|
||||
AND attempt_count < $2
|
||||
ORDER BY enqueued_at, msg_uuid
|
||||
LIMIT $3",
|
||||
)
|
||||
.bind(delivery_address)
|
||||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||
.bind(FLUSH_BATCH)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
}
|
||||
Some((ts, uuid)) => {
|
||||
sqlx::query_as(
|
||||
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
|
||||
WHERE dst_address = $1 AND expires_at > now()
|
||||
AND attempt_count < $2
|
||||
AND (enqueued_at, msg_uuid) > ($3, $4)
|
||||
ORDER BY enqueued_at, msg_uuid
|
||||
LIMIT $5",
|
||||
)
|
||||
.bind(delivery_address)
|
||||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||
.bind(ts)
|
||||
.bind(uuid)
|
||||
.bind(FLUSH_BATCH)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
if tx.send(frame).await.is_err() {
|
||||
let batch_len = rows.len();
|
||||
if batch_len == 0 {
|
||||
break;
|
||||
}
|
||||
let last = rows
|
||||
.last()
|
||||
.map(|(_, ts, uuid)| (*ts, uuid.clone()))
|
||||
.expect("dávka není prázdná");
|
||||
let mut sent_uuids: Vec<Vec<u8>> = Vec::with_capacity(batch_len);
|
||||
for (encoded, _, uuid) in &rows {
|
||||
let env = v1::Envelope::decode(encoded.as_slice())?;
|
||||
let frame = v1::ServerFrame {
|
||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||
};
|
||||
if tx.send(frame).await.is_err() {
|
||||
// Příjemce odpadl uprostřed dávky — započítat jen to, co
|
||||
// reálně odešlo, ať pokus „zdarma" nekrátí strop.
|
||||
self.bump_attempts(&sent_uuids).await;
|
||||
return Ok(total);
|
||||
}
|
||||
sent_uuids.push(uuid.clone());
|
||||
total += 1;
|
||||
}
|
||||
self.bump_attempts(&sent_uuids).await;
|
||||
if (batch_len as i64) < FLUSH_BATCH {
|
||||
break; // poslední (neúplná) dávka
|
||||
}
|
||||
cursor = Some(last);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Započítá pokus o doručení odeslaným obálkám. Selhání se jen zaloguje —
|
||||
/// obálky už jsou na drátě a shodit kvůli tomu flush by bylo horší než
|
||||
/// nepřesné počítadlo.
|
||||
async fn bump_attempts(&self, msg_uuids: &[Vec<u8>]) {
|
||||
if msg_uuids.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE message_queue SET attempt_count = attempt_count + 1
|
||||
WHERE msg_uuid = ANY($1)",
|
||||
)
|
||||
.bind(msg_uuids)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("attempt_count se nepodařilo zvýšit: {e}");
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
|
||||
@@ -228,7 +422,8 @@ mod db_tests {
|
||||
.await
|
||||
.expect("dev DB");
|
||||
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
||||
AppState::new(pool, certs, crate::attachments::Config::from_env())
|
||||
let trusted = Arc::new(crate::net::TrustedProxies::from_env());
|
||||
AppState::new(pool, certs, crate::attachments::Config::from_env(), trusted)
|
||||
}
|
||||
|
||||
fn rand_bytes(n: usize) -> Vec<u8> {
|
||||
@@ -413,4 +608,244 @@ mod db_tests {
|
||||
// idempotence: druhé smazání neexistujícího účtu nesmí spadnout
|
||||
state.delete_identity(&account_id).await.unwrap();
|
||||
}
|
||||
|
||||
/// Audit #3: flush přes hranici dávky (150 > FLUSH_BATCH) nesmí keyset
|
||||
/// kurzorem žádnou obálku ztratit ani zdvojit.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn flush_queue_davkuje_pres_hranici_bez_ztraty() {
|
||||
let state = test_state().await;
|
||||
let dst = rand_bytes(32);
|
||||
let n = 150usize;
|
||||
let mut expected = std::collections::HashSet::new();
|
||||
for i in 0..n {
|
||||
let msg_uuid = rand_bytes(16);
|
||||
expected.insert(msg_uuid.clone());
|
||||
let env = v1::Envelope {
|
||||
dst_address: dst.clone(),
|
||||
msg_uuid: msg_uuid.clone(),
|
||||
content_hint: 0,
|
||||
payload: vec![0x11; 32],
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
|
||||
VALUES ($1, $2, $3, now() + (interval '1 millisecond' * $4), now() + interval '30 days')",
|
||||
)
|
||||
.bind(&msg_uuid)
|
||||
.bind(&dst)
|
||||
.bind(env.encode_to_vec())
|
||||
.bind(i as i32)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(256);
|
||||
let count = state.flush_queue(&dst, &tx).await.unwrap();
|
||||
drop(tx);
|
||||
assert_eq!(count, n);
|
||||
|
||||
let mut got = std::collections::HashSet::new();
|
||||
while let Some(frame) = rx.recv().await {
|
||||
if let Some(v1::server_frame::Frame::Deliver(env)) = frame.frame {
|
||||
assert!(got.insert(env.msg_uuid), "obálka doručena dvakrát");
|
||||
}
|
||||
}
|
||||
assert_eq!(got, expected, "sada doručených ≠ sada zařazených");
|
||||
|
||||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&dst)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Zapsat do mpsc bufferu jde i u dávno mrtvého spojení — DELIVERED_ONLINE
|
||||
/// se proto smí hlásit jen u zařízení, od kterého nedávno něco přišlo.
|
||||
/// Zatuchlé musí spadnout na offline větev, jinak spolkne push budíček.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn route_envelope_zatuchle_spojeni_neni_online() {
|
||||
let mut state = test_state().await;
|
||||
// Posunout start procesu do minulosti, ať jde značka „naposled se
|
||||
// ozval na začátku" vyjádřit bez čekání reálných 70 s.
|
||||
state.started = std::time::Instant::now()
|
||||
- std::time::Duration::from_millis(FRESH_CONNECTION_MS + 10_000);
|
||||
let (_account_id, _device_uuid, delivery_address) = seed_device(&state).await;
|
||||
|
||||
// Kanál nikdo nečte, ale buffer zápis spolkne — přesně situace
|
||||
// mrtvého klienta, kterou dřív relay hlásil jako doručeno.
|
||||
let (tx, _rx) = mpsc::channel::<v1::ServerFrame>(64);
|
||||
let device = Arc::new(OnlineDevice::new(tx, 0));
|
||||
state
|
||||
.online
|
||||
.insert(delivery_address.clone(), device.clone());
|
||||
|
||||
let env = v1::Envelope {
|
||||
dst_address: delivery_address.clone(),
|
||||
msg_uuid: rand_bytes(16),
|
||||
content_hint: 0,
|
||||
payload: vec![0xCD; 64],
|
||||
};
|
||||
assert_eq!(
|
||||
state.route_envelope(env).await.unwrap(),
|
||||
v1::receipt::Status::StoredOffline,
|
||||
"zatuchlé spojení se nesmí hlásit jako doručené online"
|
||||
);
|
||||
|
||||
// A naopak: čerstvě označené zařízení online být má.
|
||||
device.touch(state.now_ms());
|
||||
let env2 = v1::Envelope {
|
||||
dst_address: delivery_address.clone(),
|
||||
msg_uuid: rand_bytes(16),
|
||||
content_hint: 0,
|
||||
payload: vec![0xCE; 64],
|
||||
};
|
||||
assert_eq!(
|
||||
state.route_envelope(env2).await.unwrap(),
|
||||
v1::receipt::Status::DeliveredOnline,
|
||||
"živé spojení má být online"
|
||||
);
|
||||
|
||||
state.online.remove(&delivery_address);
|
||||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&delivery_address)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Flush počítá pokusy — bez toho by strop nikdy nezabral.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn flush_queue_pocita_pokusy() {
|
||||
let state = test_state().await;
|
||||
let dst = rand_bytes(32);
|
||||
let msg_uuid = rand_bytes(16);
|
||||
let env = v1::Envelope {
|
||||
dst_address: dst.clone(),
|
||||
msg_uuid: msg_uuid.clone(),
|
||||
content_hint: 0,
|
||||
payload: vec![0x22; 32],
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
||||
VALUES ($1, $2, $3, now() + interval '30 days')",
|
||||
)
|
||||
.bind(&msg_uuid)
|
||||
.bind(&dst)
|
||||
.bind(env.encode_to_vec())
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Dva flushe bez ACK = dva započtené pokusy.
|
||||
for _ in 0..2 {
|
||||
let (tx, rx) = mpsc::channel::<v1::ServerFrame>(16);
|
||||
assert_eq!(state.flush_queue(&dst, &tx).await.unwrap(), 1);
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
let attempts: (i16,) =
|
||||
sqlx::query_as("SELECT attempt_count FROM message_queue WHERE msg_uuid = $1")
|
||||
.bind(&msg_uuid)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(attempts.0, 2, "každý flush musí započítat pokus");
|
||||
|
||||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&dst)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Obálka, kterou příjemce nikdy neACKne (ztracený ACK → ratchet už
|
||||
/// postoupil), musí po stropu zmizet, ne otravovat frontu 30 dní.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn flush_queue_zahodi_nepruchodnou_obalku() {
|
||||
let state = test_state().await;
|
||||
let dst = rand_bytes(32);
|
||||
let msg_uuid = rand_bytes(16);
|
||||
let env = v1::Envelope {
|
||||
dst_address: dst.clone(),
|
||||
msg_uuid: msg_uuid.clone(),
|
||||
content_hint: 0,
|
||||
payload: vec![0x33; 32],
|
||||
};
|
||||
// Rovnou na stropu = obálka, která se marně posílala MAX_DELIVERY_ATTEMPTS×.
|
||||
sqlx::query(
|
||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at, attempt_count)
|
||||
VALUES ($1, $2, $3, now() + interval '30 days', $4)",
|
||||
)
|
||||
.bind(&msg_uuid)
|
||||
.bind(&dst)
|
||||
.bind(env.encode_to_vec())
|
||||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel::<v1::ServerFrame>(16);
|
||||
assert_eq!(
|
||||
state.flush_queue(&dst, &tx).await.unwrap(),
|
||||
0,
|
||||
"vyčerpaná obálka se nesmí znovu poslat"
|
||||
);
|
||||
drop(rx);
|
||||
|
||||
let zbylo: (i64,) =
|
||||
sqlx::query_as("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&dst)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(zbylo.0, 0, "vyčerpaná obálka měla být zahozena");
|
||||
}
|
||||
|
||||
/// Audit #3: FIFO strop zahodí nejstarší přes limit, nejnovější nechá.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn trim_queue_fifo_zahodi_nejstarsi() {
|
||||
let state = test_state().await;
|
||||
let dst = rand_bytes(32);
|
||||
// 5 obálek po 100 B, rostoucí enqueued_at. Strop 250 B → nejnovější
|
||||
// dvě (2×100 ≤ 250) zůstanou, tři nejstarší padnou.
|
||||
let mut uuids = Vec::new();
|
||||
for i in 0..5 {
|
||||
let msg_uuid = rand_bytes(16);
|
||||
uuids.push(msg_uuid.clone());
|
||||
sqlx::query(
|
||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
|
||||
VALUES ($1, $2, $3, now() + (interval '1 second' * $4), now() + interval '30 days')",
|
||||
)
|
||||
.bind(&msg_uuid)
|
||||
.bind(&dst)
|
||||
.bind(vec![0u8; 100])
|
||||
.bind(i as i32)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let dropped = state.trim_queue(&dst, 250).await.unwrap();
|
||||
assert_eq!(dropped, 3);
|
||||
|
||||
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
||||
"SELECT msg_uuid FROM message_queue WHERE dst_address = $1 ORDER BY enqueued_at",
|
||||
)
|
||||
.bind(&dst)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, vec![uuids[3].clone(), uuids[4].clone()]);
|
||||
|
||||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&dst)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+66
-33
@@ -12,6 +12,7 @@ use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{ConnectInfo, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
@@ -26,38 +27,26 @@ use tokio::time::Instant;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Max souběžných WS spojení z jedné IP (za traefikem = reálná klientská
|
||||
/// IP z X-Real-Ip; tor kontejner sdílí jednu — proto ne moc těsně).
|
||||
/// Max souběžných WS spojení z jedné IP. Klientská IP jde přes trusted-proxy
|
||||
/// logiku (viz net.rs): za traefikem reálná IP z forwarded hlavičky, onion
|
||||
/// klienti sdílí IP tor kontejneru (jeden bucket) — proto ne moc těsně.
|
||||
const MAX_CONNS_PER_IP: u32 = 32;
|
||||
/// Neautentizované spojení zavřít po tomto čase.
|
||||
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
||||
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
|
||||
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
||||
const FRAME_WINDOW_MAX: u32 = 200;
|
||||
|
||||
/// Klientská IP: hlavičky od trusted proxy (na relay dosáhne jen traefik
|
||||
/// z docker sítě), fallback peer adresa (dev/tor).
|
||||
/// Jak často server posílá WS Ping autentizovanému klientovi.
|
||||
///
|
||||
/// TRUST MODEL — POZOR: X-Real-Ip / X-Forwarded-For se přebírají bezpodmínečně
|
||||
/// a řídí per-IP limity (cap spojení, rate limit). To je bezpečné VÝHRADNĚ
|
||||
/// proto, že před relayem stojí důvěryhodná reverzní proxy (traefik), která
|
||||
/// tyhle hlavičky vždy přepisuje reálnou klientskou IP. Klientem poslané
|
||||
/// hodnoty tak nikdy neprojdou. Kdyby byl relay vystaven přímo do internetu
|
||||
/// (bez proxy, která hlavičky sanitizuje), útočník by je mohl libovolně
|
||||
/// podvrhnout, tím rozprostřít provoz přes falešné IP a všechny per-IP limity
|
||||
/// obejít. Relay proto NIKDY nenasazovat bez takové proxy.
|
||||
fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
let from_header = |name: &str| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.and_then(|v| v.trim().parse::<IpAddr>().ok())
|
||||
};
|
||||
from_header("x-real-ip")
|
||||
.or_else(|| from_header("x-forwarded-for"))
|
||||
.unwrap_or_else(|| peer.ip())
|
||||
}
|
||||
/// Mobilní NAT zahazuje nečinné TCP tiše — bez keepalive se ani jedna strana
|
||||
/// nedozví, že spojení je mrtvé: klient hlásí „připojeno" a píše do prázdna,
|
||||
/// relay drží záznam v `online` a proto ani nepošle push budíček. Ping je
|
||||
/// PROTOKOLOVÝ (ne `v1::Ping`), protože na ten dart:io odpovídá Pongem sám —
|
||||
/// funguje tedy i s klienty, které jsou už venku a aplikační Ping neznají.
|
||||
const PING_INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// Bez jediného rámce od klienta (stačí Pong) je spojení mrtvé. Tři zmeškané
|
||||
/// pingy — velkoryse, ať krátký výpadek mobilní sítě spojení nezabije.
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(95);
|
||||
|
||||
/// Drop-guard: úbytek čítače spojení i při panice/cancelu handleru.
|
||||
struct ConnGuard {
|
||||
@@ -80,7 +69,7 @@ pub async fn ws_handler(
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl IntoResponse {
|
||||
let ip = client_ip(&headers, peer);
|
||||
let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
|
||||
|
||||
let over_cap = {
|
||||
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
||||
@@ -134,11 +123,28 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(64);
|
||||
|
||||
// Writer drží sink sám, takže si i pinguje — jinak by control rámce
|
||||
// musely téct přes `tx`, který nese jen aplikační ServerFrame.
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(frame) = rx.recv().await {
|
||||
let bytes = frame.encode_to_vec();
|
||||
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
||||
break;
|
||||
let mut ping = tokio::time::interval(PING_INTERVAL);
|
||||
// Zpožděné ticky se nedohánějí — po pauze chceme jeden ping, ne dávku.
|
||||
ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
ping.tick().await; // první tick padne hned, ten nechceme
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = rx.recv() => {
|
||||
let Some(frame) = frame else { break };
|
||||
let bytes = frame.encode_to_vec();
|
||||
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = ping.tick() => {
|
||||
if sink.send(WsMessage::Ping(Bytes::new())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -147,6 +153,9 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
let mut authed_address: Option<Vec<u8>> = None;
|
||||
// device_uuid ověřeného zařízení — klíč pro push registraci.
|
||||
let mut authed_device: Option<Vec<u8>> = None;
|
||||
// Záznam v `online` mapě — značkuje se každým rámcem od klienta, aby
|
||||
// routing poznal živé spojení od zatuchlého.
|
||||
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
||||
|
||||
// Anti-abuse okno: počítá VŠECHNY rámce od klienta (envelope/ack/pong)
|
||||
let mut window_start = Instant::now();
|
||||
@@ -163,10 +172,29 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stream.next().await
|
||||
// Po AUTH hlídá spojení idle timeout: klient, který dýchá, pošle
|
||||
// aspoň Pong na náš ping. Ticho = mrtvé spojení → zavřít, ať
|
||||
// záznam zmizí z `online` a další zpráva jde přes push budíček.
|
||||
// Bez tohohle tam zombie visel, dokud traefiku nedošel readTimeout
|
||||
// (30 min), a push se celou tu dobu vůbec nezkusil.
|
||||
match tokio::time::timeout(IDLE_TIMEOUT, stream.next()).await {
|
||||
Ok(next) => next,
|
||||
Err(_) => {
|
||||
// info, ne debug: relay běží na RUST_LOG=info a kolik
|
||||
// spojení umírá na ticho je provozní signál (kvalita sítě
|
||||
// klientů), ne ladicí detail. Bez identifikátoru zařízení.
|
||||
tracing::info!("ws zavřeno: klient neodpovídá na ping");
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(Ok(msg)) = next else { break };
|
||||
|
||||
// Cokoliv od klienta (i Pong na náš ping) = spojení dýchá.
|
||||
if let Some(dev) = &online_device {
|
||||
dev.touch(state.now_ms());
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if now.duration_since(window_start) > FRAME_WINDOW {
|
||||
window_start = now;
|
||||
@@ -242,9 +270,14 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
break;
|
||||
}
|
||||
|
||||
let device = Arc::new(crate::state::OnlineDevice::new(
|
||||
tx.clone(),
|
||||
state.now_ms(),
|
||||
));
|
||||
state
|
||||
.online
|
||||
.insert(pd.delivery_address.clone(), tx.clone());
|
||||
.insert(pd.delivery_address.clone(), device.clone());
|
||||
online_device = Some(device);
|
||||
authed_address = Some(pd.delivery_address.clone());
|
||||
|
||||
// Potvrzení AUTH — klient až teď smí hlásit „připojeno"
|
||||
@@ -254,7 +287,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
)))
|
||||
.await;
|
||||
|
||||
// last_seen jen v hrubé granularitě (privacy — zaokrouhleno na hodinu).
|
||||
// last_seen jen v hrubé granularitě (privacy, §1.2).
|
||||
let _ = sqlx::query(
|
||||
"UPDATE devices SET last_seen_at = date_trunc('hour', now())
|
||||
WHERE device_uuid = $1",
|
||||
|
||||
Reference in New Issue
Block a user