refactor(social): one shared relay connection per identity
Every feature (offers, messaging, trust, profile, the inbox listener) used to open its OWN RelayPool via social.openSession — several sockets to the same relays, more battery, and reconnection logic living only in the inbox listener. Add SocialConnection: ONE shared session per identity, lazily connected and reused by all. It watches connectivity — dropping the session when the network goes and reconnecting when it returns, announcing each change on a sessions stream so the inbox listener re-subscribes automatically. Callers get the session via connection.session() and never close it (the connection owns its lifecycle); recreated/disposed on an identity switch. - InboxService now consumes the shared connection (subscribes on its sessions stream) instead of owning its own socket + connectivity code. - createOffersCubit + the chat/market/profile screens use the shared connection; dead createMessagesCubit/createTrustCubit factories removed. - DI registers SocialConnection per identity; Bootstrap starts it (after the inbox subscribes); switchSocialAccount disposes + recreates it. Tests: SocialConnection lifecycle (reuse, concurrent connect, offline drop + reconnect, unreachable retry) with a fake opener + online stream; inbox test updated. nostr added as a dev_dependency for the channel fake.
This commit is contained in:
parent
e2b88b4f26
commit
bb4ee2fd89
19 changed files with 431 additions and 280 deletions
|
|
@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:tane/services/inbox_service.dart';
|
||||
import 'package:tane/services/message_store.dart';
|
||||
import 'package:tane/services/notification_service.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/services/unread_service.dart';
|
||||
|
|
@ -26,21 +27,29 @@ class _RecordingNotifications extends NotificationService {
|
|||
}
|
||||
}
|
||||
|
||||
/// The app-wide inbox listener persists incoming messages and announces changes,
|
||||
/// so the inbox list refreshes even when the specific chat isn't open. Driven
|
||||
/// through the [InboxService.ingest] seam so no relay/network is involved.
|
||||
/// The app-wide inbox listener persists incoming messages, updates unread and
|
||||
/// notifies — even when the specific chat isn't open. Driven through the
|
||||
/// [InboxService.ingest] seam so no relay/network is involved.
|
||||
void main() {
|
||||
const seedHex =
|
||||
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
||||
|
||||
// A connection that never actually opens — ingest doesn't use it.
|
||||
Future<SocialConnection> offlineConnection() async => SocialConnection(
|
||||
social: await SocialService.fromRootSeedHex(seedHex),
|
||||
settings: SocialSettings(InMemorySecretStore()),
|
||||
open: (_) async => throw StateError('unused in ingest tests'),
|
||||
online: const Stream.empty(),
|
||||
);
|
||||
|
||||
late MessageStore store;
|
||||
late InboxService inbox;
|
||||
|
||||
setUp(() async {
|
||||
store = MessageStore(InMemorySecretStore());
|
||||
inbox = InboxService(
|
||||
social: await SocialService.fromRootSeedHex(seedHex),
|
||||
settings: SocialSettings(InMemorySecretStore()),
|
||||
connection: await offlineConnection(),
|
||||
selfPubkey: 'me',
|
||||
store: store,
|
||||
);
|
||||
});
|
||||
|
|
@ -75,16 +84,14 @@ void main() {
|
|||
group('unread + notification hooks', () {
|
||||
late UnreadService unread;
|
||||
late _RecordingNotifications notifications;
|
||||
late String myPubkey;
|
||||
const myPubkey = 'me';
|
||||
|
||||
setUp(() async {
|
||||
final social = await SocialService.fromRootSeedHex(seedHex);
|
||||
myPubkey = social.publicKeyHex;
|
||||
unread = UnreadService(store, InMemorySecretStore());
|
||||
notifications = _RecordingNotifications();
|
||||
inbox = InboxService(
|
||||
social: social,
|
||||
settings: SocialSettings(InMemorySecretStore()),
|
||||
connection: await offlineConnection(),
|
||||
selfPubkey: myPubkey,
|
||||
store: store,
|
||||
unread: unread,
|
||||
notifications: notifications,
|
||||
|
|
|
|||
119
apps/app_seeds/test/services/social_connection_test.dart
Normal file
119
apps/app_seeds/test/services/social_connection_test.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart';
|
||||
import 'package:tane/services/social_connection.dart';
|
||||
import 'package:tane/services/social_service.dart';
|
||||
import 'package:tane/services/social_settings.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
/// A no-op [NostrChannel] that only tracks whether it was closed — enough to
|
||||
/// build a [SocialSession] and assert the connection's lifecycle.
|
||||
class FakeChannel implements NostrChannel {
|
||||
bool closed = false;
|
||||
|
||||
@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 => closed = true;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const seedHex =
|
||||
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
||||
|
||||
late SocialService social;
|
||||
late SocialSettings settings; // relayUrls() falls back to defaults (non-empty)
|
||||
|
||||
setUp(() async {
|
||||
social = await SocialService.fromRootSeedHex(seedHex);
|
||||
settings = SocialSettings(InMemorySecretStore());
|
||||
});
|
||||
|
||||
SocialConnection make({
|
||||
required List<FakeChannel> opened,
|
||||
Stream<bool>? online,
|
||||
bool Function()? fail,
|
||||
}) =>
|
||||
SocialConnection(
|
||||
social: social,
|
||||
settings: settings,
|
||||
online: online,
|
||||
open: (_) async {
|
||||
if (fail?.call() ?? false) throw StateError('unreachable');
|
||||
final ch = FakeChannel();
|
||||
opened.add(ch);
|
||||
return SocialSession(ch);
|
||||
},
|
||||
);
|
||||
|
||||
test('connects once and reuses the shared session', () async {
|
||||
final opened = <FakeChannel>[];
|
||||
final conn = make(opened: opened);
|
||||
final a = await conn.session();
|
||||
final b = await conn.session();
|
||||
expect(a, isNotNull);
|
||||
expect(identical(a, b), isTrue); // same shared instance
|
||||
expect(opened, hasLength(1)); // only one connection opened
|
||||
await conn.dispose();
|
||||
});
|
||||
|
||||
test('concurrent callers share a single connect', () async {
|
||||
final opened = <FakeChannel>[];
|
||||
final conn = make(opened: opened);
|
||||
final results = await Future.wait([conn.session(), conn.session()]);
|
||||
expect(identical(results[0], results[1]), isTrue);
|
||||
expect(opened, hasLength(1));
|
||||
await conn.dispose();
|
||||
});
|
||||
|
||||
test('drops when offline and reconnects when back online', () async {
|
||||
final opened = <FakeChannel>[];
|
||||
final online = StreamController<bool>.broadcast();
|
||||
final conn = make(opened: opened, online: online.stream);
|
||||
final emitted = <SocialSession?>[];
|
||||
conn.sessions.listen(emitted.add);
|
||||
|
||||
conn.start(); // watch connectivity + initial connect
|
||||
final first = await conn.session();
|
||||
expect(first, isNotNull);
|
||||
expect(opened, hasLength(1));
|
||||
|
||||
online.add(false); // network lost
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(conn.current, isNull);
|
||||
expect(opened.first.closed, isTrue); // old session closed
|
||||
expect(emitted.last, isNull); // announced the drop
|
||||
|
||||
online.add(true); // network back
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(conn.current, isNotNull);
|
||||
expect(opened, hasLength(2)); // reconnected
|
||||
expect(identical(emitted.last, conn.current), isTrue);
|
||||
|
||||
await conn.dispose();
|
||||
await online.close();
|
||||
});
|
||||
|
||||
test('returns null when the relay is unreachable, and retries later',
|
||||
() async {
|
||||
final opened = <FakeChannel>[];
|
||||
var down = true;
|
||||
final conn = make(opened: opened, fail: () => down);
|
||||
expect(await conn.session(), isNull); // unreachable now
|
||||
down = false;
|
||||
expect(await conn.session(), isNotNull); // succeeds on retry
|
||||
await conn.dispose();
|
||||
});
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/i18n/strings.g.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/ui/chat_screen.dart';
|
||||
|
|
@ -14,6 +15,7 @@ void main() {
|
|||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||
final settings = SocialSettings(InMemorySecretStore());
|
||||
await settings.setRelayUrls(const []); // offline: don't hit the network
|
||||
final connection = SocialConnection(social: social, settings: settings);
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
|
||||
await tester.pumpWidget(
|
||||
|
|
@ -28,7 +30,7 @@ void main() {
|
|||
],
|
||||
home: ChatScreen(
|
||||
social: social,
|
||||
settings: settings,
|
||||
connection: connection,
|
||||
peerPubkey: 'ab' * 32,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/i18n/strings.g.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/ui/market_offer_detail_screen.dart';
|
||||
|
|
@ -43,11 +44,11 @@ void main() {
|
|||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||
final settings = SocialSettings(InMemorySecretStore());
|
||||
await settings.setRelayUrls(const []); // offline: no network in the test
|
||||
final connection = SocialConnection(social: social, settings: settings);
|
||||
|
||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||
offer: _offer(organic: true),
|
||||
social: social,
|
||||
settings: settings,
|
||||
connection: connection,
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
|
@ -61,11 +62,11 @@ void main() {
|
|||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||
final settings = SocialSettings(InMemorySecretStore());
|
||||
await settings.setRelayUrls(const []);
|
||||
final connection = SocialConnection(social: social, settings: settings);
|
||||
|
||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||
offer: _offer(),
|
||||
social: social,
|
||||
settings: settings,
|
||||
connection: connection,
|
||||
mine: true,
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -77,11 +78,11 @@ void main() {
|
|||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||
final settings = SocialSettings(InMemorySecretStore());
|
||||
await settings.setRelayUrls(const []);
|
||||
final connection = SocialConnection(social: social, settings: settings);
|
||||
|
||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||
offer: _offer(imageUrl: 'https://media.example/abc.jpg'),
|
||||
social: social,
|
||||
settings: settings,
|
||||
connection: connection,
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
|
@ -92,11 +93,11 @@ void main() {
|
|||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||
final settings = SocialSettings(InMemorySecretStore());
|
||||
await settings.setRelayUrls(const []);
|
||||
final connection = SocialConnection(social: social, settings: settings);
|
||||
|
||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||
offer: _offer(),
|
||||
social: social,
|
||||
settings: settings,
|
||||
connection: connection,
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/i18n/strings.g.dart';
|
||||
import 'package:tane/services/coarse_location.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';
|
||||
|
|
@ -60,7 +61,12 @@ Widget _wrapMarket(SocialService social, SocialSettings settings,
|
|||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: MarketScreen(social: social, settings: settings, location: location),
|
||||
home: MarketScreen(
|
||||
social: social,
|
||||
settings: settings,
|
||||
connection: SocialConnection(social: social, settings: settings),
|
||||
location: location,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue