Fix mobile relay reconnect lifecycle (#1772)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-13 15:25:58 +00:00
committed by GitHub
co-authored by Pinky
parent 0950d392b7
commit dbe7fb8534
12 changed files with 544 additions and 173 deletions
-54
View File
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
@@ -68,7 +67,6 @@ class App extends HookConsumerWidget {
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => const HomePage(),
AuthStatus.offline => const _OfflineScreen(),
_ => const PairingPage(),
},
),
@@ -84,55 +82,3 @@ class _SplashScreen extends StatelessWidget {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}
class _OfflineScreen extends ConsumerWidget {
const _OfflineScreen();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.wifiOff,
size: 48,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xs),
Text(
'Unable to reach relay',
style: context.textTheme.titleMedium,
),
const SizedBox(height: Grid.xxs),
Text(
'Your pairing is saved — check your connection and try again.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.sm),
FilledButton.icon(
onPressed: () => ref.read(authProvider.notifier).retry(),
icon: const Icon(LucideIcons.refreshCw),
label: const Text('Retry'),
),
const SizedBox(height: Grid.twelve),
TextButton(
onPressed: () => ref.read(authProvider.notifier).signOut(),
child: Text(
'Remove workspace and re-pair',
style: TextStyle(color: context.colors.onSurfaceVariant),
),
),
],
),
),
),
);
}
}
@@ -197,15 +197,17 @@ class ChannelsPage extends HookConsumerWidget {
}
}
// Defer the error view to absorb transient AsyncError frames caused by
// the relay session cancelling in-flight history fetches on disconnect/
// reconnect (relay_session.dart `_cancelAllHistory`). If the error clears
// (channels populate or the next _fetch succeeds) within the grace
// window, we never render the error UI.
// Only surface fetch errors while the relay is stably connected. During a
// reconnect the session owns recovery, so a cancelled in-flight query must
// not turn into a manual Retry page.
final showError = useState(false);
final hasError = channelsAsync.hasError && channels == null;
final canSurfaceError =
hasError &&
sessionState.status != SessionStatus.connecting &&
sessionState.status != SessionStatus.reconnecting;
useEffect(() {
if (!hasError) {
if (!canSurfaceError) {
showError.value = false;
return null;
}
@@ -213,7 +215,26 @@ class ChannelsPage extends HookConsumerWidget {
showError.value = true;
});
return timer.cancel;
}, [hasError]);
}, [canSurfaceError]);
// Match desktop's degraded-state debounce: cached content remains steady
// through brief socket flaps, and the banner appears only for a sustained
// reconnect.
final showConnectionBanner = useState(false);
final isReconnectingWithContent =
channels != null &&
(sessionState.status == SessionStatus.connecting ||
sessionState.status == SessionStatus.reconnecting);
useEffect(() {
if (!isReconnectingWithContent) {
showConnectionBanner.value = false;
return null;
}
final timer = Timer(const Duration(seconds: 2), () {
showConnectionBanner.value = true;
});
return timer.cancel;
}, [isReconnectingWithContent]);
return FrostedScaffold(
appBar: FrostedAppBar(
@@ -248,6 +269,7 @@ class ChannelsPage extends HookConsumerWidget {
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
@@ -5,6 +5,7 @@ class _ChannelsBody extends StatelessWidget {
final AsyncValue<List<Channel>> channelsAsync;
final bool showError;
final SessionStatus sessionStatus;
final bool showConnectionBanner;
final String? currentPubkey;
final Future<void> Function() onRefresh;
final Future<void> Function(Channel channel) onSelectChannel;
@@ -14,6 +15,7 @@ class _ChannelsBody extends StatelessWidget {
required this.channelsAsync,
required this.showError,
required this.sessionStatus,
required this.showConnectionBanner,
required this.currentPubkey,
required this.onRefresh,
required this.onSelectChannel,
@@ -33,8 +35,7 @@ class _ChannelsBody extends StatelessWidget {
slivers: [
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
// Extra space for the connection banner when visible.
if (sessionStatus != SessionStatus.connected &&
sessionStatus != SessionStatus.disconnected)
if (showConnectionBanner)
const SliverToBoxAdapter(
child: SizedBox(height: _kBannerHeight),
),
@@ -50,7 +51,9 @@ class _ChannelsBody extends StatelessWidget {
top: barHeight,
left: 0,
right: 0,
child: _ConnectionBanner(status: sessionStatus),
child: showConnectionBanner
? _ConnectionBanner(status: sessionStatus)
: const SizedBox.shrink(),
),
],
);
@@ -43,6 +43,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Set<String> _participatedRootIds = {};
Set<String> _authoredRootIds = {};
String? _threadInterestPubkey;
bool _hasLoaded = false;
Map<String, int> get latestObservedByChannel =>
Map.unmodifiable(_latestObservedByChannel);
@@ -55,9 +56,22 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
});
@override
Future<List<Channel>> build() {
final sessionState = ref.watch(relaySessionProvider);
Future<List<Channel>> build() async {
ref.watch(relayConfigProvider);
final connected = Completer<void>();
final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection =
sessionState.status != SessionStatus.connected;
ref.listen(relaySessionProvider, (previous, next) {
if (next.status != SessionStatus.connected) return;
if (waitingForInitialConnection &&
!_hasLoaded &&
!connected.isCompleted) {
connected.complete();
} else if (previous?.status != SessionStatus.connected) {
unawaited(_backstopRefresh());
}
});
// Re-fetch when the app returns to foreground so channels created on
// another device while mobile was backgrounded appear immediately.
@@ -76,27 +90,29 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
});
if (sessionState.status != SessionStatus.connected) {
_clearLiveSubscriptions();
_latestObservedByChannel.clear();
_observedUnreadEventsByChannel.clear();
// Preserve the last successfully loaded channels while reconnecting
// instead of re-entering a loading/error state. The UI will show cached
// channels with a "Reconnecting…" banner overlay, which is far better
// than a blank screen.
final previous = state.value;
if (previous != null && previous.isNotEmpty) {
return Future.value(previous);
}
// Keep the prior workspace's cache visible until the new relay connects.
if (_hasLoaded) return state.value ?? const [];
await connected.future;
}
return _fetch(
subscribeLive: sessionState.status == SessionStatus.connected,
);
return _fetch(subscribeLive: true);
}
Future<List<Channel>> _fetch({
bool subscribeLive = false,
bool fetchLastMessage = true,
}) async {
final channels = await _fetchChannels(
subscribeLive: subscribeLive,
fetchLastMessage: fetchLastMessage,
);
_hasLoaded = true;
return channels;
}
Future<List<Channel>> _fetchChannels({
bool subscribeLive = false,
bool fetchLastMessage = true,
}) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) throw StateError('No signing identity available');
@@ -11,8 +11,7 @@ import 'mention_ranking.dart';
/// Relay agent directory from kind:10100 agent-profile events.
///
/// Watches the session so it re-fetches once connected (a fetch fired
/// before the WS is up resolves empty on timeout).
/// Watches the session and only fetches after the WebSocket connects.
final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
ref,
) async {
+29 -57
View File
@@ -1,10 +1,10 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../relay/relay.dart';
import '../workspace/workspace.dart';
import '../workspace/workspace_provider.dart';
enum AuthStatus { unknown, unauthenticated, authenticated, offline }
enum AuthStatus { unknown, unauthenticated, authenticated }
class AuthState {
final AuthStatus status;
@@ -13,10 +13,8 @@ class AuthState {
const AuthState({required this.status, this.workspace});
}
/// Validates the active workspace on startup by opening a NIP-42-authenticated
/// websocket. A successful AUTH means the nsec is valid and the relay accepts
/// us; any other outcome falls through to offline (transient) or removes the
/// workspace (auth explicitly rejected).
/// Restores the active workspace without making connectivity load-bearing.
/// The relay session owns connection recovery after startup.
class AuthNotifier extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
@@ -29,56 +27,26 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
return const AuthState(status: AuthStatus.unauthenticated);
}
final activeId = await storage.loadActiveId();
final Workspace active;
if (activeId != null && workspaces.any((w) => w.id == activeId)) {
active = workspaces.firstWhere((w) => w.id == activeId);
} else {
// activeId is null or points to a workspace that no longer exists.
// Fall back to first workspace and persist the choice.
active = workspaces.first;
var activeId = await storage.loadActiveId();
while (workspaces.isNotEmpty) {
final active = activeId != null && workspaces.any((w) => w.id == activeId)
? workspaces.firstWhere((w) => w.id == activeId)
: workspaces.first;
await storage.saveActiveId(active.id);
}
// Validate by attempting a NIP-42 authenticated WS connection.
final socket = RelaySocket(
wsUrl: _wsFromBase(active.relayUrl),
nsec: active.nsec,
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) {},
);
try {
await socket.connect().timeout(const Duration(seconds: 8));
await socket.disconnect();
return AuthState(status: AuthStatus.authenticated, workspace: active);
} catch (e) {
final msg = e.toString();
// The relay explicitly rejected our auth — drop this workspace.
if (msg.contains('Auth rejected') ||
msg.contains('restricted') ||
msg.contains('auth-required')) {
await storage.remove(active.id);
final remaining = await storage.loadAll();
if (remaining.isNotEmpty) {
final next = remaining.first;
await storage.saveActiveId(next.id);
ref.invalidate(workspaceListProvider);
ref.invalidate(activeWorkspaceProvider);
ref.invalidateSelf();
return await future;
}
return const AuthState(status: AuthStatus.unauthenticated);
if (_hasValidNsec(active.nsec)) {
return AuthState(status: AuthStatus.authenticated, workspace: active);
}
// Transient (timeout, network) — keep workspace, go offline.
return AuthState(status: AuthStatus.offline, workspace: active);
}
}
/// Retry credential validation (e.g. after a network error).
Future<void> retry() async {
ref.invalidateSelf();
await future;
await storage.remove(active.id);
workspaces.removeWhere((workspace) => workspace.id == active.id);
activeId = null;
ref.invalidate(workspaceListProvider);
ref.invalidate(activeWorkspaceProvider);
}
await storage.clearActiveId();
return const AuthState(status: AuthStatus.unauthenticated);
}
/// Authenticate with a workspace. Saves it and switches to it.
@@ -126,11 +94,15 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
}
}
/// Derive the websocket URL from the workspace's HTTP base URL.
String _wsFromBase(String baseUrl) {
final uri = Uri.parse(baseUrl);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
return uri.replace(scheme: scheme).toString();
bool _hasValidNsec(String? nsec) {
if (nsec == null || nsec.isEmpty) return false;
try {
final decoded = nostr.Nip19.decode(payload: nsec);
return decoded.prefix == nostr.Nip19Prefix.nsec &&
decoded.data.length == 64;
} catch (_) {
return false;
}
}
final authProvider = AsyncNotifierProvider<AuthNotifier, AuthState>(
+66 -27
View File
@@ -65,10 +65,24 @@ class _BufferedEvent {
/// Manages websocket subscriptions, event batching, reconnection with replay,
/// and pending event tracking. Equivalent to the desktop's RelayClientSession.
typedef RelaySocketFactory =
RelaySocket Function({
required String wsUrl,
required String? nsec,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
});
class RelaySessionNotifier extends Notifier<SessionState> {
RelaySessionNotifier({http.Client? httpClient}) : _httpClient = httpClient;
RelaySessionNotifier({
http.Client? httpClient,
RelaySocketFactory socketFactory = RelaySocket.new,
}) : _httpClient = httpClient,
_socketFactory = socketFactory;
final http.Client? _httpClient;
final RelaySocketFactory _socketFactory;
static const _baseReconnectDelayMs = 1000;
static const _maxReconnectDelayMs = 30000;
@@ -88,6 +102,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
int _reconnectDelayMs = _baseReconnectDelayMs;
int _subIdCounter = 0;
bool _disposed = false;
bool _paused = false;
bool _hasConnectedOnce = false;
int _connectionGeneration = 0;
@override
SessionState build() {
@@ -173,8 +190,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
final timer = Timer(timeout, () {
final sub = _historySubscriptions.remove(subId);
if (sub != null && !sub.completer.isCompleted) {
// Resolve with whatever we collected so far rather than failing.
sub.completer.complete(sub.events);
sub.completer.completeError(
TimeoutException('Relay history request timed out after $timeout'),
);
}
_sendClose(subId);
});
@@ -266,6 +284,16 @@ class RelaySessionNotifier extends Notifier<SessionState> {
@visibleForTesting
void debugFlushEventBuffer() => _flushEventBuffer();
@visibleForTesting
void debugHandleConnected() => _handleConnected(_connectionGeneration);
@visibleForTesting
void debugHandleDisconnected([Object? error]) =>
_handleDisconnected(_connectionGeneration, error);
@visibleForTesting
void debugPauseNow() => _pauseNow();
/// Force a reconnect (e.g., returning from background).
Future<void> reconnect() async {
await _socket?.disconnect();
@@ -277,14 +305,21 @@ class RelaySessionNotifier extends Notifier<SessionState> {
/// Called by the app lifecycle provider when the app goes to background.
void onAppPaused() {
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = Timer(const Duration(seconds: 5), () {
_socket?.disconnect();
state = const SessionState(status: SessionStatus.disconnected);
});
_backgroundGraceTimer = Timer(const Duration(seconds: 5), _pauseNow);
}
void _pauseNow() {
_paused = true;
_reconnectTimer?.cancel();
_cancelAllHistory(Exception('App moved to background'));
_rejectAllPending(Exception('App moved to background'));
_socket?.disconnect();
state = const SessionState(status: SessionStatus.disconnected);
}
/// Called by the app lifecycle provider when the app returns to foreground.
void onAppResumed() {
_paused = false;
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = null;
@@ -302,52 +337,55 @@ class RelaySessionNotifier extends Notifier<SessionState> {
Future<void> _connect(RelayConfig config) async {
if (_disposed) return;
if (_socket?.state == SocketState.connecting ||
_socket?.state == SocketState.authenticating) {
return;
}
final generation = ++_connectionGeneration;
state = SessionState(
status: SessionStatus.connecting,
status: _hasConnectedOnce
? SessionStatus.reconnecting
: SessionStatus.connecting,
reconnectAttempt: state.reconnectAttempt,
);
_socket?.dispose();
_socket = RelaySocket(
final socket = _socketFactory(
wsUrl: config.wsUrl,
nsec: config.nsec,
onMessage: _handleMessage,
onConnected: _handleConnected,
onDisconnected: _handleDisconnected,
onMessage: (message) {
if (generation == _connectionGeneration) _handleMessage(message);
},
onConnected: () => _handleConnected(generation),
onDisconnected: (error) => _handleDisconnected(generation, error),
);
_socket = socket;
await _socket!.connect();
await socket.connect();
}
void _handleConnected() {
if (_disposed) return;
void _handleConnected(int generation) {
if (_disposed || generation != _connectionGeneration) return;
_hasConnectedOnce = true;
_reconnectDelayMs = _baseReconnectDelayMs;
state = const SessionState(status: SessionStatus.connected);
_replayLiveSubscriptions();
}
void _handleDisconnected(Object? error) {
if (_disposed) return;
void _handleDisconnected(int generation, Object? error) {
if (_disposed || generation != _connectionGeneration) return;
_cancelAllHistory(error);
_rejectAllPending(error);
_eventBuffer.clear();
_flushTimer?.cancel();
_flushTimer = null;
if (error is RelayAuthRejectedException) {
_reconnectTimer?.cancel();
state = const SessionState(status: SessionStatus.disconnected);
return;
}
_scheduleReconnect();
}
void _scheduleReconnect() {
if (_disposed) return;
if (_liveSubscriptions.isEmpty) {
state = const SessionState(status: SessionStatus.disconnected);
return;
}
if (_disposed || _paused) return;
final attempt = state.reconnectAttempt + 1;
state = SessionState(
status: SessionStatus.reconnecting,
@@ -586,6 +624,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
void _dispose() {
_disposed = true;
_connectionGeneration++;
_reconnectTimer?.cancel();
_flushTimer?.cancel();
_backgroundGraceTimer?.cancel();
+15 -1
View File
@@ -14,6 +14,20 @@ import 'nostr_models.dart';
/// Does NOT handle reconnection — that is [RelaySessionNotifier]'s job.
enum SocketState { disconnected, connecting, authenticating, connected }
class RelayAuthRejectedException implements Exception {
final String message;
const RelayAuthRejectedException(this.message);
@override
String toString() => 'Relay authentication rejected: $message';
}
Exception classifyRelayAuthFailure(String message) {
if (message.startsWith('error:')) return Exception(message);
return RelayAuthRejectedException(message);
}
class RelaySocket {
final String _wsUrl;
final String? _nsec;
@@ -222,7 +236,7 @@ class RelaySocket {
final message = data.length > 3
? data[3] as String
: 'Auth rejected by relay';
_failAuth(Exception(message));
_failAuth(classifyRelayAuthFailure(message));
}
return;
}
@@ -242,6 +242,95 @@ void main() {
},
);
test(
'keeps cached channels and live subscriptions during reconnect',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
final initial = await container.read(channelsProvider.future);
expect(initial.single.name, 'general');
expect(session.subscribeFilters, hasLength(1));
session.setStatus(SessionStatus.reconnecting);
final reconnecting = await container.read(channelsProvider.future);
expect(reconnecting.single.name, 'general');
expect(session.subscribeFilters, hasLength(1));
expect(session.unsubscribeCount, 0);
},
);
test(
'refreshes cached channels after a disconnected workspace switch',
() async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
expect(
(await container.read(channelsProvider.future)).single.name,
'general',
);
session.setStatus(SessionStatus.disconnected);
session.memberships = [_membership(_channelB, myPk)];
session.metadata = [_meta(id: _channelB, name: 'random')];
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://new-workspace.example');
await Future<void>.delayed(Duration.zero);
expect(container.read(channelsProvider).value?.single.name, 'general');
session.setStatus(SessionStatus.connected);
await Future<void>.delayed(Duration.zero);
expect(container.read(channelsProvider).value?.single.name, 'random');
},
);
test('recovers an initial fetch failure after reconnecting', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
membershipFailures: 1,
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await expectLater(container.read(channelsProvider.future), throwsException);
session.setStatus(SessionStatus.reconnecting);
session.setStatus(SessionStatus.connected);
await Future<void>.delayed(Duration.zero);
final recovered = await container.read(channelsProvider.future);
expect(recovered.single.name, 'general');
});
test(
'preserves a successfully loaded empty list while disconnected',
() async {
final session = _FakeRelaySession(memberships: [], metadata: []);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
expect(await container.read(channelsProvider.future), isEmpty);
final fetchCount = session.historyFilters.length;
session.setStatus(SessionStatus.reconnecting);
expect(await container.read(channelsProvider.future), isEmpty);
expect(session.historyFilters, hasLength(fetchCount));
},
);
test('initial fetch issues membership + metadata queries', () async {
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
@@ -325,6 +414,7 @@ NostrEvent _meta({
ProviderContainer _buildContainer({required _FakeRelaySession session}) {
return ProviderContainer(
retry: (_, _) => null,
overrides: [
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
relaySessionProvider.overrideWith(() => session),
@@ -340,15 +430,18 @@ class _FakeRelaySession extends RelaySessionNotifier {
required this.memberships,
required this.metadata,
this.hiddenDmEvents = const [],
this.membershipFailures = 0,
});
final List<NostrEvent> memberships;
final List<NostrEvent> metadata;
List<NostrEvent> memberships;
List<NostrEvent> metadata;
final List<NostrEvent> hiddenDmEvents;
int membershipFailures;
final List<NostrFilter> historyFilters = [];
final List<NostrFilter> subscribeFilters = [];
final List<void Function(NostrEvent)> _listeners = [];
int unsubscribeCount = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@@ -359,7 +452,11 @@ class _FakeRelaySession extends RelaySessionNotifier {
Duration timeout = const Duration(seconds: 8),
}) async {
historyFilters.add(filter);
if (filter.kinds.contains(39002)) {
if (filter.kinds.contains(39002) && filter.tags['#p'] != null) {
if (membershipFailures > 0) {
membershipFailures--;
throw Exception('membership fetch failed');
}
// Membership query — return all memberships we have for this pubkey.
final myPk = filter.tags['#p']?.single;
return memberships
@@ -389,11 +486,16 @@ class _FakeRelaySession extends RelaySessionNotifier {
subscribeFilters.add(filter);
_listeners.add(onEvent);
return () {
unsubscribeCount++;
subscribeFilters.remove(filter);
_listeners.remove(onEvent);
};
}
void setStatus(SessionStatus status) {
state = SessionState(status: status);
}
/// Emit a live event to all subscribers.
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
@@ -184,9 +184,6 @@ class FakeAuthNotifier extends AsyncNotifier<AuthState>
state = const AsyncData(AuthState(status: AuthStatus.unauthenticated));
}
@override
Future<void> retry() async {}
@override
Future<void> authenticateWithWorkspace(Workspace workspace) async {
lastWorkspace = workspace;
@@ -0,0 +1,61 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/shared/auth/auth_provider.dart';
import 'package:buzz/shared/workspace/workspace.dart';
import 'package:buzz/shared/workspace/workspace_provider.dart';
import 'package:buzz/shared/workspace/workspace_storage.dart';
import '../workspace/workspace_storage_test.dart';
void main() {
test(
'removes an invalid saved workspace instead of authenticating',
() async {
final storage = WorkspaceStorage(secure: FakeSecureStorage());
final invalid = Workspace.create(
name: 'Invalid',
relayUrl: 'https://relay.example',
nsec: 'not-an-nsec',
);
await storage.save(invalid);
await storage.saveActiveId(invalid.id);
final container = ProviderContainer(
overrides: [workspaceStorageProvider.overrideWithValue(storage)],
);
addTearDown(container.dispose);
final auth = await container.read(authProvider.future);
expect(auth.status, AuthStatus.unauthenticated);
expect(await storage.loadAll(), isEmpty);
expect(await storage.loadActiveId(), isNull);
},
);
test('falls through to the next valid saved workspace', () async {
final storage = WorkspaceStorage(secure: FakeSecureStorage());
final invalid = Workspace.create(
name: 'Invalid',
relayUrl: 'https://invalid.example',
);
final valid = Workspace.create(
name: 'Valid',
relayUrl: 'https://valid.example',
nsec: nostr.Keys.generate().nsec,
);
await storage.save(invalid);
await storage.save(valid);
await storage.saveActiveId(invalid.id);
final container = ProviderContainer(
overrides: [workspaceStorageProvider.overrideWithValue(storage)],
);
addTearDown(container.dispose);
final auth = await container.read(authProvider.future);
expect(auth.status, AuthStatus.authenticated);
expect(auth.workspace?.id, valid.id);
expect(await storage.loadActiveId(), valid.id);
});
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
@@ -6,6 +7,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'package:buzz/shared/auth/auth_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
@@ -103,6 +105,161 @@ void main() {
);
});
test(
'history timeout rejects instead of returning partial empty data',
() async {
final session = RelaySessionNotifier();
await expectLater(
session.fetchHistory(
const NostrFilter(kinds: [39002]),
timeout: const Duration(milliseconds: 1),
),
throwsA(isA<TimeoutException>()),
);
},
);
test('background disconnect rejects in-flight history', () async {
final session = RelaySessionNotifier();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
final history = session.fetchHistory(
const NostrFilter(kinds: [39002]),
timeout: const Duration(seconds: 1),
);
final expectation = expectLater(history, throwsException);
session.debugPauseNow();
await expectation;
});
test('retries a dropped connected session without live subscriptions', () {
final session = RelaySessionNotifier();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
session.debugHandleConnected();
session.debugHandleDisconnected();
expect(session.state.status, SessionStatus.reconnecting);
expect(session.state.reconnectAttempt, 1);
});
test('classifies relay internal auth errors as transient', () {
expect(
classifyRelayAuthFailure(
'error: internal error checking restriction state',
),
isNot(isA<RelayAuthRejectedException>()),
);
expect(
classifyRelayAuthFailure('restricted: access revoked'),
isA<RelayAuthRejectedException>(),
);
});
test(
'stops reconnecting without deleting workspace after auth rejection',
() async {
final session = RelaySessionNotifier();
final auth = _FakeAuthNotifier();
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
authProvider.overrideWith(() => auth),
],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
session.debugHandleDisconnected(
const RelayAuthRejectedException('auth-required: verification failed'),
);
await Future<void>.delayed(Duration.zero);
expect(session.state.status, SessionStatus.disconnected);
expect(auth.signOutCount, 0);
},
);
test('ignores callbacks from a socket replaced by a config change', () async {
final sockets = <_ControlledRelaySocket>[];
final keychain = nostr.Keys.generate();
final session = RelaySessionNotifier(
socketFactory:
({
required wsUrl,
required nsec,
required onMessage,
required onConnected,
required onDisconnected,
}) {
final socket = _ControlledRelaySocket(
wsUrl: wsUrl,
nsec: nsec,
onMessage: onMessage,
onConnected: onConnected,
onDisconnected: onDisconnected,
);
sockets.add(socket);
return socket;
},
);
final config = _FakeRelayConfigNotifier(
baseUrl: 'https://old.example',
nsec: keychain.nsec,
);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
relayConfigProvider.overrideWith(() => config),
authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
],
);
addTearDown(container.dispose);
await container.read(authProvider.future);
final subscription = container.listen(relaySessionProvider, (_, _) {});
addTearDown(subscription.close);
await Future<void>.delayed(Duration.zero);
config.update(baseUrl: 'https://new.example', nsec: keychain.nsec);
await Future<void>.delayed(Duration.zero);
expect(sockets, hasLength(2));
sockets.first.disconnectWith(
const RelayAuthRejectedException('blocked: stale workspace'),
);
sockets.first.connectSuccessfully();
expect(session.state.status, SessionStatus.connecting);
sockets.last.connectSuccessfully();
expect(session.state.status, SessionStatus.connected);
});
test('does not schedule reconnects after background disconnect', () {
final session = RelaySessionNotifier();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
session.debugHandleConnected();
session.debugPauseNow();
session.debugHandleDisconnected();
expect(session.state.status, SessionStatus.disconnected);
});
test('delivers the same live event to each matching subscription', () async {
final session = RelaySessionNotifier();
final firstEvents = <NostrEvent>[];
@@ -193,6 +350,49 @@ void main() {
);
}
class _FakeAuthNotifier extends AuthNotifier {
int signOutCount = 0;
@override
Future<AuthState> build() async =>
const AuthState(status: AuthStatus.unauthenticated);
@override
Future<void> signOut() async {
signOutCount++;
}
}
class _AuthenticatedAuthNotifier extends AuthNotifier {
@override
Future<AuthState> build() async =>
const AuthState(status: AuthStatus.authenticated);
}
class _ControlledRelaySocket extends RelaySocket {
final void Function() _connected;
final void Function(Object? error) _disconnected;
_ControlledRelaySocket({
required super.wsUrl,
required super.nsec,
required super.onMessage,
required super.onConnected,
required super.onDisconnected,
}) : _connected = onConnected,
_disconnected = onDisconnected;
@override
Future<void> connect() async {}
@override
void dispose() {}
void connectSuccessfully() => _connected();
void disconnectWith(Object? error) => _disconnected(error);
}
const _channelId = '11111111-1111-4111-8111-111111111111';
class _FakeRelayConfigNotifier extends RelayConfigNotifier {