diff --git a/lib/core/models/lantern_status.dart b/lib/core/models/lantern_status.dart index 7d6e236780..2575e536fd 100644 --- a/lib/core/models/lantern_status.dart +++ b/lib/core/models/lantern_status.dart @@ -42,7 +42,7 @@ class LanternStatus { factory LanternStatus.fromJson(Map json) { appLogger.info('LanternStatus.fromJson $json'); final VPNStatus status; - final String statusStr = json['status'].toLowerCase(); + final String statusStr = (json['status'] as String?)?.toLowerCase() ?? ''; if (statusStr == 'connected') { status = VPNStatus.connected; } else if (statusStr == 'disconnected') { diff --git a/lib/features/vpn/provider/vpn_notifier.dart b/lib/features/vpn/provider/vpn_notifier.dart index 940d751191..70f14fe150 100644 --- a/lib/features/vpn/provider/vpn_notifier.dart +++ b/lib/features/vpn/provider/vpn_notifier.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:io'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fpdart/fpdart.dart'; import 'package:lantern/core/common/common.dart'; @@ -18,13 +18,31 @@ part 'vpn_notifier.g.dart'; @Riverpod(keepAlive: true) class VpnNotifier extends _$VpnNotifier { + bool _hasStatusStreamEmission = false; + @override VPNStatus build() { - ref.read(lanternServiceProvider).isVPNConnected(); + unawaited(_hydrateInitialStatus()); ref.listen(vPNStatusProvider, (previous, next) { - final previousStatus = previous?.value?.status; - final nextStatus = next.value!.status; - final nextOrigin = next.value!.origin; + if (next.hasError) { + _hasStatusStreamEmission = true; + appLogger.error( + 'VPN status provider failed', + next.error, + next.stackTrace, + ); + state = VPNStatus.error; + return; + } + if (!next.hasValue) return; + final lanternStatus = next.value; + if (lanternStatus == null) return; + _hasStatusStreamEmission = true; + final previousStatus = previous?.hasValue == true + ? previous!.value?.status + : null; + final nextStatus = lanternStatus.status; + final nextOrigin = lanternStatus.origin; // [vpn-state-trace] hop=dart_applied — moment Riverpod fires the listener // after the FFI ReceivePort delivers a status. Pairs with ffi_to_port // (lantern-core) and ssehandler_flushed / sse_parsed / daemon_setstatus @@ -84,12 +102,44 @@ class VpnNotifier extends _$VpnNotifier { return VPNStatus.disconnected; } - Future> onVPNStateChange(BuildContext context) async { + Future _hydrateInitialStatus() async { + late final Either result; + try { + result = await ref.read(lanternServiceProvider).isVPNConnected(); + } catch (e, stackTrace) { + appLogger.error('Failed to hydrate VPN status', e, stackTrace); + if (ref.mounted && + !_hasStatusStreamEmission && + state == VPNStatus.disconnected) { + state = VPNStatus.error; + } + return; + } + if (!ref.mounted || _hasStatusStreamEmission) return; + result.fold( + (failure) { + appLogger.error( + 'Failed to hydrate VPN status: ${failure.error}', + failure.error, + ); + if (!_hasStatusStreamEmission && state == VPNStatus.disconnected) { + state = VPNStatus.error; + } + }, + (connected) { + if (!_hasStatusStreamEmission && state == VPNStatus.disconnected) { + state = connected ? VPNStatus.connected : VPNStatus.disconnected; + } + }, + ); + } + + Future> onVPNStateChange() async { if (state == VPNStatus.connecting || state == VPNStatus.disconnecting) { return Right(""); } appLogger.info("VPN State Change requested. Current state: $state"); - return state == VPNStatus.disconnected ? startVPN() : stopVPN(); + return state == VPNStatus.connected ? stopVPN() : startVPN(); } /// Starts the VPN connection. diff --git a/lib/features/vpn/provider/vpn_status_notifier.dart b/lib/features/vpn/provider/vpn_status_notifier.dart index 141404b7c3..06bd76bafc 100644 --- a/lib/features/vpn/provider/vpn_status_notifier.dart +++ b/lib/features/vpn/provider/vpn_status_notifier.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/lantern_status.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -7,7 +10,22 @@ part 'vpn_status_notifier.g.dart'; @Riverpod(keepAlive: true) class VPNStatusNotifier extends _$VPNStatusNotifier { @override - Stream build() async* { - yield* ref.read(lanternServiceProvider).watchVPNStatus(); + Stream build() { + final statusStream = ref.read(lanternServiceProvider).watchVPNStatus(); + return statusStream.transform( + StreamTransformer.fromHandlers( + handleData: (status, sink) => sink.add(status), + handleError: (e, stackTrace, sink) { + appLogger.error('VPN status stream failed', e, stackTrace); + sink.add( + LanternStatus( + status: VPNStatus.error, + error: e.toString(), + origin: VPNStatusOrigin.system, + ), + ); + }, + ), + ); } } diff --git a/lib/features/vpn/vpn_switch.dart b/lib/features/vpn/vpn_switch.dart index 78d0a8076d..42b04835ab 100644 --- a/lib/features/vpn/vpn_switch.dart +++ b/lib/features/vpn/vpn_switch.dart @@ -87,24 +87,22 @@ class VPNSwitch extends HookConsumerWidget { if (PlatformUtils.isMacOS) { final systemExtensionStatus = ref.read(macosExtensionProvider); if (!systemExtensionStatus.isReady) { - appRouter.push(const MacOSExtensionDialog()).then((value) { + final value = await appRouter.push(const MacOSExtensionDialog()); + if (!context.mounted) return; + appLogger.info( + 'Returned from MacOS Extension Dialog with value: $value', + ); + if (value is bool && value) { appLogger.info( - 'Returned from MacOS Extension Dialog with value: $value', + 'Retrying VPN state change after MacOS Extension setup', ); - if (value != null && value as bool) { - appLogger.info( - 'Retrying VPN state change after MacOS Extension setup', - ); - onVPNStateChange(ref, context); - } - }); + return onVPNStateChange(ref, context); + } return; } } - final result = await ref - .read(vpnProvider.notifier) - .onVPNStateChange(context); + final result = await ref.read(vpnProvider.notifier).onVPNStateChange(); if (!context.mounted) return; result.fold((failure) { diff --git a/test/features/vpn/provider/vpn_notifier_test.dart b/test/features/vpn/provider/vpn_notifier_test.dart new file mode 100644 index 0000000000..6a2999b99e --- /dev/null +++ b/test/features/vpn/provider/vpn_notifier_test.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/lantern_status.dart'; +import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; +import 'package:lantern/features/vpn/provider/vpn_status_notifier.dart'; +import 'package:lantern/lantern/lantern_service.dart'; +import 'package:lantern/lantern/lantern_service_notifier.dart'; + +class _FakeLanternService implements LanternService { + final statusController = StreamController.broadcast(); + + Either isConnectedResult = right(false); + Completer>? isConnectedCompleter; + Object? isConnectedException; + int isVPNConnectedCalls = 0; + int startVPNCalls = 0; + int stopVPNCalls = 0; + + @override + Future> isVPNConnected() async { + isVPNConnectedCalls += 1; + if (isConnectedException != null) { + throw isConnectedException!; + } + final completer = isConnectedCompleter; + if (completer != null) { + return completer.future; + } + return isConnectedResult; + } + + @override + Stream watchVPNStatus() => statusController.stream; + + @override + Future> startVPN() async { + startVPNCalls += 1; + return right('ok'); + } + + @override + Future> stopVPN() async { + stopVPNCalls += 1; + return right('ok'); + } + + @override + Future checkVpnConflict() async => false; + + @override + Future isTagAvailable(String tag) async => true; + + Future dispose() => statusController.close(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +ProviderContainer _container(_FakeLanternService service) { + return ProviderContainer( + overrides: [ + lanternServiceProvider.overrideWithValue(service), + serverLocationProvider.overrideWithValue(initialServerLocation()), + ], + ); +} + +void _disposeContainerAndService( + ProviderContainer container, + _FakeLanternService service, +) { + addTearDown(() async { + container.dispose(); + await service.dispose(); + }); +} + +Future _pumpProviderQueue() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +void main() { + group('VpnNotifier', () { + test('hydrates initial connected state from core', () async { + final service = _FakeLanternService()..isConnectedResult = right(true); + final container = _container(service); + _disposeContainerAndService(container, service); + + expect(container.read(vpnProvider), VPNStatus.disconnected); + + await _pumpProviderQueue(); + + expect(service.isVPNConnectedCalls, 1); + expect(container.read(vpnProvider), VPNStatus.connected); + }); + + test('stream status wins over slower initial hydration', () async { + final service = _FakeLanternService() + ..isConnectedCompleter = Completer>(); + final container = _container(service); + _disposeContainerAndService(container, service); + final statusSub = container.listen>( + vPNStatusProvider, + (previous, next) {}, + fireImmediately: true, + ); + addTearDown(statusSub.close); + + container.read(vpnProvider); + await _pumpProviderQueue(); + service.statusController.add( + LanternStatus(status: VPNStatus.disconnected), + ); + await _pumpProviderQueue(); + + expect(container.read(vpnProvider), VPNStatus.disconnected); + + service.isConnectedCompleter!.complete(right(true)); + await _pumpProviderQueue(); + + expect(container.read(vpnProvider), VPNStatus.disconnected); + }); + + test('thrown hydration failures become VPNStatus.error', () async { + final service = _FakeLanternService() + ..isConnectedException = StateError('hydration failed'); + final container = _container(service); + _disposeContainerAndService(container, service); + + expect(container.read(vpnProvider), VPNStatus.disconnected); + + await _pumpProviderQueue(); + + expect(service.isVPNConnectedCalls, 1); + expect(container.read(vpnProvider), VPNStatus.error); + }); + + test( + 'starts VPN from an error state because the switch displays off', + () async { + final service = _FakeLanternService(); + final container = _container(service); + _disposeContainerAndService(container, service); + final statusSub = container.listen>( + vPNStatusProvider, + (previous, next) {}, + fireImmediately: true, + ); + addTearDown(statusSub.close); + + container.read(vpnProvider); + await _pumpProviderQueue(); + service.statusController.add( + LanternStatus(status: VPNStatus.error, error: 'connect failed'), + ); + await _pumpProviderQueue(); + + expect(container.read(vpnProvider), VPNStatus.error); + + final result = await container + .read(vpnProvider.notifier) + .onVPNStateChange(); + + expect(result.isRight(), isTrue); + expect(service.startVPNCalls, 1); + expect(service.stopVPNCalls, 0); + }, + ); + + test( + 'status stream errors become VPNStatus.error instead of crashing', + () async { + final service = _FakeLanternService(); + final container = _container(service); + _disposeContainerAndService(container, service); + final statusSub = container.listen>( + vPNStatusProvider, + (previous, next) {}, + fireImmediately: true, + ); + addTearDown(statusSub.close); + + container.read(vpnProvider); + await _pumpProviderQueue(); + service.statusController.addError(StateError('status stream failed')); + await _pumpProviderQueue(); + + expect(container.read(vpnProvider), VPNStatus.error); + final status = container.read(vPNStatusProvider); + expect(status.hasValue, isTrue); + expect(status.value?.status, VPNStatus.error); + expect(status.value?.error, contains('status stream failed')); + + service.statusController.add( + LanternStatus( + status: VPNStatus.disconnected, + origin: VPNStatusOrigin.settingsMutation, + ), + ); + await _pumpProviderQueue(); + + expect(container.read(vpnProvider), VPNStatus.disconnected); + final recoveredStatus = container.read(vPNStatusProvider); + expect(recoveredStatus.hasValue, isTrue); + expect(recoveredStatus.value?.status, VPNStatus.disconnected); + }, + ); + }); +}