mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user