fix(mobile): address offline state review feedback

Ensure interrupted authentication completes, fully reload channels after terminal recovery, avoid exposing raw connection errors, and distinguish auth rejection in cached banners.

Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e
2026-07-14 11:42:29 -07:00
parent b1dbe3e40b
commit 336a1a0b7a
8 changed files with 123 additions and 10 deletions
@@ -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,
),
@@ -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(
@@ -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(),
@@ -75,7 +75,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
return;
}
if (next.status != SessionStatus.connected) return;
if (initiallyFailed) {
if (initiallyFailed ||
(waitingForInitialConnection &&
connected.isCompleted &&
!_hasLoaded)) {
ref.invalidateSelf();
} else if (waitingForInitialConnection &&
!_hasLoaded &&
@@ -156,6 +156,7 @@ class RelaySocket {
}
void _resetConnection() {
_failAuth(null);
_state = SocketState.disconnected;
_subscription?.cancel();
_subscription = null;
@@ -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);
});
@@ -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<void>.delayed(Duration.zero);
session.setStatus(SessionStatus.failed, lastError: error);
await expectLater(pending, throwsA(same(error)));
session.setStatus(SessionStatus.connected);
await Future<void>.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<void>.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 {
@@ -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<void>.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<Object?>();
@@ -26,6 +48,35 @@ void main() {
});
}
class _ReadyWebSocketChannel implements WebSocketChannel {
final _controller = StreamController<dynamic>();
int closeCount = 0;
@override
Future<void> get ready => Future.value();
@override
String? get protocol => null;
@override
int? get closeCode => null;
@override
String? get closeReason => null;
@override
Stream<dynamic> 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<dynamic>();
final _ready = Completer<void>();