diff --git a/mobile/lib/features/channels/channel_detail_page/banners.dart b/mobile/lib/features/channels/channel_detail_page/banners.dart index 006705d3d..4af232cc3 100644 --- a/mobile/lib/features/channels/channel_detail_page/banners.dart +++ b/mobile/lib/features/channels/channel_detail_page/banners.dart @@ -79,7 +79,9 @@ class _DetailConnectionBanner extends StatelessWidget { ), child: Center( child: Text( - 'Connection lost — Retry', + state.lastError is RelayAuthRejectedException + ? 'Authentication rejected — Retry' + : 'Connection lost — Retry', style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index 09abc6a2e..36f129f7f 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -125,12 +125,16 @@ class _ConnectionBanner extends StatelessWidget { } class _ConnectionLostBanner extends StatelessWidget { + final Object? error; final VoidCallback onRetry; - const _ConnectionLostBanner({required this.onRetry}); + const _ConnectionLostBanner({required this.error, required this.onRetry}); @override Widget build(BuildContext context) { + final message = error is RelayAuthRejectedException + ? 'Authentication rejected — Retry' + : 'Connection lost — Retry'; return Material( color: context.colors.surfaceContainerHighest, child: InkWell( @@ -139,7 +143,7 @@ class _ConnectionLostBanner extends StatelessWidget { height: _kBannerHeight, child: Center( child: Text( - 'Connection lost — Retry', + message, style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -169,11 +173,9 @@ class _OfflineView extends StatelessWidget { ? 'Authentication rejected by the relay' : "Can't reach your workspace"; final body = authRejected - ? (error as RelayAuthRejectedException).message + ? 'Check your workspace access or credentials, then retry.' : 'Check your network or VPN (e.g. Cloudflare WARP), then retry.'; - final detail = authRejected - ? relayHost - : '$relayHost\n${error ?? 'Connection failed'}'; + final detail = relayHost; return Center( child: Padding( diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index ce7867da2..15cf693ca 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -57,7 +57,10 @@ class _ChannelsBody extends StatelessWidget { left: 0, right: 0, child: connectionLost - ? _ConnectionLostBanner(onRetry: onRetryConnection) + ? _ConnectionLostBanner( + error: sessionState.lastError, + onRetry: onRetryConnection, + ) : showConnectionBanner ? _ConnectionBanner(state: sessionState) : const SizedBox.shrink(), diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 01f6f11d8..690d1d1a7 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -75,7 +75,10 @@ class ChannelsNotifier extends AsyncNotifier> { return; } if (next.status != SessionStatus.connected) return; - if (initiallyFailed) { + if (initiallyFailed || + (waitingForInitialConnection && + connected.isCompleted && + !_hasLoaded)) { ref.invalidateSelf(); } else if (waitingForInitialConnection && !_hasLoaded && diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index acb9378ea..d0fd8426e 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -156,6 +156,7 @@ class RelaySocket { } void _resetConnection() { + _failAuth(null); _state = SocketState.disconnected; _subscription?.cancel(); _subscription = null; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 011064ddf..228a9e73f 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -207,7 +207,10 @@ void main() { await tester.pump(); expect(find.text('Authentication rejected by the relay'), findsOneWidget); - expect(find.text('access revoked'), findsOneWidget); + expect( + find.text('Check your workspace access or credentials, then retry.'), + findsOneWidget, + ); expect(find.textContaining('Cloudflare WARP'), findsNothing); expect(find.byType(CircularProgressIndicator), findsNothing); }); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 0a10af2e7..a7d4710fc 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -264,6 +264,54 @@ void main() { }, ); + test( + 'fails a pending initial build and fully reloads after recovery', + () async { + final error = Exception('relay unavailable'); + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + initialState: const SessionState(status: SessionStatus.connecting), + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final pending = container.read(channelsProvider.future); + await Future.delayed(Duration.zero); + session.setStatus(SessionStatus.failed, lastError: error); + await expectLater(pending, throwsA(same(error))); + + session.setStatus(SessionStatus.connected); + await Future.delayed(Duration.zero); + + final recovered = await container.read(channelsProvider.future); + expect(recovered.single.name, 'general'); + expect(session.subscribeFilters, hasLength(1)); + }, + ); + + test('fully reloads after being built in a failed state', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + initialState: SessionState( + status: SessionStatus.failed, + lastError: Exception('relay unavailable'), + ), + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await expectLater(container.read(channelsProvider.future), throwsException); + + session.setStatus(SessionStatus.connected); + await Future.delayed(Duration.zero); + + final recovered = await container.read(channelsProvider.future); + expect(recovered.single.name, 'general'); + expect(session.subscribeFilters, hasLength(1)); + }); + test( 'keeps cached channels and live subscriptions during reconnect', () async { diff --git a/mobile/test/shared/relay/relay_socket_test.dart b/mobile/test/shared/relay/relay_socket_test.dart index 4efe4021f..5546a822b 100644 --- a/mobile/test/shared/relay/relay_socket_test.dart +++ b/mobile/test/shared/relay/relay_socket_test.dart @@ -5,6 +5,28 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; void main() { + test('disconnect completes a pending authentication attempt', () async { + final channel = _ReadyWebSocketChannel(); + final socket = RelaySocket( + wsUrl: 'wss://relay.example', + nsec: null, + onMessage: (_) {}, + onConnected: () => fail('socket must not connect'), + onDisconnected: (_) {}, + channelFactory: (_) => channel, + ); + + final connecting = socket.connect(); + await Future.delayed(Duration.zero); + expect(socket.state, SocketState.authenticating); + + await socket.disconnect(); + await connecting.timeout(const Duration(seconds: 1)); + + expect(socket.state, SocketState.disconnected); + expect(channel.closeCount, 1); + }); + test('hung handshake times out, closes, and reports disconnection', () async { final channel = _HungWebSocketChannel(); final disconnected = Completer(); @@ -26,6 +48,35 @@ void main() { }); } +class _ReadyWebSocketChannel implements WebSocketChannel { + final _controller = StreamController(); + int closeCount = 0; + + @override + Future get ready => Future.value(); + + @override + String? get protocol => null; + + @override + int? get closeCode => null; + + @override + String? get closeReason => null; + + @override + Stream get stream => _controller.stream; + + @override + late final WebSocketSink sink = _RecordingWebSocketSink( + _controller.sink, + () => closeCount++, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + class _HungWebSocketChannel implements WebSocketChannel { final _controller = StreamController(); final _ready = Completer();