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
+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;