Pull request 51: TRUST-136 routing profile validation fix

Squashed commit of the following:

commit 453267837d6777a257d30375e761593d813bca8d
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Wed Dec 10 12:28:11 2025 +0200

    typo fix

commit d77bda94cc8fb9956a70caa2e14cc005c4405c05
Merge: 75eea68 b945f3e
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Wed Dec 10 12:06:16 2025 +0200

    Merge branch 'master' of ssh://bit.int.agrd.dev:7999/adguard-core-libs/vpn-oss-gui into bugfix/TRUST-136-routing-profile-validation-fix

commit 75eea6878f724bf1fb0595b28810be34f7ac8f9d
Merge: 2f2951a 92dc049
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Tue Dec 9 16:08:14 2025 +0200

    Merge branch 'master' of ssh://bit.int.agrd.dev:7999/adguard-core-libs/vpn-oss-gui into bugfix/TRUST-136-routing-profile-validation-fix

commit 2f2951a1b094dcc0faf2c4d852aa731765c2d6c5
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Tue Dec 9 13:39:59 2025 +0200

    routing details validation fixed

commit d15ba04f2efce5d59b6530a3eb2f5365ac7d0e1a
Merge: 729a4ba 33ec26b
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Tue Dec 9 13:10:57 2025 +0200

    Merge branch 'master' of ssh://bit.int.agrd.dev:7999/adguard-core-libs/vpn-oss-gui into bugfix/TRUST-136-routing-profile-validation-fix

commit 729a4ba90f8f99010bb604e30d57dd96977d3fec
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Tue Dec 9 09:36:24 2025 +0200

    domain validation fixed in parsing method

commit 5184cb3845672d443b50cf234749dd967115be5b
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 20:54:44 2025 +0200

    fix missing code part

commit e931458386ac7c193134577a6793cddc72e7d182
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 20:49:55 2025 +0200

    profile routes refactoring

commit e6f0130fd591bbc80a8140b97a5bd6a017682fea
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 20:21:10 2025 +0200

    wildcard update

commit b1233b7419bdd12a9f559da513f9e48a05603a13
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 19:45:13 2025 +0200

    wildcard update

commit be04e7b87f8ea7c5303a3281711a174a02187cb0
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 19:33:41 2025 +0200

    cidr validation fix

commit 3dfadc9ea4e1a2ba53507d4d97f9e5281b86e871
Merge: ea6bd89 d20019a
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 18:27:06 2025 +0200

    Merge branch 'bugfix/TRUST-130-address-field-fix' of ssh://bit.int.agrd.dev:7999/adguard-core-libs/vpn-oss-gui into bugfix/TRUST-136-routing-profile-validation-fix

commit ea6bd89998c1888335912ed3248d39224f5f9610
Merge: 3c2d0c5 fddefaf
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 18:21:26 2025 +0200

    Merge branch 'master' of ssh://bit.int.agrd.dev:7999/adguard-core-libs/vpn-oss-gui into bugfix/TRUST-136-routing-profile-validation-fix

commit d20019a9329d2b1f65b4f0bbc75a52c90fd07b24
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 18:20:52 2025 +0200

    dns addresses fix

commit 4fed2c4eed335502fa398111c4169f03980ffa93
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 15:47:12 2025 +0200

    fixed server modification

commit 185cca84bfb59bb10b802e9a4e397390a4b0617b
Merge: ff0ecfd fddefaf
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Mon Dec 8 14:54:53 2025 +0200

    Merge branch 'master' of ssh://bit.int.agrd.dev:7999/adguard-core-libs/vpn-oss-gui into bugfix/TRUST-130-address-field-fix

commit ff0ecfd2cd5afa05bedaf9b134478fe79c034b80
Merge: b01fb28 cde86b9
Author: Atlassian Bamboo <bamboo@example.com>
Date:   Fri Dec 5 20:55:55 2025 +0300

    [bamboo] Automated branch merge (from master:cde86b9302ae37f8cacb70f44440d67e0f32ab57)

commit b01fb28bf856410f0f03e8d451642708220ef3a6
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Fri Dec 5 19:50:27 2025 +0200

    parsing fixed

commit 3c2d0c5560bbe164cf7bd6129e7200c7e66ce763
Merge: 1821ceb ebdaed1
Author: Atlassian Bamboo <bamboo@example.com>
Date:   Fri Dec 5 15:58:00 2025 +0300

    [bamboo] Automated branch merge (from master:ebdaed157bcab6af1cb0a1a82805018ab18f9991)

commit 1821cebe3a1cc9112c1fce4c82c6d7412f1b5da6
Author: Konstantin Gorynin <k.goryinin@adguard.com>
Date:   Fri Dec 5 14:46:06 2025 +0200

    validation fixed

... and 3 more commits
This commit is contained in:
Konstantin Gorynin
2025-12-10 14:03:21 +03:00
parent 3f92cece35
commit c354f91b55
7 changed files with 157 additions and 38 deletions
@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:vpn/common/extensions/model_extensions.dart';
import 'package:vpn/common/utils/upstream_protocol_encoder.dart';
import 'package:vpn/common/utils/validation_utils.dart';
@@ -58,8 +57,6 @@ class VpnDataSourceImpl implements VpnDataSource {
required RoutingProfile routingProfile,
required List<String> excludedRoutes,
}) {
final routingProfile = server.routingProfile;
final exclusions = _getExclusionsByMode(routingProfile);
final endPoint = Endpoint(
@@ -72,7 +69,6 @@ class VpnDataSourceImpl implements VpnDataSource {
],
exclusions: exclusions,
dnsUpStreams: server.dnsServers,
upStreamProtocol: UpStreamProtocolEncoder().convert(
server.vpnProtocol,
),
@@ -112,13 +108,29 @@ class VpnDataSourceImpl implements VpnDataSource {
case RoutingMode.vpn:
exclusions = profile.bypassRules;
}
final parsedExclusions = exclusions.map(ValidationUtils.tryParseDomain).nonNulls.toList();
final wildCard = '*.';
final Set<String> parsedDomains = {};
final Set<String> parsedAddresses = {};
for (final exclusion in exclusions) {
final domainValue = ValidationUtils.tryParseDomain(exclusion);
if (domainValue == null) {
parsedAddresses.add(exclusion);
continue;
}
parsedDomains.add(domainValue);
bool hasWildCard = domainValue.startsWith(wildCard);
if (!hasWildCard) {
parsedDomains.add('$wildCard$domainValue');
}
}
return {
...parsedExclusions,
...parsedExclusions.whereNot((e) => e.startsWith(wildCard)).map((e) => '$wildCard$e'),
...parsedAddresses,
...parsedDomains,
}.toList();
}
}
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:vpn/common/extensions/context_extensions.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:vpn/feature/routing/routing/bloc/routing_bloc.dart';
import 'package:vpn/feature/routing/routing/view/widget/routing_screen_view.dart';
@@ -14,7 +14,7 @@ class _RoutingScreenState extends State<RoutingScreen> {
@override
void initState() {
super.initState();
context.blocFactory.routingBloc().add(
context.read<RoutingBloc>().add(
const RoutingEvent.fetch(),
);
}
@@ -1,4 +1,5 @@
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:vpn/common/error/error_utils.dart';
@@ -172,7 +173,7 @@ class RoutingDetailsBloc extends Bloc<RoutingDetailsEvent, RoutingDetailsState>
_ChangeDefaultMode event,
Emitter<RoutingDetailsState> emit,
) async {
final updatedData = state.data.copyWith(
final updatedData = state.initialData.copyWith(
defaultMode: event.defaultMode,
);
@@ -184,7 +185,9 @@ class RoutingDetailsBloc extends Bloc<RoutingDetailsEvent, RoutingDetailsState>
emit(
state.copyWith(
initialData: updatedData,
data: updatedData,
data: state.data.copyWith(
defaultMode: event.defaultMode,
),
),
);
emit(state.copyWith(action: const RoutingDetailsAction.defaultModeChanged()));
@@ -14,7 +14,15 @@ sealed class RoutingDetailsState with _$RoutingDetailsState {
const RoutingDetailsState._();
bool get hasChanges => data != initialData;
bool get hasChanges {
print('''
Bypass equals ${listEquals(data.bypassRules, initialData.bypassRules)},
Vpn equals ${listEquals(data.vpnRules, initialData.vpnRules)},
Default mode equals ${data.defaultMode == initialData.defaultMode}
''');
return !listEquals(data.bypassRules, initialData.bypassRules) || !listEquals(data.vpnRules, initialData.vpnRules);
}
bool get isEditing => routingId != null;
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:vpn/common/extensions/context_extensions.dart';
import 'package:vpn/common/localization/extensions/locale_enum_extension.dart';
@@ -8,9 +9,40 @@ import 'package:vpn/feature/routing/routing_details/bloc/routing_details_bloc.da
import 'package:vpn/feature/routing/routing_details/domain/routing_spell_check_service.dart';
import 'package:vpn/view/inputs/custom_text_field.dart';
class RoutingDetailsForm extends StatelessWidget {
class RoutingDetailsForm extends StatefulWidget {
const RoutingDetailsForm({super.key});
@override
State<RoutingDetailsForm> createState() => _RoutingDetailsFormState();
}
class _RoutingDetailsFormState extends State<RoutingDetailsForm> {
bool _bypassValid = true;
bool _vpnValid = true;
late final SpellCheckService _bypassSpellCheckService;
late final SpellCheckService _vpnSpellCheckService;
@override
void initState() {
super.initState();
_bypassSpellCheckService = RoutingSpellCheckService(
onChecked: (spellValid) => _onDataChanged(
context,
RoutingMode.bypass,
spellValid: spellValid,
),
);
_vpnSpellCheckService = RoutingSpellCheckService(
onChecked: (spellValid) => _onDataChanged(
context,
RoutingMode.vpn,
spellValid: spellValid,
),
);
}
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(16.0),
@@ -47,17 +79,13 @@ class RoutingDetailsForm extends StatelessWidget {
value: state.data.vpnRules.join('\n'),
autofocus: true,
hint: context.ln.enterRulesHint,
spellCheckService: RoutingSpellCheckService(
onChecked: (spellValid) => _onDataChanged(
context,
hasInvalidRules: !spellValid,
),
),
spellCheckService: _vpnSpellCheckService,
minLines: 40,
maxLines: 40,
showClearButton: false,
onChanged: (vpnRules) => _onDataChanged(
context,
RoutingMode.vpn,
vpnRules: vpnRules.split('\n').map((r) => r.trim()).where((r) => r.isNotEmpty).toList(),
),
);
@@ -73,31 +101,36 @@ class RoutingDetailsForm extends StatelessWidget {
hint: context.ln.enterRulesHint,
minLines: 40,
maxLines: 40,
spellCheckService: RoutingSpellCheckService(
onChecked: (spellValid) => _onDataChanged(
context,
hasInvalidRules: !spellValid,
),
),
spellCheckService: _bypassSpellCheckService,
showClearButton: false,
onChanged: (bypassRules) => _onDataChanged(
context,
RoutingMode.bypass,
bypassRules: bypassRules.split('\n').map((r) => r.trim()).where((r) => r.isNotEmpty).toList(),
),
);
void _onDataChanged(
BuildContext context, {
RoutingMode? mode,
BuildContext context,
RoutingMode mode, {
List<String>? vpnRules,
List<String>? bypassRules,
bool? hasInvalidRules,
}) => context.read<RoutingDetailsBloc>().add(
RoutingDetailsEvent.dataChanged(
defaultMode: mode,
vpnRules: vpnRules,
bypassRules: bypassRules,
hasInvalidRules: hasInvalidRules,
),
);
bool? spellValid,
}) {
if (mode == RoutingMode.bypass) {
_bypassValid = spellValid ?? (_bypassValid || bypassRules?.isEmpty == true);
}
if (mode == RoutingMode.vpn) {
_vpnValid = spellValid ?? (_vpnValid || vpnRules?.isEmpty == true);
}
context.read<RoutingDetailsBloc>().add(
RoutingDetailsEvent.dataChanged(
vpnRules: vpnRules,
bypassRules: bypassRules,
hasInvalidRules: !(_vpnValid && _bypassValid),
),
);
}
}
@@ -1,14 +1,23 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:vpn/common/extensions/context_extensions.dart';
import 'package:vpn/common/localization/extensions/locale_enum_extension.dart';
import 'package:vpn/common/localization/localization.dart';
import 'package:vpn/data/model/routing_mode.dart';
import 'package:vpn/data/model/routing_profile.dart';
import 'package:vpn/data/model/vpn_state.dart';
import 'package:vpn/feature/routing/routing/bloc/routing_bloc.dart';
import 'package:vpn/feature/routing/routing/common/routing_profile_utils.dart';
import 'package:vpn/feature/routing/routing_details/bloc/routing_details_bloc.dart';
import 'package:vpn/feature/routing/routing_details/view/widget/routing_details_discard_changes_dialog.dart';
import 'package:vpn/feature/routing/routing_details/view/widget/routing_details_form.dart';
import 'package:vpn/feature/routing/routing_details/view/widget/routing_details_screen_app_bar_action.dart';
import 'package:vpn/feature/routing/routing_details/view/widget/routing_details_submit_button_section.dart';
import 'package:vpn/feature/server/servers/bloc/servers_bloc.dart';
import 'package:vpn/feature/settings/excluded_routes/bloc/excluded_routes_bloc.dart';
import 'package:vpn/feature/vpn/domain/entity/vpn_controller.dart';
import 'package:vpn/feature/vpn/widgets/vpn_scope.dart';
import 'package:vpn/view/custom_app_bar.dart';
import 'package:vpn/view/scaffold_wrapper.dart';
@@ -23,22 +32,55 @@ class RoutingDetailsScreenView extends StatelessWidget {
child: BlocConsumer<RoutingDetailsBloc, RoutingDetailsState>(
listenWhen: (previous, current) => current.action != const RoutingDetailsAction.none(),
listener: (innerContext, state) {
final vpnController = VpnScope.vpnControllerOf(context);
final serversBloc = context.read<ServersBloc>();
final excludedRoutesBloc = context.read<ExcludedRoutesBloc>();
final routingProfile = RoutingProfile(
id: state.routingId ?? -1,
name: state.routingName,
defaultMode: state.data.defaultMode,
bypassRules: state.data.bypassRules,
vpnRules: state.data.vpnRules,
);
switch (state.action) {
case RoutingDetailsPresentationError(:final error):
context.showInfoSnackBar(message: error.toLocalizedString(context));
case RoutingDetailsSaved():
onUpdated(
vpnController,
serversBloc,
routingProfile,
excludedRoutesBloc,
);
context.pop();
context.showInfoSnackBar(message: context.ln.changesSavedSnackbar);
case RoutingDetailsCreated(:final name):
context.pop();
context.showInfoSnackBar(message: context.ln.profileCreatedSnackbar(name));
case RoutingDetailsDeleted(:final name):
final defaultProfile = context.read<RoutingBloc>().state.routingList.firstWhere(
(element) => element.id == RoutingProfileUtils.defaultRoutingProfileId,
);
onUpdated(
vpnController,
serversBloc,
defaultProfile,
excludedRoutesBloc,
);
context.pop();
context.showInfoSnackBar(message: context.ln.profileDeletedSnackbar(name));
case RoutingDetailsCleared():
innerContext.showInfoSnackBar(message: context.ln.allRulesDeleted);
case RoutingDetailsDefaultModeChanged():
innerContext.showInfoSnackBar(message: context.ln.changesSavedSnackbar);
onUpdated(
vpnController,
serversBloc,
routingProfile,
excludedRoutesBloc,
);
default:
break;
}
@@ -84,6 +126,27 @@ class RoutingDetailsScreenView extends StatelessWidget {
),
);
void onUpdated(
VpnController controller,
ServersBloc bloc,
RoutingProfile profile,
ExcludedRoutesBloc excludedRoutesBloc,
) {
final selectedServer = bloc.state.serverList.firstWhereOrNull((server) => server.id == bloc.state.selectedServerId);
if (selectedServer != null) {
bool picked = selectedServer.routingProfile.id == profile.id;
bool running = controller.state != VpnState.disconnected;
if (picked && running) {
controller.start(
server: selectedServer,
routingProfile: profile,
excludedRoutes: excludedRoutesBloc.state.excludedRoutes,
);
}
}
}
void _showNotSavedChangesWarning(BuildContext context) => showDialog(
context: context,
builder: (_) => RoutingDetailsDiscardChangesDialog(
@@ -25,7 +25,7 @@ class _Button extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocBuilder<RoutingDetailsBloc, RoutingDetailsState>(
buildWhen: (previous, current) => previous.action == current.action,
builder: (context, state) => FilledButton(
builder: (context,state) => FilledButton(
onPressed: state.hasChanges && !state.hasInvalidRules ? () => _addRouting(context) : null,
child: Text(
state.isEditing ? context.ln.save : context.ln.add,