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:
vjrj 2026-07-10 21:41:17 +02:00
parent e2b88b4f26
commit bb4ee2fd89
19 changed files with 431 additions and 280 deletions

View file

@ -18,6 +18,7 @@ import 'services/onboarding_store.dart';
import 'services/profile_cache.dart'; import 'services/profile_cache.dart';
import 'services/profile_store.dart'; import 'services/profile_store.dart';
import 'services/social_account_store.dart'; import 'services/social_account_store.dart';
import 'services/social_connection.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
import 'services/social_settings.dart'; import 'services/social_settings.dart';
import 'services/trust_referents.dart'; import 'services/trust_referents.dart';
@ -50,6 +51,7 @@ class TaneApp extends StatelessWidget {
required this.onboarding, required this.onboarding,
this.social, this.social,
this.socialSettings, this.socialSettings,
this.connection,
this.location, this.location,
this.outbox, this.outbox,
this.messageStore, this.messageStore,
@ -69,6 +71,7 @@ class TaneApp extends StatelessWidget {
showIntro, showIntro,
social, social,
socialSettings, socialSettings,
connection,
location, location,
outbox, outbox,
messageStore, messageStore,
@ -94,6 +97,9 @@ class TaneApp extends StatelessWidget {
final SocialService? social; final SocialService? social;
final SocialSettings? socialSettings; final SocialSettings? socialSettings;
/// The shared relay connection (one per identity) reused by every feature.
final SocialConnection? connection;
/// Optional device-location source for the market's "use my location". /// Optional device-location source for the market's "use my location".
final CoarseLocationProvider? location; final CoarseLocationProvider? location;
@ -135,6 +141,7 @@ class TaneApp extends StatelessWidget {
bool showIntro, bool showIntro,
SocialService? social, SocialService? social,
SocialSettings? socialSettings, SocialSettings? socialSettings,
SocialConnection? connection,
CoarseLocationProvider? location, CoarseLocationProvider? location,
OfferOutbox? outbox, OfferOutbox? outbox,
MessageStore? messageStore, MessageStore? messageStore,
@ -153,50 +160,49 @@ class TaneApp extends StatelessWidget {
builder: (context, state) => builder: (context, state) =>
HomeScreen(marketEnabled: social != null), HomeScreen(marketEnabled: social != null),
), ),
if (social != null && socialSettings != null) if (social != null && socialSettings != null && connection != null)
GoRoute( GoRoute(
path: '/market', path: '/market',
builder: (context, state) => MarketScreen( builder: (context, state) => MarketScreen(
social: social, social: social,
settings: socialSettings, settings: socialSettings,
connection: connection,
location: location, location: location,
outbox: outbox, outbox: outbox,
), ),
), ),
if (social != null && socialSettings != null) if (social != null && connection != null)
GoRoute( GoRoute(
path: '/market/offer', path: '/market/offer',
builder: (context, state) { builder: (context, state) {
final offer = state.extra as Offer; final offer = state.extra as Offer;
return MarketOfferDetailScreen( return MarketOfferDetailScreen(
offer: offer, offer: offer,
social: social, connection: connection,
settings: socialSettings,
mine: offer.authorPubkeyHex == social.publicKeyHex, mine: offer.authorPubkeyHex == social.publicKeyHex,
profileCache: profileCache, profileCache: profileCache,
); );
}, },
), ),
if (social != null && socialSettings != null && messageStore != null) if (messageStore != null)
GoRoute( GoRoute(
path: '/messages', path: '/messages',
builder: (context, state) => ChatListScreen( builder: (context, state) => ChatListScreen(
store: messageStore, store: messageStore,
social: social, connection: connection,
settings: socialSettings,
profileCache: profileCache, profileCache: profileCache,
inbox: inbox, inbox: inbox,
), ),
), ),
if (social != null && if (social != null &&
socialSettings != null && connection != null &&
profileStore != null && profileStore != null &&
socialAccounts != null) socialAccounts != null)
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (context, state) => ProfileScreen( builder: (context, state) => ProfileScreen(
social: social, social: social,
settings: socialSettings, connection: connection,
profileStore: profileStore, profileStore: profileStore,
accounts: socialAccounts, accounts: socialAccounts,
trustNetworkEnabled: trustNetworkEnabled:
@ -211,12 +217,12 @@ class TaneApp extends StatelessWidget {
wotSettings: wotSettings, wotSettings: wotSettings,
), ),
), ),
if (social != null && socialSettings != null) if (social != null && connection != null)
GoRoute( GoRoute(
path: '/chat/:pubkey', path: '/chat/:pubkey',
builder: (context, state) => ChatScreen( builder: (context, state) => ChatScreen(
social: social, social: social,
settings: socialSettings, connection: connection,
peerPubkey: state.pathParameters['pubkey']!, peerPubkey: state.pathParameters['pubkey']!,
messageStore: messageStore, messageStore: messageStore,
profileCache: profileCache, profileCache: profileCache,

View file

@ -19,6 +19,7 @@ import 'services/onboarding_store.dart';
import 'services/profile_cache.dart'; import 'services/profile_cache.dart';
import 'services/profile_store.dart'; import 'services/profile_store.dart';
import 'services/social_account_store.dart'; import 'services/social_account_store.dart';
import 'services/social_connection.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
import 'services/social_settings.dart'; import 'services/social_settings.dart';
import 'services/trust_referents.dart'; import 'services/trust_referents.dart';
@ -53,6 +54,9 @@ class _BootstrapState extends State<Bootstrap> {
if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale); if (savedLocale != null) LocaleSettings.setLocaleSync(savedLocale);
final onboarding = getIt<OnboardingStore>(); final onboarding = getIt<OnboardingStore>();
final connection = getIt.isRegistered<SocialConnection>()
? getIt<SocialConnection>()
: null;
final inbox = final inbox =
getIt.isRegistered<InboxService>() ? getIt<InboxService>() : null; getIt.isRegistered<InboxService>() ? getIt<InboxService>() : null;
final notifications = getIt.isRegistered<NotificationService>() final notifications = getIt.isRegistered<NotificationService>()
@ -61,9 +65,10 @@ class _BootstrapState extends State<Bootstrap> {
// Ask for notification permission and set up the OS channel (no-op on // Ask for notification permission and set up the OS channel (no-op on
// unsupported platforms). Done before the inbox starts listening. // unsupported platforms). Done before the inbox starts listening.
if (notifications != null) await notifications.initialize(); if (notifications != null) await notifications.initialize();
// Listen for incoming private messages app-wide (foreground) so they arrive // Subscribe the inbox listener BEFORE the shared connection starts
// and fill the inbox even before their chat is opened. // connecting, so the first session is caught; then bring the connection up.
if (inbox != null) unawaited(inbox.start()); inbox?.start();
connection?.start();
return TaneApp( return TaneApp(
repository: getIt<VarietyRepository>(), repository: getIt<VarietyRepository>(),
@ -72,6 +77,7 @@ class _BootstrapState extends State<Bootstrap> {
social: social:
getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null, getIt.isRegistered<SocialService>() ? getIt<SocialService>() : null,
socialSettings: getIt<SocialSettings>(), socialSettings: getIt<SocialSettings>(),
connection: connection,
location: const GeolocatorCoarseLocation(), location: const GeolocatorCoarseLocation(),
outbox: getIt<OfferOutbox>(), outbox: getIt<OfferOutbox>(),
messageStore: getIt<MessageStore>(), messageStore: getIt<MessageStore>(),

View file

@ -37,6 +37,7 @@ import '../services/offer_outbox.dart';
import '../services/profile_cache.dart'; import '../services/profile_cache.dart';
import '../services/profile_store.dart'; import '../services/profile_store.dart';
import '../services/social_account_store.dart'; import '../services/social_account_store.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
import '../services/trust_referents.dart'; import '../services/trust_referents.dart';
@ -199,19 +200,25 @@ Future<void> configureDependencies() async {
// Optional: absent only if the derivation above failed then the app runs // Optional: absent only if the derivation above failed then the app runs
// inventory-only, by design (market/chat/profile hidden). // inventory-only, by design (market/chat/profile hidden).
if (socialService != null) { if (socialService != null) {
// ONE shared relay connection per identity, reused by every feature.
final connection = SocialConnection(
social: socialService,
settings: getIt<SocialSettings>(),
);
getIt getIt
..registerSingleton<SocialService>(socialService) ..registerSingleton<SocialService>(socialService)
..registerSingleton<SocialConnection>(connection)
// Tracks unread private messages so the UI can badge them. // Tracks unread private messages so the UI can badge them.
..registerSingleton<UnreadService>( ..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore), UnreadService(getIt<MessageStore>(), secretStore),
) )
// App-wide inbox listener so private messages arrive (and land in the // App-wide inbox listener over the shared connection, so private messages
// inbox list) even when the specific chat isn't open. Started in `main`. // arrive (and land in the inbox list) even when the chat isn't open.
// It also updates unread counts and fires notifications. // Started in `Bootstrap`; it also updates unread and fires notifications.
..registerSingleton<InboxService>( ..registerSingleton<InboxService>(
InboxService( InboxService(
social: socialService, connection: connection,
settings: getIt<SocialSettings>(), selfPubkey: socialService.publicKeyHex,
store: getIt<MessageStore>(), store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(), profileCache: getIt<ProfileCache>(),
unread: getIt<UnreadService>(), unread: getIt<UnreadService>(),
@ -238,11 +245,18 @@ Future<void> switchSocialAccount(int account) async {
final scope = socialAccountScope(account); final scope = socialAccountScope(account);
final rootSeedHex = await getIt<SecureKeyStore>().rootSeedHex(); final rootSeedHex = await getIt<SecureKeyStore>().rootSeedHex();
// Tear down the old identity's live listener/sessions before replacing. // Tear down the old identity's listener + shared connection before replacing.
if (getIt.isRegistered<InboxService>()) { if (getIt.isRegistered<InboxService>()) {
await getIt<InboxService>().stop(); await getIt<InboxService>().stop();
await getIt.unregister<InboxService>(); await getIt.unregister<InboxService>();
} }
if (getIt.isRegistered<SocialConnection>()) {
await getIt<SocialConnection>().dispose();
await getIt.unregister<SocialConnection>();
}
if (getIt.isRegistered<UnreadService>()) {
await getIt.unregister<UnreadService>();
}
if (getIt.isRegistered<SocialService>()) { if (getIt.isRegistered<SocialService>()) {
await getIt.unregister<SocialService>(); await getIt.unregister<SocialService>();
} }
@ -257,20 +271,29 @@ Future<void> switchSocialAccount(int account) async {
..registerSingleton<ProfileStore>( ..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope)) ProfileStore(secretStore, accountScope: scope))
..registerSingleton<ProfileCache>( ..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope)); ProfileCache(secretStore, accountScope: scope))
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore));
final social = final social =
await SocialService.fromRootSeedHex(rootSeedHex, account: account); await SocialService.fromRootSeedHex(rootSeedHex, account: account);
final connection =
SocialConnection(social: social, settings: getIt<SocialSettings>());
final inbox = InboxService( final inbox = InboxService(
social: social, connection: connection,
settings: getIt<SocialSettings>(), selfPubkey: social.publicKeyHex,
store: getIt<MessageStore>(), store: getIt<MessageStore>(),
profileCache: getIt<ProfileCache>(), profileCache: getIt<ProfileCache>(),
unread: getIt<UnreadService>(),
notifications: getIt<NotificationService>(),
); );
getIt getIt
..registerSingleton<SocialService>(social) ..registerSingleton<SocialService>(social)
..registerSingleton<SocialConnection>(connection)
..registerSingleton<InboxService>(inbox); ..registerSingleton<InboxService>(inbox);
unawaited(inbox.start()); // Subscribe the listener BEFORE the connection starts connecting.
inbox.start();
connection.start();
} }
Future<Directory> _backupsDir() async { Future<Directory> _backupsDir() async {

View file

@ -1,110 +1,89 @@
import 'dart:async'; import 'dart:async';
import 'package:commons_core/commons_core.dart'; import 'package:commons_core/commons_core.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import 'message_store.dart'; import 'message_store.dart';
import 'notification_service.dart'; import 'notification_service.dart';
import 'profile_cache.dart'; import 'profile_cache.dart';
import 'social_service.dart'; import 'social_connection.dart';
import 'social_settings.dart'; import 'social_service.dart' show SocialSession;
import 'unread_service.dart'; import 'unread_service.dart';
/// App-wide inbox listener for private messages (NIP-17). /// App-wide inbox listener for private messages (NIP-17).
/// ///
/// Without this, a message only arrived while its specific chat screen was /// Without this, a message only arrived while its specific chat screen was open
/// open so a first message from a new peer was invisible (nothing in local /// so a first message from a new peer was invisible. This keeps one inbox
/// storage, so nothing in the inbox list, so you never opened the chat, so you /// subscription over the SHARED [SocialConnection] for the whole app while
/// never subscribed). This keeps ONE long-lived subscription for the whole app /// online: it persists every incoming message to the [MessageStore], updates
/// while online: it persists every incoming message to the [MessageStore] and /// unread counts, fires an OS notification, and fires [changes] so the inbox
/// fires [changes] so the inbox list refreshes. Reconnects when the network /// list refreshes. It re-subscribes automatically each time the connection
/// returns; degrades to nothing offline (local-first). /// reconnects (and stops when it drops), because it listens to the connection's
/// session stream rather than owning a socket itself.
/// ///
/// Foreground only background/push delivery is a later concern. /// Foreground only background/push delivery is a later concern.
class InboxService { class InboxService {
InboxService({ InboxService({
required SocialService social, required SocialConnection connection,
required SocialSettings settings, required String selfPubkey,
required MessageStore store, required MessageStore store,
ProfileCache? profileCache, ProfileCache? profileCache,
UnreadService? unread, UnreadService? unread,
NotificationService? notifications, NotificationService? notifications,
}) : _social = social, }) : _connection = connection,
_settings = settings, _selfPubkey = selfPubkey,
_store = store, _store = store,
_profileCache = profileCache, _profileCache = profileCache,
_unread = unread, _unread = unread,
_notifications = notifications; _notifications = notifications;
final SocialService _social; final SocialConnection _connection;
final SocialSettings _settings; final String _selfPubkey;
final MessageStore _store; final MessageStore _store;
final ProfileCache? _profileCache; final ProfileCache? _profileCache;
final UnreadService? _unread; final UnreadService? _unread;
final NotificationService? _notifications; final NotificationService? _notifications;
final _changes = StreamController<void>.broadcast(); final _changes = StreamController<void>.broadcast();
SocialSession? _session; StreamSubscription<SocialSession?>? _sessionsSub;
StreamSubscription<PrivateMessage>? _inboxSub; StreamSubscription<PrivateMessage>? _inboxSub;
StreamSubscription<List<ConnectivityResult>>? _connSub; SocialSession? _session;
bool _started = false; bool _started = false;
bool _connecting = false;
/// Fires (with no payload) after a NEW message is persisted the inbox list /// Fires (no payload) after a NEW message is persisted the inbox list
/// listens to this to reload. Broadcast, so several screens can listen. /// listens to reload. Broadcast, so several screens can listen.
Stream<void> get changes => _changes.stream; Stream<void> get changes => _changes.stream;
/// Begins listening. Idempotent. Connects now if online, and (re)connects /// Begins listening. Subscribe to the connection's sessions BEFORE the
/// whenever the network returns. Safe to call once at app start. /// connection starts connecting, so the first session is caught too.
Future<void> start() async { void start() {
if (_started) return; if (_started) return;
_started = true; _started = true;
try { _sessionsSub = _connection.sessions.listen(_onSession);
_connSub = Connectivity().onConnectivityChanged.listen((results) { // In case the connection is already up (a screen connected first).
final offline = final current = _connection.current;
results.isEmpty || results.every((r) => r == ConnectivityResult.none); if (current != null) _onSession(current);
if (offline) {
_dropSession();
} else {
unawaited(_connect());
}
});
} catch (_) {
// Platform without connectivity support just try once below.
}
await _connect();
} }
/// Opens a session and subscribes to the inbox, if not already connected. /// (Re)binds the inbox subscription to [session] the connection handing us a
/// Guarded so overlapping connectivity events can't open two sessions. /// fresh session (reconnect) or null (dropped).
Future<void> _connect() async { void _onSession(SocialSession? session) {
if (_session != null || _connecting) return; // already (being) connected if (identical(session, _session) && _inboxSub != null) return;
_connecting = true; unawaited(_inboxSub?.cancel());
try { _inboxSub = null;
final relays = await _settings.relayUrls(); _session = session;
if (relays.isEmpty) return; // offline / unconfigured if (session != null) {
final session = await _social.openSession(relays);
if (!_started) {
await session.close(); // stopped while connecting
return;
}
_session = session;
_inboxSub = session.messages.inbox().listen( _inboxSub = session.messages.inbox().listen(
ingest, ingest,
onError: (_) => _dropSession(), // relay dropped retry on next change onError: (_) {}, // drop handled by the connection's reconnect
); );
} catch (_) {
// No relay reachable a later connectivity change retries.
} finally {
_connecting = false;
} }
} }
/// Persists one incoming [message] (deduped) and, when it's new, fires /// Persists one incoming [message] (deduped) and, when new, fires [changes],
/// [changes] and best-effort caches the sender's display name. Separated from /// updates unread, notifies, and best-effort caches the sender's name. A
/// the transport so it can be unit-tested without a relay. /// testable seam (no relay).
@visibleForTesting @visibleForTesting
Future<void> ingest(PrivateMessage message) async { Future<void> ingest(PrivateMessage message) async {
final stored = await _store.append(message.fromPubkey, message); final stored = await _store.append(message.fromPubkey, message);
@ -113,9 +92,8 @@ class InboxService {
final peer = message.fromPubkey; final peer = message.fromPubkey;
// Defensive: never badge or notify for your own message. NIP-17 doesn't // Defensive: never badge or notify for your own message. NIP-17 doesn't
// loop a sent gift wrap back to its author, so this shouldn't happen — the // loop a sent gift wrap back to its author; the guard keeps the seam honest.
// guard just keeps the seam honest. if (peer != _selfPubkey) {
if (peer != _social.publicKeyHex) {
await _unread?.onMessageReceived(peer, message); await _unread?.onMessageReceived(peer, message);
await _maybeNotify(peer); await _maybeNotify(peer);
} }
@ -151,20 +129,12 @@ class InboxService {
} }
} }
void _dropSession() {
unawaited(_inboxSub?.cancel());
_inboxSub = null;
unawaited(_session?.close());
_session = null;
}
Future<void> stop() async { Future<void> stop() async {
_started = false; _started = false;
await _connSub?.cancel(); await _sessionsSub?.cancel();
_connSub = null; _sessionsSub = null;
await _inboxSub?.cancel(); await _inboxSub?.cancel();
_inboxSub = null; _inboxSub = null;
await _session?.close();
_session = null; _session = null;
if (!_changes.isClosed) await _changes.close(); if (!_changes.isClosed) await _changes.close();
} }

View file

@ -0,0 +1,107 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'social_service.dart';
import 'social_settings.dart';
/// Opens a session against the given relays. Injectable so the connection logic
/// can be unit-tested without a real relay.
typedef SessionOpener = Future<SocialSession> Function(List<String> relays);
/// ONE shared relay connection per identity, reused by every social feature
/// (offers, messaging, trust, profile, the inbox listener) instead of each
/// opening its own `RelayPool`. Fewer sockets, less battery, one place to manage
/// reconnection.
///
/// Lazily connects on first [session] call; reconnects when the network returns
/// and drops the session when it goes away, announcing each change on [sessions]
/// so long-lived consumers (the inbox listener) can re-subscribe. Callers must
/// NOT close the session they get this owns its lifecycle. Recreated on an
/// identity switch (the old one is disposed).
class SocialConnection {
SocialConnection({
required SocialService social,
required SocialSettings settings,
SessionOpener? open,
Stream<bool>? online,
}) : _settings = settings,
_open = open ?? social.openSession,
_online = online;
final SocialSettings _settings;
final SessionOpener _open;
final Stream<bool>? _online;
final _sessions = StreamController<SocialSession?>.broadcast();
SocialSession? _current;
Future<SocialSession?>? _pending;
StreamSubscription<bool>? _onlineSub;
bool _disposed = false;
/// Emits the live session on each (re)connect, and null when it drops.
Stream<SocialSession?> get sessions => _sessions.stream;
/// The current shared session, or null if not connected right now.
SocialSession? get current => _current;
/// Begins watching connectivity (reconnect on regain, drop when offline) and
/// attempts an initial connect. Idempotent-ish; call once at startup.
void start() {
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
if (!isOnline) {
_drop();
} else if (_current == null) {
unawaited(session());
}
});
unawaited(session()); // initial attempt (also connects if already online)
}
/// The shared session, connecting on first use. Returns null when offline or
/// no relay is reachable; the feature then degrades gracefully.
Future<SocialSession?> session() {
if (_current != null) return Future.value(_current);
return _pending ??= _connect();
}
Future<SocialSession?> _connect() async {
try {
final relays = await _settings.relayUrls();
if (relays.isEmpty) return null;
final s = await _open(relays);
if (_disposed) {
await s.close();
return null;
}
_current = s;
_sessions.add(s);
return s;
} catch (_) {
return null; // unreachable a later connectivity change retries
} finally {
_pending = null;
}
}
void _drop() {
final s = _current;
_current = null;
if (s != null) {
unawaited(s.close());
if (!_sessions.isClosed) _sessions.add(null);
}
}
Future<void> dispose() async {
_disposed = true;
await _onlineSub?.cancel();
_onlineSub = null;
_drop();
if (!_sessions.isClosed) await _sessions.close();
}
static Stream<bool> _connectivityOnline() =>
Connectivity().onConnectivityChanged.map((results) =>
results.any((r) => r != ConnectivityResult.none));
}

View file

@ -5,8 +5,6 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../services/message_store.dart'; import '../services/message_store.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
/// A 1:1 conversation with one peer. Holds the running message list plus send /// A 1:1 conversation with one peer. Holds the running message list plus send
/// status. Transport-agnostic depends on [MessageTransport], not the relay. /// status. Transport-agnostic depends on [MessageTransport], not the relay.
@ -134,29 +132,3 @@ class MessagesCubit extends Cubit<ChatState> {
return super.close(); return super.close();
} }
} }
/// Opens a [MessagesCubit] wired to the social layer for a chat with
/// [peerPubkey], or an offline one that degrades gracefully.
Future<MessagesCubit> createMessagesCubit(
SocialService social,
SocialSettings settings, {
required String peerPubkey,
}) async {
final relays = await settings.relayUrls();
if (relays.isEmpty) {
return MessagesCubit(null,
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
}
try {
final session = await social.openSession(relays);
return MessagesCubit(
session.messages,
peerPubkey: peerPubkey,
selfPubkey: social.publicKeyHex,
onDispose: session.close,
);
} catch (_) {
return MessagesCubit(null,
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
}
}

View file

@ -9,8 +9,7 @@ import '../data/variety_repository.dart';
import '../services/offer_mapper.dart'; import '../services/offer_mapper.dart';
import '../services/offer_outbox.dart'; import '../services/offer_outbox.dart';
import '../services/offer_thumbnail.dart'; import '../services/offer_thumbnail.dart';
import '../services/social_service.dart'; import '../services/social_connection.dart';
import '../services/social_settings.dart';
/// State of the offer discovery/publish screen. Transport-agnostic it holds /// State of the offer discovery/publish screen. Transport-agnostic it holds
/// only what the UI shows, never a relay handle. /// only what the UI shows, never a relay handle.
@ -323,27 +322,20 @@ class OffersCubit extends Cubit<OffersState> {
} }
} }
/// Opens an [OffersCubit] wired to the social layer, or an offline one that /// Opens an [OffersCubit] over the SHARED [SocialConnection], or an offline one
/// degrades gracefully. Local-first: when no relay is configured (or connecting /// that degrades gracefully. Local-first: when the connection isn't up (no relay
/// fails), the cubit is created with a null transport and the screen still opens. /// configured / unreachable), the cubit gets a null transport and the screen
/// still opens. Does NOT close the session the connection owns it.
Future<OffersCubit> createOffersCubit( Future<OffersCubit> createOffersCubit(
SocialService social, SocialConnection connection, {
SocialSettings settings, {
VarietyRepository? repository, VarietyRepository? repository,
}) async { }) async {
final relays = await settings.relayUrls(); final session = await connection.session();
if (relays.isEmpty) return OffersCubit(null); return OffersCubit(
try { session?.offers,
final session = await social.openSession(relays); coverPhoto: repository?.coverPhotoFor,
return OffersCubit( thumbnail: offerThumbnailDataUri,
session.offers, );
coverPhoto: repository?.coverPhotoFor,
thumbnail: offerThumbnailDataUri,
onDispose: session.close,
);
} catch (_) {
return OffersCubit(null); // offline / no relay reachable
}
} }
/// Publishes any queued (offline-parked) lots now that we're online, then clears /// Publishes any queued (offline-parked) lots now that we're online, then clears

View file

@ -2,9 +2,6 @@ import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
/// Where a peer stands, from strongest to weakest signal. /// Where a peer stands, from strongest to weakest signal.
enum TrustTier { enum TrustTier {
/// Passes the full Duniter membership rule (sigQty certifications from members, /// Passes the full Duniter membership rule (sigQty certifications from members,
@ -189,29 +186,3 @@ class TrustCubit extends Cubit<TrustState> {
return super.close(); return super.close();
} }
} }
/// Opens a [TrustCubit] wired to the social layer for [peerPubkey], or an
/// offline one.
Future<TrustCubit> createTrustCubit(
SocialService social,
SocialSettings settings, {
required String peerPubkey,
}) async {
final relays = await settings.relayUrls();
if (relays.isEmpty) {
return TrustCubit(null,
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
}
try {
final session = await social.openSession(relays);
return TrustCubit(
session.trust,
peerPubkey: peerPubkey,
selfPubkey: social.publicKeyHex,
onDispose: session.close,
);
} catch (_) {
return TrustCubit(null,
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
}
}

View file

@ -7,8 +7,7 @@ import '../i18n/strings.g.dart';
import '../services/inbox_service.dart'; import '../services/inbox_service.dart';
import '../services/message_store.dart'; import '../services/message_store.dart';
import '../services/profile_cache.dart'; import '../services/profile_cache.dart';
import '../services/social_service.dart'; import '../services/social_connection.dart';
import '../services/social_settings.dart';
import 'theme.dart'; import 'theme.dart';
import 'unread_badge.dart'; import 'unread_badge.dart';
@ -17,16 +16,16 @@ import 'unread_badge.dart';
class ChatListScreen extends StatefulWidget { class ChatListScreen extends StatefulWidget {
const ChatListScreen({ const ChatListScreen({
required this.store, required this.store,
this.social, this.connection,
this.settings,
this.profileCache, this.profileCache,
this.inbox, this.inbox,
super.key, super.key,
}); });
final MessageStore store; final MessageStore store;
final SocialService? social;
final SocialSettings? settings; /// The shared relay connection, for resolving peers' published names.
final SocialConnection? connection;
final ProfileCache? profileCache; final ProfileCache? profileCache;
/// App-wide inbox listener; the list reloads whenever it reports a change. /// App-wide inbox listener; the list reloads whenever it reports a change.
@ -69,20 +68,18 @@ class _ChatListScreenState extends State<ChatListScreen> {
unawaited(_resolveMissingNames(items)); unawaited(_resolveMissingNames(items));
} }
/// Fetches published names for peers we don't have cached yet, over one /// Fetches published names for peers we don't have cached yet, over the shared
/// session. Best effort; the list already shows short keys meanwhile. /// connection. Best effort; the list already shows short keys meanwhile.
Future<void> _resolveMissingNames(List<ChatSummary> items) async { Future<void> _resolveMissingNames(List<ChatSummary> items) async {
final social = widget.social; final connection = widget.connection;
final settings = widget.settings;
final cache = widget.profileCache; final cache = widget.profileCache;
if (social == null || settings == null || cache == null) return; if (connection == null || cache == null) return;
final missing = final missing =
items.map((c) => c.peerPubkey).where((p) => !_names.containsKey(p)); items.map((c) => c.peerPubkey).where((p) => !_names.containsKey(p));
if (missing.isEmpty) return; if (missing.isEmpty) return;
final relays = await settings.relayUrls(); final session = await connection.session();
if (relays.isEmpty) return; if (session == null) return;
try { try {
final session = await social.openSession(relays);
for (final peer in missing) { for (final peer in missing) {
final profile = await session.profile.fetch(peer); final profile = await session.profile.fetch(peer);
if (profile != null && profile.name.isNotEmpty) { if (profile != null && profile.name.isNotEmpty) {
@ -90,7 +87,6 @@ class _ChatListScreenState extends State<ChatListScreen> {
if (mounted) setState(() => _names[peer] = profile.name); if (mounted) setState(() => _names[peer] = profile.name);
} }
} }
await session.close();
} catch (_) { } catch (_) {
// offline / unreachable short keys stay. // offline / unreachable short keys stay.
} }

View file

@ -10,8 +10,8 @@ import '../di/injector.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/message_store.dart'; import '../services/message_store.dart';
import '../services/profile_cache.dart'; import '../services/profile_cache.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart';
import '../services/trust_referents.dart'; import '../services/trust_referents.dart';
import '../services/unread_service.dart'; import '../services/unread_service.dart';
import '../services/wot_settings.dart'; import '../services/wot_settings.dart';
@ -25,7 +25,7 @@ import 'theme.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
const ChatScreen({ const ChatScreen({
required this.social, required this.social,
required this.settings, required this.connection,
required this.peerPubkey, required this.peerPubkey,
this.messageStore, this.messageStore,
this.profileCache, this.profileCache,
@ -35,7 +35,9 @@ class ChatScreen extends StatefulWidget {
}); });
final SocialService social; final SocialService social;
final SocialSettings settings;
/// The shared relay connection (one per identity), carrying messaging + trust.
final SocialConnection connection;
final String peerPubkey; final String peerPubkey;
/// Optional persistence for chat history (keystore-backed); null in tests. /// Optional persistence for chat history (keystore-backed); null in tests.
@ -53,7 +55,6 @@ class ChatScreen extends StatefulWidget {
} }
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
SocialSession? _session;
MessagesCubit? _messages; MessagesCubit? _messages;
TrustCubit? _trust; TrustCubit? _trust;
bool _loading = true; bool _loading = true;
@ -79,25 +80,14 @@ class _ChatScreenState extends State<ChatScreen> {
} }
Future<void> _init() async { Future<void> _init() async {
// One session for this chat carries both messaging and trust (the shared- // The shared connection carries both messaging and trust. Offline (not
// connection shape). Offline (no relays / unreachable) null transports. // connected / unreachable) null transports and the screen degrades.
final relays = await widget.settings.relayUrls(); final session = await widget.connection.session();
SocialSession? session;
if (relays.isNotEmpty) {
try {
session = await widget.social.openSession(relays);
} catch (_) {
session = null;
}
}
final cachedName = await widget.profileCache?.name(widget.peerPubkey); final cachedName = await widget.profileCache?.name(widget.peerPubkey);
final referents = await widget.trustReferents?.all() ?? const <String>{}; final referents = await widget.trustReferents?.all() ?? const <String>{};
final wotParams = final wotParams =
await widget.wotSettings?.params() ?? WotParams.duniter; await widget.wotSettings?.params() ?? WotParams.duniter;
if (!mounted) { if (!mounted) return; // shared session is owned by the connection, not us
await session?.close();
return;
}
final self = widget.social.publicKeyHex; final self = widget.social.publicKeyHex;
final messages = MessagesCubit( final messages = MessagesCubit(
session?.messages, session?.messages,
@ -114,7 +104,6 @@ class _ChatScreenState extends State<ChatScreen> {
); );
unawaited(trust.load()); unawaited(trust.load());
setState(() { setState(() {
_session = session;
_messages = messages; _messages = messages;
_trust = trust; _trust = trust;
_peerName = cachedName; _peerName = cachedName;
@ -184,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
unawaited(_unread?.markRead(widget.peerPubkey)); unawaited(_unread?.markRead(widget.peerPubkey));
_messages?.close(); _messages?.close();
_trust?.close(); _trust?.close();
_session?.close(); // The session belongs to the shared connection don't close it here.
_input.dispose(); _input.dispose();
super.dispose(); super.dispose();
} }

View file

@ -7,8 +7,7 @@ import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/profile_cache.dart'; import '../services/profile_cache.dart';
import '../services/social_service.dart'; import '../services/social_connection.dart';
import '../services/social_settings.dart';
import 'market_widgets.dart'; import 'market_widgets.dart';
import 'theme.dart'; import 'theme.dart';
@ -20,16 +19,16 @@ import 'theme.dart';
class MarketOfferDetailScreen extends StatefulWidget { class MarketOfferDetailScreen extends StatefulWidget {
const MarketOfferDetailScreen({ const MarketOfferDetailScreen({
required this.offer, required this.offer,
required this.social, required this.connection,
required this.settings,
this.mine = false, this.mine = false,
this.profileCache, this.profileCache,
super.key, super.key,
}); });
final Offer offer; final Offer offer;
final SocialService social;
final SocialSettings settings; /// The shared relay connection, for fetching the author's published profile.
final SocialConnection connection;
/// Whether this is the current user's own listing (hide the message button). /// Whether this is the current user's own listing (hide the message button).
final bool mine; final bool mine;
@ -43,7 +42,6 @@ class MarketOfferDetailScreen extends StatefulWidget {
} }
class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> { class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
SocialSession? _session;
String? _sellerName; String? _sellerName;
String? _sellerAbout; String? _sellerAbout;
String? _sellerG1; String? _sellerG1;
@ -61,20 +59,8 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex); final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex);
if (mounted && cachedName != null) setState(() => _sellerName = cachedName); if (mounted && cachedName != null) setState(() => _sellerName = cachedName);
final relays = await widget.settings.relayUrls(); final session = await widget.connection.session();
SocialSession? session; if (!mounted) return;
if (relays.isNotEmpty) {
try {
session = await widget.social.openSession(relays);
} catch (_) {
session = null;
}
}
if (!mounted) {
await session?.close();
return;
}
_session = session;
if (session == null) { if (session == null) {
setState(() => _profileLoading = false); setState(() => _profileLoading = false);
return; return;
@ -100,11 +86,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
} }
} }
@override // No dispose of the session it belongs to the shared connection.
void dispose() {
_session?.close();
super.dispose();
}
Future<void> _copyId() async { Future<void> _copyId() async {
final t = context.t; final t = context.t;

View file

@ -8,6 +8,7 @@ import '../i18n/strings.g.dart';
import '../services/coarse_location.dart'; import '../services/coarse_location.dart';
import '../services/discovery_area.dart'; import '../services/discovery_area.dart';
import '../services/offer_outbox.dart'; import '../services/offer_outbox.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
import '../state/offers_cubit.dart'; import '../state/offers_cubit.dart';
@ -21,6 +22,7 @@ class MarketScreen extends StatefulWidget {
const MarketScreen({ const MarketScreen({
required this.social, required this.social,
required this.settings, required this.settings,
required this.connection,
this.location, this.location,
this.outbox, this.outbox,
super.key, super.key,
@ -29,6 +31,9 @@ class MarketScreen extends StatefulWidget {
final SocialService social; final SocialService social;
final SocialSettings settings; final SocialSettings settings;
/// The shared relay connection (one per identity), reused for discovery.
final SocialConnection connection;
/// Optional device-location source for the "use my location" shortcut; when /// Optional device-location source for the "use my location" shortcut; when
/// null (tests, or a platform without it) the shortcut is hidden. /// null (tests, or a platform without it) the shortcut is hidden.
final CoarseLocationProvider? location; final CoarseLocationProvider? location;
@ -58,11 +63,8 @@ class _MarketScreenState extends State<MarketScreen> {
final repo = final repo =
widget.outbox != null ? context.read<VarietyRepository>() : null; widget.outbox != null ? context.read<VarietyRepository>() : null;
setState(() => _loading = true); setState(() => _loading = true);
final cubit = await createOffersCubit( final cubit =
widget.social, await createOffersCubit(widget.connection, repository: repo);
widget.settings,
repository: repo,
);
final area = await widget.settings.areaGeohash(); final area = await widget.settings.areaGeohash();
if (!mounted) { if (!mounted) {
await cubit.close(); await cubit.close();

View file

@ -7,8 +7,8 @@ import '../i18n/strings.g.dart';
import '../services/profile_cache.dart' show shortPubkey; import '../services/profile_cache.dart' show shortPubkey;
import '../services/social_account_store.dart'; import '../services/social_account_store.dart';
import '../services/profile_store.dart'; import '../services/profile_store.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart';
import 'qr_view.dart'; import 'qr_view.dart';
import 'restart_widget.dart'; import 'restart_widget.dart';
import 'theme.dart'; import 'theme.dart';
@ -19,7 +19,7 @@ import 'theme.dart';
class ProfileScreen extends StatefulWidget { class ProfileScreen extends StatefulWidget {
const ProfileScreen({ const ProfileScreen({
required this.social, required this.social,
required this.settings, required this.connection,
required this.profileStore, required this.profileStore,
required this.accounts, required this.accounts,
this.trustNetworkEnabled = false, this.trustNetworkEnabled = false,
@ -27,7 +27,9 @@ class ProfileScreen extends StatefulWidget {
}); });
final SocialService social; final SocialService social;
final SocialSettings settings;
/// The shared relay connection, for publishing your profile.
final SocialConnection connection;
final ProfileStore profileStore; final ProfileStore profileStore;
final SocialAccountStore accounts; final SocialAccountStore accounts;
@ -121,20 +123,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
await widget.profileStore await widget.profileStore
.save(name: _name.text, about: _about.text, g1: _g1.text); .save(name: _name.text, about: _about.text, g1: _g1.text);
// Best-effort publish to the network so peers see the name. // Best-effort publish over the shared connection so peers see the name.
final relays = await widget.settings.relayUrls(); try {
if (relays.isNotEmpty) { final session = await widget.connection.session();
try { await session?.profile.publish(
final session = await widget.social.openSession(relays); name: _name.text.trim(),
await session.profile.publish( about: _about.text.trim(),
name: _name.text.trim(), g1: _g1.text.trim(),
about: _about.text.trim(), );
g1: _g1.text.trim(), } catch (_) {
); // offline / unreachable the local copy is saved regardless.
await session.close();
} catch (_) {
// offline / unreachable the local copy is saved regardless.
}
} }
if (!mounted) return; if (!mounted) return;
setState(() => _saving = false); setState(() => _saving = false);

View file

@ -90,6 +90,8 @@ dev_dependencies:
drift_dev: ^2.28.0 drift_dev: ^2.28.0
slang_build_runner: ^4.7.0 slang_build_runner: ^4.7.0
mocktail: ^1.0.4 mocktail: ^1.0.4
# Nostr types (Event/Filter) to fake a NostrChannel in the connection test.
nostr: ^2.0.0
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4
flutter_native_splash: ^2.4.7 flutter_native_splash: ^2.4.7

View file

@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/inbox_service.dart'; import 'package:tane/services/inbox_service.dart';
import 'package:tane/services/message_store.dart'; import 'package:tane/services/message_store.dart';
import 'package:tane/services/notification_service.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_service.dart';
import 'package:tane/services/social_settings.dart'; import 'package:tane/services/social_settings.dart';
import 'package:tane/services/unread_service.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, /// The app-wide inbox listener persists incoming messages, updates unread and
/// so the inbox list refreshes even when the specific chat isn't open. Driven /// notifies even when the specific chat isn't open. Driven through the
/// through the [InboxService.ingest] seam so no relay/network is involved. /// [InboxService.ingest] seam so no relay/network is involved.
void main() { void main() {
const seedHex = const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; '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 MessageStore store;
late InboxService inbox; late InboxService inbox;
setUp(() async { setUp(() async {
store = MessageStore(InMemorySecretStore()); store = MessageStore(InMemorySecretStore());
inbox = InboxService( inbox = InboxService(
social: await SocialService.fromRootSeedHex(seedHex), connection: await offlineConnection(),
settings: SocialSettings(InMemorySecretStore()), selfPubkey: 'me',
store: store, store: store,
); );
}); });
@ -75,16 +84,14 @@ void main() {
group('unread + notification hooks', () { group('unread + notification hooks', () {
late UnreadService unread; late UnreadService unread;
late _RecordingNotifications notifications; late _RecordingNotifications notifications;
late String myPubkey; const myPubkey = 'me';
setUp(() async { setUp(() async {
final social = await SocialService.fromRootSeedHex(seedHex);
myPubkey = social.publicKeyHex;
unread = UnreadService(store, InMemorySecretStore()); unread = UnreadService(store, InMemorySecretStore());
notifications = _RecordingNotifications(); notifications = _RecordingNotifications();
inbox = InboxService( inbox = InboxService(
social: social, connection: await offlineConnection(),
settings: SocialSettings(InMemorySecretStore()), selfPubkey: myPubkey,
store: store, store: store,
unread: unread, unread: unread,
notifications: notifications, notifications: notifications,

View 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();
});
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.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_service.dart';
import 'package:tane/services/social_settings.dart'; import 'package:tane/services/social_settings.dart';
import 'package:tane/ui/chat_screen.dart'; import 'package:tane/ui/chat_screen.dart';
@ -14,6 +15,7 @@ void main() {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore()); final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network await settings.setRelayUrls(const []); // offline: don't hit the network
final connection = SocialConnection(social: social, settings: settings);
LocaleSettings.setLocaleSync(AppLocale.en); LocaleSettings.setLocaleSync(AppLocale.en);
await tester.pumpWidget( await tester.pumpWidget(
@ -28,7 +30,7 @@ void main() {
], ],
home: ChatScreen( home: ChatScreen(
social: social, social: social,
settings: settings, connection: connection,
peerPubkey: 'ab' * 32, peerPubkey: 'ab' * 32,
), ),
), ),

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.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_service.dart';
import 'package:tane/services/social_settings.dart'; import 'package:tane/services/social_settings.dart';
import 'package:tane/ui/market_offer_detail_screen.dart'; import 'package:tane/ui/market_offer_detail_screen.dart';
@ -43,11 +44,11 @@ void main() {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore()); final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: no network in the test await settings.setRelayUrls(const []); // offline: no network in the test
final connection = SocialConnection(social: social, settings: settings);
await tester.pumpWidget(_wrap(MarketOfferDetailScreen( await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
offer: _offer(organic: true), offer: _offer(organic: true),
social: social, connection: connection,
settings: settings,
))); )));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -61,11 +62,11 @@ void main() {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore()); final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); await settings.setRelayUrls(const []);
final connection = SocialConnection(social: social, settings: settings);
await tester.pumpWidget(_wrap(MarketOfferDetailScreen( await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
offer: _offer(), offer: _offer(),
social: social, connection: connection,
settings: settings,
mine: true, mine: true,
))); )));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -77,11 +78,11 @@ void main() {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore()); final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); await settings.setRelayUrls(const []);
final connection = SocialConnection(social: social, settings: settings);
await tester.pumpWidget(_wrap(MarketOfferDetailScreen( await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
offer: _offer(imageUrl: 'https://media.example/abc.jpg'), offer: _offer(imageUrl: 'https://media.example/abc.jpg'),
social: social, connection: connection,
settings: settings,
))); )));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -92,11 +93,11 @@ void main() {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore()); final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); await settings.setRelayUrls(const []);
final connection = SocialConnection(social: social, settings: settings);
await tester.pumpWidget(_wrap(MarketOfferDetailScreen( await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
offer: _offer(), offer: _offer(),
social: social, connection: connection,
settings: settings,
))); )));
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View file

@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart'; import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/coarse_location.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_service.dart';
import 'package:tane/services/social_settings.dart'; import 'package:tane/services/social_settings.dart';
import 'package:tane/state/offers_cubit.dart'; import 'package:tane/state/offers_cubit.dart';
@ -60,7 +61,12 @@ Widget _wrapMarket(SocialService social, SocialSettings settings,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.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,
),
), ),
); );
} }