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
18 changes: 18 additions & 0 deletions assets/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ msgstr "Automatically chooses fastest location"
msgid "pro_locations"
msgstr "Pro locations:"

msgid "currently_unavailable"
msgstr "Currently Unavailable:"

msgid "choose_your_location_with_lantern_pro"
msgstr "Choose your location with Lantern Pro:"

Expand Down Expand Up @@ -934,6 +937,21 @@ msgstr "Private Servers"
msgid "fastest_server"
msgstr "Fastest Server"

msgid "server_may_be_unreachable"
msgstr "May be unreachable"

msgid "server_may_be_unreachable_title"
msgstr "This Server may be unreachable"

msgid "server_may_be_unreachable_message"
msgstr "This Server may not work from your current network. Use Smart Location to pick a working server automatically."

msgid "use_smart_location"
msgstr "Use Smart Location"

msgid "try_anyway"
msgstr "Try Anyway"
Comment thread
atavism marked this conversation as resolved.

msgid "join_my_private_server"
msgstr "Join My Lantern Private Server"

Expand Down
142 changes: 122 additions & 20 deletions lib/core/models/available_servers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,75 @@ class AvailableServers {

List<Server> get userServers => servers.where((s) => !s.isLantern).toList();

/// One representative Lantern server per country/city. If any server for the
/// location has a successful probe, use the fastest one; otherwise keep a
/// deterministic representative so the location can be shown as unavailable.
List<Server> get lanternServerLocations {
final grouped = <String, List<Server>>{};
for (final server in lanternServers) {
grouped.putIfAbsent(_locationKey(server), () => <Server>[]).add(server);
}

return grouped.values.map(_bestServerForLocation).toList()
..sort(_compareServersByLocation);
}

bool get hasUserServers => servers.any((s) => !s.isLantern);

/// Lantern server with the lowest URL-test delay. Null when no server has
/// a usable probe result — sing-box reports delay 0 for unreachable probes,
/// so those are excluded.
Server? serverByTag(String tag) {
for (final server in servers) {
if (server.tag == tag) return server;
}
return null;
}

/// Lantern server with the lowest successful probe delay. Null when no
/// Lantern server has a successful probe.
Server? get fastestLanternServer {
final ranked = lanternServers
.where((s) => s.urlTestResult != null && s.urlTestResult!.delay > 0)
.toList()
final ranked = lanternServers.where((s) => s.hasSuccessfulProbe).toList()
..sort(
(a, b) => a.urlTestResult!.delay.compareTo(b.urlTestResult!.delay),
(a, b) => a.selectionHistory!.lastSuccessDelayMs.compareTo(
b.selectionHistory!.lastSuccessDelayMs,
),
);
return ranked.isEmpty ? null : ranked.first;
}
}

String _locationKey(Server server) {
final location = server.location;
return [
location.countryCode.trim().toUpperCase(),
location.country.trim().toLowerCase(),
location.city.trim().toLowerCase(),
].join('|');
}

Server _bestServerForLocation(List<Server> servers) {
final successful = servers.where((s) => s.hasSuccessfulProbe).toList();
if (successful.isNotEmpty) {
successful.sort((a, b) {
final delay = a.selectionHistory!.lastSuccessDelayMs.compareTo(
b.selectionHistory!.lastSuccessDelayMs,
);
if (delay != 0) return delay;
return a.tag.compareTo(b.tag);
});
return successful.first;
}

final sorted = [...servers]..sort(_compareServersByLocation);
return sorted.first;
}

int _compareServersByLocation(Server a, Server b) {
final country = a.location.country.compareTo(b.location.country);
if (country != 0) return country;
final city = a.location.city.compareTo(b.location.city);
if (city != 0) return city;
return a.tag.compareTo(b.tag);
}

class Server {
final String tag;
final String type;
Expand All @@ -35,7 +88,7 @@ class Server {
final Map<String, dynamic>? endpoint;
final GeoLocation location;
final ServerCredential? credentials;
final UrlTestResult? urlTestResult;
final SelectionHistory? selectionHistory;

Server({
required this.tag,
Expand All @@ -45,7 +98,7 @@ class Server {
this.endpoint,
required this.location,
this.credentials,
this.urlTestResult,
this.selectionHistory,
});

factory Server.fromJson(Map<String, dynamic> json) => Server(
Expand All @@ -60,14 +113,21 @@ class Server {
credentials: json['credentials'] != null
? ServerCredential.fromJson(json['credentials'] as Map<String, dynamic>)
: null,
urlTestResult: json["urlTestResult"] == null
selectionHistory: json["selection_history"] == null
? null
: UrlTestResult.fromJson(json["urlTestResult"]),
: SelectionHistory.fromJson(json["selection_history"]),
);

/// IP address extracted from outbound or endpoint options.
String get serverIP =>
outbound?['server'] as String? ?? endpoint?['server'] as String? ?? '';

bool get hasSuccessfulProbe =>
(selectionHistory?.lastSuccessDelayMs ?? 0) > 0;

/// Manual mode pins traffic to exactly this server. If Smart Location has no
/// successful probe for it, warn before pinning so users can stay on auto.
bool get shouldWarnBeforeManualSelection => isLantern && !hasSuccessfulProbe;
}

class GeoLocation {
Expand Down Expand Up @@ -113,17 +173,59 @@ class ServerCredential {
);
}

class UrlTestResult {
int delay;
DateTime time;

UrlTestResult({required this.delay, required this.time});
/// SelectionHistory mirrors radiance's per-server selection history. Probe
/// outcomes feed [lastSuccessDelayMs]/[consecutiveFailures]; real user-traffic
/// failures feed [userFailures], kept separate so a censor that passes the
/// probe URL while dropping user traffic is still visible.
class SelectionHistory {
/// Most recent successful probe RTT in milliseconds. 0 means no successful
/// probe yet, used as the sentinel by [Server.hasSuccessfulProbe].
final int lastSuccessDelayMs;

/// Timestamp of the most recent probe outcome, success or failure.
final DateTime? lastOutcomeAt;

/// Probe failures since the last probe success; resets on success.
final int consecutiveFailures;

/// Sliding window of user-traffic failure timestamps. Probe successes never
/// enter this window.
final List<DateTime> userFailures;

final DateTime? updatedAt;

SelectionHistory({
this.lastSuccessDelayMs = 0,
this.lastOutcomeAt,
this.consecutiveFailures = 0,
this.userFailures = const [],
this.updatedAt,
});

factory UrlTestResult.fromJson(Map<String, dynamic> json) =>
UrlTestResult(delay: json["delay"], time: DateTime.parse(json["time"]));
factory SelectionHistory.fromJson(Map<String, dynamic> json) =>
SelectionHistory(
lastSuccessDelayMs: json["last_success_delay_ms"] ?? 0,
lastOutcomeAt: json["last_outcome_at"] == null
? null
: DateTime.parse(json["last_outcome_at"]),
consecutiveFailures: json["consecutive_failures"] ?? 0,
userFailures:
(json["user_failures"] as List<dynamic>?)
?.map((e) => DateTime.parse(e as String))
.toList() ??
const [],
updatedAt: json["updated_at"] == null
? null
: DateTime.parse(json["updated_at"]),
);

Map<String, dynamic> toJson() => {
"delay": delay,
"time": time.toIso8601String(),
"last_success_delay_ms": lastSuccessDelayMs,
if (lastOutcomeAt != null)
"last_outcome_at": lastOutcomeAt!.toIso8601String(),
"consecutive_failures": consecutiveFailures,
if (userFailures.isNotEmpty)
"user_failures": userFailures.map((e) => e.toIso8601String()).toList(),
if (updatedAt != null) "updated_at": updatedAt!.toIso8601String(),
};
}
7 changes: 7 additions & 0 deletions lib/features/home/provider/app_event_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ class AppEventNotifier extends _$AppEventNotifier {

break;
case 'server-location':
// The selected server event is also our current Lantern-only signal
// that Smart Location probe data may have changed.
unawaited(
ref
.read(availableServersProvider.notifier)
.forceFetchAvailableServers(),
);
// Only consume this event when the user is actually in auto mode.
// Otherwise (custom server selected) ignore it — applying it would
// silently flip the user's selection back to Smart Location on
Expand Down
24 changes: 19 additions & 5 deletions lib/features/vpn/location_setting.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lantern/core/widgets/setting_tile.dart';
import 'package:lantern/features/vpn/provider/available_servers_notifier.dart';
import 'package:lantern/features/vpn/provider/server_location_notifier.dart';
import 'package:lantern/features/vpn/server_reachability.dart';

import '../../core/common/common.dart';

Expand All @@ -12,6 +14,15 @@ class LocationSetting extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final serverLocation = ref.watch(serverLocationProvider);
final serverType = serverLocation.serverType.toServerLocationType;
final selectedServer = ref
.watch(availableServersProvider)
.maybeWhen(
data: (servers) => servers.serverByTag(serverLocation.serverName),
orElse: () => null,
);
final shouldWarnBeforeManualSelection =
serverType == ServerLocationType.lanternLocation &&
selectedServer?.shouldWarnBeforeManualSelection == true;

String title = '';
String value = '';
Expand Down Expand Up @@ -50,14 +61,17 @@ class LocationSetting extends HookConsumerWidget {
tileKey: const Key('home.location_setting'),
label: title,
value: value.i18n,
subtitle: protocol,
subtitle: shouldWarnBeforeManualSelection
? 'server_may_be_unreachable'.i18n
: protocol,
icon: flag.isEmpty ? AppImagePaths.location : Flag(countryCode: flag),
actions: [
if (serverType == ServerLocationType.auto)
AppImage(
path: AppImagePaths.blot,
useThemeColor: false,
),
AppImage(path: AppImagePaths.blot, useThemeColor: false),
if (shouldWarnBeforeManualSelection) ...[
serverReachabilityIcon(context),
const SizedBox(width: 8),
],
const SizedBox(width: 8),
IconButton(
onPressed: () => appRouter.push(const ServerSelection()),
Expand Down
46 changes: 39 additions & 7 deletions lib/features/vpn/provider/available_servers_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'available_servers_notifier.g.dart';

const _availableServersSettleReloadThrottle = Duration(seconds: 30);
const _availableServersSettleReloadDelay = Duration(seconds: 4);

@Riverpod(keepAlive: true)
class AvailableServersNotifier extends _$AvailableServersNotifier {
DateTime? _lastSettleReloadAt;
Future<void>? _settleReload;

@override
Future<AvailableServers> build() async {
final result = await fetchAvailableServers();
return result.fold(
(failure) {
appLogger.error(
'Error getting available servers: ${failure.error}',
);
appLogger.error('Error getting available servers: ${failure.error}');
throw Exception('Failed to load available servers');
},
(servers) {
Expand All @@ -39,9 +43,7 @@ class AvailableServersNotifier extends _$AvailableServersNotifier {
final result = await fetchAvailableServers();
result.fold(
(failure) {
appLogger.error(
'Error getting available servers: ${failure.error}',
);
appLogger.error('Error getting available servers: ${failure.error}');
},
(servers) {
state = AsyncValue.data(servers);
Expand All @@ -50,6 +52,36 @@ class AvailableServersNotifier extends _$AvailableServersNotifier {
);
}

/// Reloads available servers from the latest persisted Smart Location probe data.
Future<void> refreshAvailableServersAfterProbeSettle() async {
final now = DateTime.now();
final lastReload = _lastSettleReloadAt;
await forceFetchAvailableServers();

if (_settleReload != null) {
await _settleReload;
return;
}
if (lastReload != null &&
now.difference(lastReload) < _availableServersSettleReloadThrottle) {
return;
}
_lastSettleReloadAt = now;

final settleReload = Future<void>(() async {
await Future.delayed(_availableServersSettleReloadDelay);
await forceFetchAvailableServers();
});
_settleReload = settleReload;
try {
await settleReload;
} finally {
if (_settleReload == settleReload) {
_settleReload = null;
}
}
}

/// Pushes the fastest Lantern server to the Smart Location if the current selection is auto
void _pushFastestToSmartLocation(AvailableServers servers) {
final fastest = servers.fastestLanternServer;
Expand All @@ -65,7 +97,7 @@ class AvailableServersNotifier extends _$AvailableServersNotifier {
final city = fastest.location.city;
appLogger.debug(
'Pushing fastest server to Smart Location: '
'tag=${fastest.tag} delay=${fastest.urlTestResult?.delay}ms',
'tag=${fastest.tag} delay=${fastest.selectionHistory?.lastSuccessDelayMs}ms',
);
ref
.read(serverLocationProvider.notifier)
Expand Down
Loading
Loading