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

@ -7,7 +7,11 @@ import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/services/discovery_area.dart';
import 'package:tane/services/offer_mapper.dart';
import 'package:nostr/nostr.dart';
import 'package:tane/services/offer_outbox.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:tane/state/offers_cubit.dart';
import '../support/test_support.dart';
@ -835,4 +839,111 @@ void main() {
await cubit.close();
});
});
group('connection recovery (fresh-install fix)', () {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
late SocialService social;
late SocialSettings settings;
setUp(() async {
social = await SocialService.fromRootSeedHex(seedHex);
settings = SocialSettings(InMemorySecretStore());
});
SocialConnection makeConnection({
required bool Function() down,
Stream<bool>? online,
}) =>
SocialConnection(
social: social,
settings: settings,
online: online ?? const Stream.empty(),
retrySchedule: const [Duration(milliseconds: 5)],
open: (_) async {
if (down()) throw StateError('unreachable');
return SocialSession(_NoopChannel());
},
);
test('comes online by itself when the shared connection connects later',
() async {
var down = true;
final conn = makeConnection(down: () => down);
final cubit = await createOffersCubit(conn);
expect(cubit.isOnline, isFalse); // built while unreachable
final epochBefore = cubit.state.connectionEpoch;
conn.start();
down = false; // network path recovers; backoff retry reconnects
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(cubit.isOnline, isTrue, reason: 'no manual Retry needed');
expect(cubit.state.connectionEpoch, greaterThan(epochBefore),
reason: 'a state was emitted so the UI re-reads isOnline');
await cubit.close();
await conn.dispose();
});
test('re-runs the last discovery when the connection recovers', () async {
var down = true;
final conn = makeConnection(down: () => down);
final cubit = await createOffersCubit(conn);
await cubit.discover('sp3e9'); // offline error, but the wish is kept
expect(cubit.state.error, 'offline');
conn.start();
down = false;
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(cubit.isOnline, isTrue);
expect(cubit.state.areaGeohash, 'sp3e9', reason: 'discovery re-ran');
expect(cubit.state.hasSearched, isTrue);
expect(cubit.state.error, isNull, reason: 'offline error cleared');
await cubit.close();
await conn.dispose();
});
test('goes offline (and tells the UI) when the session drops', () async {
final online = StreamController<bool>.broadcast();
final conn = makeConnection(down: () => false, online: online.stream);
conn.start();
await pumpEventQueue();
final cubit = await createOffersCubit(conn);
expect(cubit.isOnline, isTrue);
final epochBefore = cubit.state.connectionEpoch;
online.add(false); // network lost
await pumpEventQueue();
expect(cubit.isOnline, isFalse);
expect(cubit.state.connectionEpoch, greaterThan(epochBefore));
await cubit.close();
await conn.dispose();
await online.close();
});
});
}
/// A no-op [NostrChannel]: just enough to build a [SocialSession] whose offer
/// transport answers with empty results.
class _NoopChannel implements NostrChannel {
@override
String get privateKeyHex => '00' * 32;
@override
String get publicKeyHex => 'ab' * 32;
@override
Future<({bool accepted, String message})> publish(Event event) async =>
(accepted: true, message: '');
@override
Stream<Event> subscribe(Filter filter) => const Stream.empty();
@override
Future<List<Event>> reqOnce(Filter filter) async => const [];
@override
Future<void> close() async {}
}