From 102cd0b810cf582b7421995ed33e90b5b43d0acf Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 16 Apr 2026 05:01:56 +0200 Subject: [PATCH] show decoded url's to the user --- apps/weblibre/lib/extensions/uri.dart | 55 +++++++++++++++ .../widgets/share_bottom_sheet.dart | 3 +- .../screens/unshortener_settings.dart | 3 +- .../tracking_protection_exceptions.dart | 3 +- .../widgets/doh_settings_content.dart | 3 +- .../presentation/select_feed_dialog.dart | 5 +- .../presentation/widgets/uri_breadcrumb.dart | 9 ++- apps/weblibre/test/extensions/uri_test.dart | 69 +++++++++++++++++++ 8 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 apps/weblibre/test/extensions/uri_test.dart diff --git a/apps/weblibre/lib/extensions/uri.dart b/apps/weblibre/lib/extensions/uri.dart index 53e29797..f407c008 100644 --- a/apps/weblibre/lib/extensions/uri.dart +++ b/apps/weblibre/lib/extensions/uri.dart @@ -41,4 +41,59 @@ extension UriX on Uri { } return this; } + + String get displayPath { + if (path.isEmpty) { + return ''; + } + + final segments = pathSegments.join('/'); + final buffer = StringBuffer(); + + if (path.startsWith('/')) { + buffer.write('/'); + } + buffer.write(segments); + if (path.length > 1 && path.endsWith('/')) { + buffer.write('/'); + } + + return buffer.toString(); + } + + String get displayString { + final buffer = StringBuffer(); + + if (scheme.isNotEmpty) { + buffer.write('$scheme:'); + } + if (authority.isNotEmpty) { + buffer + ..write('//') + ..write(authority); + } + buffer.write(displayPath); + if (query.isNotEmpty) { + buffer + ..write('?') + ..write(query); + } + if (fragment.isNotEmpty) { + buffer + ..write('#') + ..write(fragment); + } + + return buffer.toString(); + } +} + +extension UriStringX on String { + String get uriDisplayString { + final uri = Uri.tryParse(this); + if (uri == null || uri.toString() != this) { + return this; + } + return uri.displayString; + } } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart index f9bb349d..71c90af5 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart @@ -29,6 +29,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:share_plus/share_plus.dart'; import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; @@ -271,7 +272,7 @@ class _ShareHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - url, + url.uriDisplayString, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( diff --git a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart index 6d2d9a44..3c061955 100644 --- a/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart +++ b/apps/weblibre/lib/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart @@ -24,6 +24,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; @@ -203,7 +204,7 @@ class _AttributionLinkRow extends StatelessWidget { width: 96, child: Text(label, style: Theme.of(context).textTheme.bodySmall), ), - Expanded(child: Text(url, style: linkStyle)), + Expanded(child: Text(url.uriDisplayString, style: linkStyle)), const SizedBox(width: 8), Icon( Icons.open_in_new, diff --git a/apps/weblibre/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart b/apps/weblibre/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart index 9aec03da..66abeaf4 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/tracking_protection_exceptions.dart @@ -23,6 +23,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/repositories/tracking_protection.dart'; import 'package:weblibre/features/settings/presentation/dialogs/delete_all_exceptions_dialog.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -151,7 +152,7 @@ class _ExceptionTile extends StatelessWidget { leading: uri != null ? UrlIcon([uri], iconSize: 24) : const Icon(MdiIcons.shieldOutline), - title: Text(exception.url), + title: Text(exception.url.uriDisplayString), trailing: IconButton( icon: const Icon(Icons.close), onPressed: onDelete, diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/doh_settings_content.dart b/apps/weblibre/lib/features/settings/presentation/widgets/doh_settings_content.dart index 14a2a5c5..49298e2b 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/doh_settings_content.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/doh_settings_content.dart @@ -22,6 +22,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; @@ -112,7 +113,7 @@ class DohSettingsContent extends HookConsumerWidget { (provider) => RadioListTile.adaptive( value: provider.url, title: Text(provider.name), - subtitle: Text(provider.url), + subtitle: Text(provider.url.uriDisplayString), ), ) .toList(), diff --git a/apps/weblibre/lib/features/web_feed/presentation/select_feed_dialog.dart b/apps/weblibre/lib/features/web_feed/presentation/select_feed_dialog.dart index b655fdb4..d11fbeac 100644 --- a/apps/weblibre/lib/features/web_feed/presentation/select_feed_dialog.dart +++ b/apps/weblibre/lib/features/web_feed/presentation/select_feed_dialog.dart @@ -22,6 +22,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/web_feed/domain/providers.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; @@ -54,7 +55,7 @@ class SelectFeedDialog extends HookConsumerWidget { title: Text( data.feedData.title.whenNotEmpty ?? 'Unnamed Feed', ), - subtitle: Text(uri.toString()), + subtitle: Text(uri.displayString), trailing: const Icon(Icons.add), onTap: () { FeedCreateRoute(feedId: uri).pushReplacement(context); @@ -72,7 +73,7 @@ class SelectFeedDialog extends HookConsumerWidget { loading: () => Skeletonizer( child: ListTile( title: Text(BoneMock.title), - subtitle: Skeleton.keep(child: Text(uri.toString())), + subtitle: Skeleton.keep(child: Text(uri.displayString)), ), ), ); diff --git a/apps/weblibre/lib/presentation/widgets/uri_breadcrumb.dart b/apps/weblibre/lib/presentation/widgets/uri_breadcrumb.dart index b396c8bb..c53b7c14 100644 --- a/apps/weblibre/lib/presentation/widgets/uri_breadcrumb.dart +++ b/apps/weblibre/lib/presentation/widgets/uri_breadcrumb.dart @@ -20,6 +20,7 @@ import 'package:collection/collection.dart'; import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; +import 'package:weblibre/extensions/uri.dart'; class UriBreadcrumb extends StatelessWidget { final Uri uri; @@ -37,8 +38,10 @@ class UriBreadcrumb extends StatelessWidget { @override Widget build(BuildContext context) { + final pathSegments = uri.pathSegments; + return Tooltip( - message: uri.toString(), + message: uri.displayString, onTriggered: onTooltipTriggered, child: DefaultTextStyle( style: style ?? DefaultTextStyle.of(context).style, @@ -59,9 +62,9 @@ class UriBreadcrumb extends StatelessWidget { overflow: TextOverflow.visible, style: const TextStyle(fontWeight: FontWeight.bold), ), - if (uri.pathSegments.any((s) => s.isNotEmpty)) + if (pathSegments.any((s) => s.isNotEmpty)) Text( - ' › ${uri.pathSegments.whereNot((s) => s.isEmpty).join(' › ')}', + ' › ${pathSegments.whereNot((s) => s.isEmpty).join(' › ')}', maxLines: 1, softWrap: false, overflow: TextOverflow.visible, diff --git a/apps/weblibre/test/extensions/uri_test.dart b/apps/weblibre/test/extensions/uri_test.dart new file mode 100644 index 00000000..000822ca --- /dev/null +++ b/apps/weblibre/test/extensions/uri_test.dart @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:weblibre/extensions/uri.dart'; + +void main() { + group('Uri display formatting', () { + test('decodes path segments for read-only display', () { + final uri = Uri.parse( + 'https://example.com/hello%20world/caf%C3%A9?greeting=Ol%C3%A1%20mundo#frag%20ment', + ); + + expect( + uri.displayString, + 'https://example.com/hello world/café?greeting=Ol%C3%A1%20mundo#frag%20ment', + ); + expect(uri.pathSegments, ['hello world', 'café']); + }); + + test('uses decoded path segments', () { + final uri = Uri.parse('https://example.com/path%2Fwith%2Fslash'); + + expect(uri.displayString, 'https://example.com/path/with/slash'); + expect(uri.pathSegments, ['path/with/slash']); + }); + + test('keeps query encoded', () { + final uri = Uri.parse( + 'https://example.com/?q=fish%20%26%20chips&redirect=a%2Fb', + ); + + expect( + uri.displayString, + 'https://example.com/?q=fish%20%26%20chips&redirect=a%2Fb', + ); + }); + + test('uses authority as-is for display', () { + final uri = Uri.parse('https://user%20name@example.com/path'); + + expect(uri.authority, 'user%20name@example.com'); + }); + + test('leaves malformed escapes unchanged', () { + expect( + 'https://example.com/%ZZ?query=%'.uriDisplayString, + 'https://example.com/%ZZ?query=%', + ); + }); + }); +}