fix(market): recover by itself when the shared connection comes up

Fresh installs could sit on 'can't reach the servers' forever: the offers
cubit captured the transport once at build time, the shared connection only
retried on a connectivity CHANGE, and a silently-filtered relay could stall
the pool for minutes.

- SocialConnection: retry with backoff after a failed attempt while started
  and not knowingly offline (injectable schedule for tests)
- OffersCubit: follow connection.sessions, re-attach the transport and re-run
  the last discovery on (re)connect; announce drops via connectionEpoch
- market _init: record the wanted area on the cubit even while offline
- NostrOfferTransport.discoverPage: sort a copy (channel lists may be
  unmodifiable)
This commit is contained in:
vjrj 2026-07-22 12:57:24 +02:00
parent 981cf10048
commit 8a7dc8d3dc
6 changed files with 273 additions and 9 deletions

View file

@ -25,19 +25,36 @@ class SocialConnection {
required SocialSettings settings,
SessionOpener? open,
Stream<bool>? online,
List<Duration>? retrySchedule,
}) : _settings = settings,
_open = open ?? social.openSession,
_online = online;
_online = online,
_retrySchedule = retrySchedule ?? _defaultRetrySchedule;
/// Backoff for self-retries after a failed attempt while started and not
/// knowingly offline (the fresh-install case: online the whole time, first
/// connect fails, so no connectivity change ever retriggers a connect).
static const _defaultRetrySchedule = [
Duration(seconds: 5),
Duration(seconds: 15),
Duration(seconds: 45),
Duration(seconds: 90),
];
final SocialSettings _settings;
final SessionOpener _open;
final Stream<bool>? _online;
final List<Duration> _retrySchedule;
final _sessions = StreamController<SocialSession?>.broadcast();
SocialSession? _current;
Future<SocialSession?>? _pending;
StreamSubscription<bool>? _onlineSub;
bool _disposed = false;
bool _started = false;
bool _knownOffline = false;
Timer? _retryTimer;
int _retryIndex = 0;
/// Emits the live session on each (re)connect, and null when it drops.
Stream<SocialSession?> get sessions => _sessions.stream;
@ -48,10 +65,14 @@ class SocialConnection {
/// Begins watching connectivity (reconnect on regain, drop when offline) and
/// attempts an initial connect. Idempotent-ish; call once at startup.
void start() {
_started = true;
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
_knownOffline = !isOnline;
if (!isOnline) {
_cancelRetry();
_drop();
} else if (_current == null) {
_retryIndex = 0; // fresh network start the backoff over
unawaited(session());
}
});
@ -75,15 +96,40 @@ class SocialConnection {
return null;
}
_current = s;
_retryIndex = 0;
_cancelRetry();
_sessions.add(s);
return s;
} catch (_) {
return null; // unreachable a later connectivity change retries
_scheduleRetry(); // unreachable retry with backoff (see below)
return null;
} finally {
_pending = null;
}
}
/// After a failed attempt, retries by itself while started and not knowingly
/// offline a connectivity change may never come if the device was online
/// all along. Un-started connections (one-shot [session] callers, tests)
/// never leave a timer behind.
void _scheduleRetry() {
if (!_started || _disposed || _knownOffline || _current != null) return;
_cancelRetry();
final i = _retryIndex < _retrySchedule.length
? _retryIndex
: _retrySchedule.length - 1;
_retryIndex++;
_retryTimer = Timer(_retrySchedule[i], () {
if (_disposed || _current != null) return;
unawaited(session());
});
}
void _cancelRetry() {
_retryTimer?.cancel();
_retryTimer = null;
}
void _drop() {
final s = _current;
_current = null;
@ -95,6 +141,7 @@ class SocialConnection {
Future<void> dispose() async {
_disposed = true;
_cancelRetry();
await _onlineSub?.cancel();
_onlineSub = null;
_drop();

View file

@ -10,6 +10,7 @@ import '../services/offer_mapper.dart';
import '../services/offer_outbox.dart';
import '../services/offer_thumbnail.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
/// State of the offer discovery/publish screen. Transport-agnostic it holds
/// only what the UI shows, never a relay handle.
@ -28,6 +29,7 @@ class OffersState extends Equatable {
this.nextPageCursor,
this.publishing = false,
this.hasSearched = false,
this.connectionEpoch = 0,
this.error,
});
@ -80,6 +82,11 @@ class OffersState extends Equatable {
/// from "searched, found nothing".
final bool hasSearched;
/// Bumped whenever the underlying transport comes or goes, so the UI rebuilds
/// and re-reads [OffersCubit.isOnline] states differing only here are
/// otherwise Equatable-equal and bloc would skip the emit.
final int connectionEpoch;
/// Last error, in human terms for the UI (null when fine).
final String? error;
@ -136,6 +143,7 @@ class OffersState extends Equatable {
int? Function()? nextPageCursor,
bool? publishing,
bool? hasSearched,
int? connectionEpoch,
String? Function()? error,
}) {
return OffersState(
@ -153,6 +161,7 @@ class OffersState extends Equatable {
nextPageCursor != null ? nextPageCursor() : this.nextPageCursor,
publishing: publishing ?? this.publishing,
hasSearched: hasSearched ?? this.hasSearched,
connectionEpoch: connectionEpoch ?? this.connectionEpoch,
error: error != null ? error() : this.error,
);
}
@ -172,6 +181,7 @@ class OffersState extends Equatable {
nextPageCursor,
publishing,
hasSearched,
connectionEpoch,
error,
];
}
@ -183,15 +193,40 @@ class OffersState extends Equatable {
class OffersCubit extends Cubit<OffersState> {
OffersCubit(
this._transport, {
SocialConnection? connection,
Future<Uint8List?> Function(String varietyId)? coverPhoto,
String? Function(Uint8List bytes)? thumbnail,
Future<void> Function()? onDispose,
}) : _coverPhoto = coverPhoto,
_thumbnail = thumbnail,
_onDispose = onDispose,
super(const OffersState());
super(const OffersState()) {
// Fresh-install fix: the transport captured at build time may be null while
// the shared connection is still coming up (or dropped). Follow the
// connection so the market recovers by itself instead of staying offline
// until a manual retry.
_connSub = connection?.sessions.listen(_onSession);
}
final OfferTransport? _transport;
OfferTransport? _transport;
StreamSubscription<SocialSession?>? _connSub;
/// The last area prefix asked of [discover]; re-run when the connection
/// (re)appears so results show without the user tapping anything.
String? _lastPrefix;
void _onSession(SocialSession? session) {
if (isClosed) return;
final transport = session?.offers;
if (identical(transport, _transport)) return;
_transport = transport;
final prefix = _lastPrefix;
if (transport != null && prefix != null) {
unawaited(discover(prefix)); // emits fresh states as results arrive
} else {
emit(state.copyWith(connectionEpoch: state.connectionEpoch + 1));
}
}
/// Fetches a variety's cover photo bytes; null in tests or when no inventory
/// repo is wired.
@ -225,6 +260,7 @@ class OffersCubit extends Cubit<OffersState> {
/// from now on older results arrive via [loadNextPage], not by draining the
/// whole area into memory.
Future<void> discover(String geohashPrefix) async {
_lastPrefix = geohashPrefix;
final transport = _transport;
if (transport == null) {
emit(state.copyWith(error: () => 'offline', hasSearched: true));
@ -467,6 +503,7 @@ class OffersCubit extends Cubit<OffersState> {
@override
Future<void> close() async {
_searchTimeout?.cancel();
await _connSub?.cancel();
await _sub?.cancel();
await _onDispose?.call();
return super.close();
@ -484,6 +521,7 @@ Future<OffersCubit> createOffersCubit(
final session = await connection.session();
return OffersCubit(
session?.offers,
connection: connection, // keeps following (re)connects see _onSession
coverPhoto: repository?.coverPhotoFor,
thumbnail: offerThumbnailDataUri,
);

View file

@ -122,10 +122,10 @@ class _MarketScreenState extends State<MarketScreen> {
_loading = false;
});
await previous?.close();
if (area.isNotEmpty && cubit.isOnline) {
if (area.isNotEmpty) {
// Flush anything parked while offline, then show what's out there.
final outbox = widget.outbox;
if (outbox != null && repo != null) {
if (outbox != null && repo != null && cubit.isOnline) {
await flushOutbox(
outbox: outbox,
cubit: cubit,
@ -136,6 +136,8 @@ class _MarketScreenState extends State<MarketScreen> {
}
if (mounted) {
final precision = await widget.settings.searchPrecision();
// Even when offline: this records the wanted area on the cubit, which
// re-runs the discovery by itself once the connection comes up.
if (mounted) await cubit.discover(searchPrefix(area, precision));
}
}