Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/core/models/lantern_status.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class LanternStatus {
factory LanternStatus.fromJson(Map<String, dynamic> 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') {
Expand Down
64 changes: 57 additions & 7 deletions lib/features/vpn/provider/vpn_notifier.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// [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
Expand Down Expand Up @@ -84,12 +102,44 @@ class VpnNotifier extends _$VpnNotifier {
return VPNStatus.disconnected;
}

Future<Either<Failure, String>> onVPNStateChange(BuildContext context) async {
Future<void> _hydrateInitialStatus() async {
late final Either<Failure, bool> 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;
}
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Future<Either<Failure, String>> 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.
Expand Down
22 changes: 20 additions & 2 deletions lib/features/vpn/provider/vpn_status_notifier.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -7,7 +10,22 @@ part 'vpn_status_notifier.g.dart';
@Riverpod(keepAlive: true)
class VPNStatusNotifier extends _$VPNStatusNotifier {
@override
Stream<LanternStatus> build() async* {
yield* ref.read(lanternServiceProvider).watchVPNStatus();
Stream<LanternStatus> 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,
),
);
},
),
);
}
}
22 changes: 10 additions & 12 deletions lib/features/vpn/vpn_switch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
215 changes: 215 additions & 0 deletions test/features/vpn/provider/vpn_notifier_test.dart
Original file line number Diff line number Diff line change
@@ -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<LanternStatus>.broadcast();

Either<Failure, bool> isConnectedResult = right(false);
Completer<Either<Failure, bool>>? isConnectedCompleter;
Object? isConnectedException;
int isVPNConnectedCalls = 0;
int startVPNCalls = 0;
int stopVPNCalls = 0;

@override
Future<Either<Failure, bool>> isVPNConnected() async {
isVPNConnectedCalls += 1;
if (isConnectedException != null) {
throw isConnectedException!;
}
final completer = isConnectedCompleter;
if (completer != null) {
return completer.future;
}
return isConnectedResult;
}

@override
Stream<LanternStatus> watchVPNStatus() => statusController.stream;

@override
Future<Either<Failure, String>> startVPN() async {
startVPNCalls += 1;
return right('ok');
}

@override
Future<Either<Failure, String>> stopVPN() async {
stopVPNCalls += 1;
return right('ok');
}

@override
Future<bool> checkVpnConflict() async => false;

@override
Future<bool> isTagAvailable(String tag) async => true;

Future<void> 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<void> _pumpProviderQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.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<Either<Failure, bool>>();
final container = _container(service);
_disposeContainerAndService(container, service);
final statusSub = container.listen<AsyncValue<LanternStatus>>(
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<AsyncValue<LanternStatus>>(
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<AsyncValue<LanternStatus>>(
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);
},
);
});
}
Loading