fix: reliable first-message send, QR routing, auto-lock recovery

Fixes reported by testers and found in an app review:

- First message right after install no longer fails. Before the app
  finished registering with the relay, the outgoing message could crash on
  a null delivery address. The profile field is now optional and the send
  path throws a clear "still connecting, try again shortly" error while
  registration completes in the background.
- The QR code and share link now appear only once the delivery address is
  ready. Previously a link generated before the first connection lacked
  routing, so the other side could not send the first message. The screen
  now shows a connecting state and reactively swaps in the QR when ready.
- Incomplete contact links are rejected on import instead of failing at
  first send, with a clear prompt to ask for a fresh link.
- Auto-lock no longer silences the app: after locking and unlocking,
  messaging is restarted (it previously stayed dead until app restart).
- Group messages are marked failed on a non-per-member send error instead
  of hanging in the "sending" state forever.
- Hardened the relay client against a rare duplicate connection when
  toggling Tor or locking mid-connect, and fromShareString now rejects a
  delivery address of the wrong length.
- Pin unifiedpush below 6.2.0 to drop the webcrypto dependency (its native
  framework lacks LC_ENCRYPTION_INFO and blocked the iOS upload).
- Skip Android release lint (network dependency check hangs on the build
  host and adds no value for a Flutter app).
This commit is contained in:
Julius Joska
2026-07-18 19:53:41 +02:00
parent 7d7f589fc5
commit 90a1390716
29 changed files with 403 additions and 162 deletions
+5 -1
View File
@@ -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');
}
+18
View File
@@ -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);
+2
View File
@@ -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,
+17 -2
View File
@@ -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,
+59 -32
View File
@@ -496,10 +496,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 +698,30 @@ 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,
'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 +754,28 @@ 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,
'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).
@@ -1009,6 +1029,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;
@@ -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(() {
@@ -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,
),
),
],
),
),
);
}
}
+16
View File
@@ -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.",
@@ -1419,6 +1427,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)"
+4
View File
@@ -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",
@@ -294,6 +296,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",
+4
View File
@@ -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",
@@ -294,6 +296,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",
+4
View File
@@ -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",
@@ -294,6 +296,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",
+4
View File
@@ -75,6 +75,8 @@
"chatEdited": "изменено",
"errorTrustRootChanged": "Trust root релея изменился! Соединение отклонено.",
"errorRelayUnavailable": "Релей недоступен (sender certificate)",
"errorNotYetRegistered": "Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.",
"errorContactLinkIncomplete": "Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.",
"errorAttachmentTooLarge": "Вложение слишком большое (макс. {maxMb} МБ)",
"errorGroupNeedsMember": "В группе должен быть хотя бы 1 участник",
"errorGroupMaxMembers": "Максимальное число участников группы — {maxMembers}",
@@ -294,6 +296,8 @@
"chatListArchive": "Архивировать",
"qrShareTitle": "Мой Šepot ID",
"qrIdentityNotFound": "Идентичность не найдена",
"qrShareConnectingTitle": "Подключение к релею…",
"qrShareConnectingBody": "Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.",
"commonCopiedToClipboard": "Скопировано в буфер обмена",
"qrShareInviteMessage": "Добавьте меня в Šepot!\n\n{shareString}",
"commonShare": "Поделиться",
@@ -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:
@@ -1862,6 +1874,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:
@@ -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)';
@@ -1048,6 +1056,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';
@@ -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)';
@@ -1067,6 +1075,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';
@@ -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)';
@@ -1044,6 +1052,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';
@@ -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)';
@@ -1055,6 +1063,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';
@@ -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 МБ)';
@@ -1072,6 +1080,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 => 'Идентичность не найдена';
+9
View File
@@ -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();
@@ -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;