url tools initial

This commit is contained in:
Fabian Freund
2026-02-24 06:08:00 +01:00
parent 3094f68607
commit 25b153af97
45 changed files with 7899 additions and 1960 deletions
@@ -0,0 +1,247 @@
{
"description": "Event contains one or more entries of known Shorteners domains",
"list": [
"1url.com",
"2h.ae",
"2ly.link",
"2no.co",
"2uuu.me",
"3c5.com",
"42url.com",
"4x.si",
"7x.qa",
"9lick.me",
"abre.ai",
"adcraft.co",
"adcrun.ch",
"adf.ly",
"adflav.com",
"aiy.ooo",
"aka.gr",
"amzn.to",
"artist.link",
"b2n.ir",
"bc.vc",
"bee4.biz",
"belea.link",
"bit.do",
"bit.ly",
"bitly.com",
"bitly.com.vn",
"bitly.lc",
"bitly.ws",
"bom.so",
"buff.ly",
"buzurl.com",
"bx.ms",
"cektkp.com",
"ci.ci",
"clc.li",
"clck.ru",
"cml.lol",
"coki.me",
"cszone.hu",
"cur.lv",
"cut.by",
"cutt.ly",
"cutt.us",
"cuty.io",
"d.to",
"db.tt",
"dft.ba",
"dik.si",
"dub.co",
"dub.sh",
"dwz.mk",
"e.vg",
"encr.pw",
"encurtador.dev",
"etd.bz",
"filoops.info",
"flowto.it",
"fun.ly",
"fzy.co",
"gg-l.xyz",
"go.ly",
"gog.li",
"golinks.co",
"goo.by",
"goo.gd",
"goo.gl",
"goo.su",
"han.gl",
"hit.my",
"hyp.ae",
"hyperurl.co",
"i3l.ir",
"ic9.in",
"id.tl",
"idm.in",
"iii.im",
"iiil.io",
"ilang.in",
"inlnk.co",
"insprl.com",
"iplogger.com",
"iplogger.org",
"is.gd",
"ito.mx",
"ity.im",
"iurl.vip",
"j.mp",
"jii.li",
"komin.fo",
"kortlink.dk",
"kutt.it",
"kutti.co",
"l.ead.me",
"lc.cx",
"link.zip.net",
"linksshortcut.com",
"linkto.im",
"litby.us",
"ln.run",
"lnk.co",
"lnk.direct",
"lnk.ink",
"lnk.pw",
"lnkd.in",
"lnkfi.re",
"long.af",
"longurl.in",
"maxiurl.com",
"mcaf.ee",
"me2.do",
"merky.de",
"mjt.lu",
"mtr.bio",
"my5353.com",
"mylinks.ai",
"myqrcode.mobi",
"n9.cl",
"nanourly.in",
"neya.io",
"nov.io",
"o2o.to",
"odesli.co",
"onelink.to",
"onx.la",
"ouvaton.link",
"ow.ly",
"p6l.org",
"picz.us",
"po.st",
"postly.link",
"ppt.cc",
"prettylinkpro.com",
"q-r.to",
"q.gs",
"qr-codes.io",
"qr.ae",
"qr.net",
"qrco.de",
"qrcodedynamic.com",
"qrcodes.pro",
"qrkit.co",
"rb.gy",
"rebrand.ly",
"rebrandly.com",
"rebrandly.info",
"relink.is",
"reurl.cc",
"ricardo.news",
"s.devh.in",
"s.ee",
"s.id",
"s.rlp.de",
"s3r.io",
"s59.site",
"scanned.page",
"scrnch.me",
"shly.link",
"shorten.ee",
"shorten.is",
"shorten.tv",
"shorter.me",
"shortquik.com",
"shorturl.ae",
"shorturl.asia",
"shorturl.at",
"shrtcnl.com",
"sht.ac",
"sk.gy",
"sl8.in",
"smarturl.it",
"smurl.fr",
"sn.rs",
"snip.ly",
"song.link",
"spoo.me",
"sprl.in",
"srink.co",
"su.pr",
"surl.li",
"t.co",
"t.ly",
"temporary-url.com",
"tg.pe",
"tiny.cc",
"tinyarrows.com",
"tinyurl.com",
"tinyurl.mobi",
"tota2.com",
"tr.ee",
"tr.im",
"trimz.me",
"tt.vg",
"tweez.me",
"twitthis.com",
"twixar.com",
"twixar.me",
"tyny.to",
"u.bb",
"u.to",
"urled.cc",
"urled.pro",
"urless.com",
"urlr.me",
"urlshort.dev",
"urltin.com",
"urlz.fr",
"ux9.de",
"v.gd",
"v.ht",
"vtaurl.com",
"vzturl.com",
"webz.cc",
"wp.me",
"x.co",
"x.gd",
"xlinkz.info",
"xshort.co",
"xso.to",
"xtu.me",
"xy2.eu",
"yirra.net",
"ykm.de",
"yourls.org",
"youtu.be",
"yu2.it",
"yu3.io",
"zpag.es",
"zpr.io",
"zurl.to",
"zws.im",
"zzb.bz"
],
"matching_attributes": [
"domain",
"hostname",
"domain|ip",
"url",
"uri"
],
"name": "List of known URL Shorteners domains",
"type": "hostname",
"version": 26
}
File diff suppressed because one or more lines are too long
+4
View File
@@ -31,6 +31,10 @@ class Format extends _$Format {
return pattern.format(date);
}
String shortDate(DateTime date) {
return DateFormat('yyyy-MM-dd').format(date);
}
@override
Future<void> build() async {
await initializeDateFormatting();
+1 -1
View File
@@ -32,7 +32,7 @@ final class FormatProvider extends $AsyncNotifierProvider<Format, void> {
Format create() => Format();
}
String _$formatHash() => r'f132aabb20bf4df77d771d6c866bc413c00bd9ff';
String _$formatHash() => r'695b94cc4b25596e4ef953d08ba954424a5ec33e';
abstract class _$Format extends $AsyncNotifier<void> {
FutureOr<void> build();
+3 -1
View File
@@ -39,13 +39,15 @@ import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/b
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/open_shared_content.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/screens/browser.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/screens/tab_view.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart';
import 'package:weblibre/features/geckoview/features/history/presentation/screens/history.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/screens/url_cleaner_settings.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/screens/search.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart';
+52
View File
@@ -1460,6 +1460,16 @@ RouteBase get $settingsRoute => GoRouteData.$route(
name: 'SyncSettingsRoute',
factory: $SyncSettingsRoute._fromState,
),
GoRouteData.$route(
path: 'url_cleaner',
name: 'UrlCleanerSettingsRoute',
factory: $UrlCleanerSettingsRoute._fromState,
),
GoRouteData.$route(
path: 'unshortener',
name: 'UnshortenerSettingsRoute',
factory: $UnshortenerSettingsRoute._fromState,
),
],
);
@@ -1845,6 +1855,48 @@ mixin $SyncSettingsRoute on GoRouteData {
void replace(BuildContext context) => context.replace(location);
}
mixin $UrlCleanerSettingsRoute on GoRouteData {
static UrlCleanerSettingsRoute _fromState(GoRouterState state) =>
UrlCleanerSettingsRoute();
@override
String get location => GoRouteData.$location('/settings/url_cleaner');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
mixin $UnshortenerSettingsRoute on GoRouteData {
static UnshortenerSettingsRoute _fromState(GoRouterState state) =>
UnshortenerSettingsRoute();
@override
String get location => GoRouteData.$location('/settings/unshortener');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $torProxyRoute => GoRouteData.$route(
path: '/tor',
name: 'TorProxyRoute',
+24
View File
@@ -81,6 +81,14 @@ part of 'routes.dart';
),
TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'),
TypedGoRoute<SyncSettingsRoute>(name: 'SyncSettingsRoute', path: 'sync'),
TypedGoRoute<UrlCleanerSettingsRoute>(
name: 'UrlCleanerSettingsRoute',
path: 'url_cleaner',
),
TypedGoRoute<UnshortenerSettingsRoute>(
name: 'UnshortenerSettingsRoute',
path: 'unshortener',
),
],
)
class SettingsRoute extends GoRouteData with $SettingsRoute {
@@ -222,3 +230,19 @@ class SyncSettingsRoute extends GoRouteData with $SyncSettingsRoute {
return const SyncSettingsScreen();
}
}
class UrlCleanerSettingsRoute extends GoRouteData
with $UrlCleanerSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const UrlCleanerSettingsScreen();
}
}
class UnshortenerSettingsRoute extends GoRouteData
with $UnshortenerSettingsRoute {
@override
Widget build(BuildContext context, GoRouterState state) {
return const UnshortenerSettingsScreen();
}
}
@@ -1,171 +0,0 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart' hide Column;
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:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoBrowserService;
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/utils/form_validators.dart';
class OpenSharedContent extends HookConsumerWidget {
final Uri sharedUrl;
const OpenSharedContent({super.key, required this.sharedUrl});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final textController = useTextEditingController(text: sharedUrl.toString());
final appColors = AppColors.of(context);
final selectedContainer = useState<ContainerData?>(null);
Future<void> openTab(bool isPrivate) async {
if (formKey.currentState?.validate() == true) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.parse(textController.text),
private: isPrivate,
container: Value(selectedContainer.value),
launchedFromIntent: true,
selectTab: true,
);
if (context.mounted) {
context.pop(true);
}
}
}
Future<void> openCustomTab(bool isPrivate) async {
if (formKey.currentState?.validate() == true) {
await GeckoBrowserService().openInCustomTab(
url: Uri.parse(textController.text),
private: isPrivate,
contextId: selectedContainer.value?.id,
);
if (context.mounted) {
context.pop(true);
}
}
}
return SafeArea(
child: Form(
key: formKey,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Open link', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
ContainerChips(
displayMenu: false,
selectedContainer: selectedContainer.value,
onSelected: (container) {
selectedContainer.value = container;
},
onDeleted: (container) {
selectedContainer.value = null;
},
),
TextFormField(
controller: textController,
keyboardType: TextInputType.url,
minLines: 1,
maxLines: 10,
validator: (value) {
return validateUrl(value, eagerParsing: false);
},
),
const SizedBox(height: 8),
_OpenActionTile(
title: 'Open in new tab',
subtitle: 'Add to your browser tabs',
icon: MdiIcons.tab,
privateColor: appColors.privateTabPurple,
onTap: () => openTab(false),
onPrivateTap: () => openTab(true),
),
_OpenActionTile(
title: 'Open in custom tab',
subtitle: 'Open in a separate window',
icon: MdiIcons.applicationOutline,
privateColor: appColors.privateTabPurple,
onTap: () => openCustomTab(false),
onPrivateTap: () => openCustomTab(true),
),
],
),
),
),
);
}
}
class _OpenActionTile extends StatelessWidget {
final String title;
final String? subtitle;
final IconData icon;
final Color privateColor;
final VoidCallback onTap;
final VoidCallback onPrivateTap;
const _OpenActionTile({
required this.title,
this.subtitle,
required this.icon,
required this.privateColor,
required this.onTap,
required this.onPrivateTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
leading: Icon(icon),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 24, child: VerticalDivider(width: 16)),
IconButton(
icon: Icon(MdiIcons.dominoMask, color: privateColor, size: 24),
tooltip: 'Private',
onPressed: onPrivateTap,
),
],
),
onTap: onTap,
);
}
}
@@ -0,0 +1,128 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/unshorten_result.dart';
part 'unshorten_response_data.g.dart';
@JsonSerializable()
class UnshortenResponseData with FastEquatable {
@JsonKey(defaultValue: false)
final bool success;
final String? error;
@JsonKey(name: 'unshortened_url')
final String? unshortenedUrl;
@JsonKey(name: 'resolved_url')
final String? resolvedUrl;
@JsonKey(name: 'remaining_calls', fromJson: _toInt)
final int? remainingCalls;
@JsonKey(name: 'usage_count', fromJson: _toInt)
final int? usageCount;
UnshortenResponseData({
required this.success,
this.error,
this.unshortenedUrl,
this.resolvedUrl,
this.remainingCalls,
this.usageCount,
});
String? get finalUrl => unshortenedUrl ?? resolvedUrl;
factory UnshortenResponseData.fromJson(Map<String, dynamic> json) =>
_$UnshortenResponseDataFromJson(json);
Map<String, dynamic> toJson() => _$UnshortenResponseDataToJson(this);
factory UnshortenResponseData.fromAuthenticatedJson(
Map<String, dynamic> json,
) {
final data = UnshortenResponseData.fromJson(json);
final error = data.error;
if (error != null && error.isNotEmpty) {
return UnshortenResponseData(success: false, error: error);
}
return UnshortenResponseData(
success: true,
unshortenedUrl: data.unshortenedUrl,
resolvedUrl: data.resolvedUrl,
remainingCalls: data.remainingCalls,
usageCount: data.usageCount,
);
}
factory UnshortenResponseData.fromUnauthenticatedJson(
Map<String, dynamic> json,
) {
final data = UnshortenResponseData.fromJson(json);
final success = data.success;
if (!success) {
return UnshortenResponseData(
success: false,
error: data.error ?? 'Unknown error',
remainingCalls: data.remainingCalls,
usageCount: data.usageCount,
);
}
return UnshortenResponseData(
success: true,
unshortenedUrl: data.unshortenedUrl,
resolvedUrl: data.resolvedUrl,
remainingCalls: data.remainingCalls,
usageCount: data.usageCount,
);
}
UnshortenResult toDomain() {
return UnshortenResult(
success: success,
error: error,
finalUrl: finalUrl,
remainingCalls: remainingCalls,
usageCount: usageCount,
);
}
@override
List<Object?> get hashParameters => [
success,
error,
unshortenedUrl,
resolvedUrl,
remainingCalls,
usageCount,
];
}
int? _toInt(Object? value) {
return switch (value) {
final int value => value,
final num value => value.toInt(),
_ => null,
};
}
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'unshorten_response_data.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UnshortenResponseData _$UnshortenResponseDataFromJson(
Map<String, dynamic> json,
) => UnshortenResponseData(
success: json['success'] as bool? ?? false,
error: json['error'] as String?,
unshortenedUrl: json['unshortened_url'] as String?,
resolvedUrl: json['resolved_url'] as String?,
remainingCalls: _toInt(json['remaining_calls']),
usageCount: _toInt(json['usage_count']),
);
Map<String, dynamic> _$UnshortenResponseDataToJson(
UnshortenResponseData instance,
) => <String, dynamic>{
'success': instance.success,
'error': instance.error,
'unshortened_url': instance.unshortenedUrl,
'resolved_url': instance.resolvedUrl,
'remaining_calls': instance.remainingCalls,
'usage_count': instance.usageCount,
};
@@ -0,0 +1,45 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
class UnshortenResult with FastEquatable {
final bool success;
final String? error;
final String? finalUrl;
final int? remainingCalls;
final int? usageCount;
UnshortenResult({
required this.success,
this.error,
this.finalUrl,
this.remainingCalls,
this.usageCount,
});
@override
List<Object?> get hashParameters => [
success,
error,
finalUrl,
remainingCalls,
usageCount,
];
}
@@ -0,0 +1,67 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
enum UrlCleanerMatchType { rawRule, queryRule, referralRule }
class RemovedParam with FastEquatable {
final String provider;
final String param;
final String match;
final UrlCleanerMatchType type;
RemovedParam({
required this.provider,
required this.param,
required this.match,
required this.type,
});
@override
List<Object?> get hashParameters => [provider, param, match, type];
}
class UrlCleanerResult with FastEquatable {
final String cleanedUrl;
final bool changed;
final bool blocked;
final List<RemovedParam> removedParams;
final List<String> matchedProviders;
final String? redirectedFrom;
UrlCleanerResult({
required this.cleanedUrl,
required this.changed,
required this.blocked,
required this.removedParams,
required this.matchedProviders,
this.redirectedFrom,
});
@override
List<Object?> get hashParameters => [
cleanedUrl,
changed,
blocked,
removedParams,
matchedProviders,
redirectedFrom,
];
}
@@ -0,0 +1,66 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as path_provider;
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'url_cleaner_catalog_file.g.dart';
@Riverpod(keepAlive: true)
class UrlCleanerCatalogFileService extends _$UrlCleanerCatalogFileService {
late final _catalogFileFuture = path_provider
.getApplicationSupportDirectory()
.then((dir) => File(p.join(dir.path, 'url_cleaner_data.minify.json')));
Future<DateTime?> lastUpdate() async {
final catalogFile = await _catalogFileFuture;
if (!await catalogFile.exists()) {
return null;
}
return catalogFile.lastModified();
}
Future<void> writeCatalog(String catalogJson) async {
final catalogFile = await _catalogFileFuture;
await catalogFile.writeAsString(catalogJson, flush: true);
}
Future<String?> readCatalog() async {
final catalogFile = await _catalogFileFuture;
if (!await catalogFile.exists()) {
return null;
}
return catalogFile.readAsString();
}
Future<void> deleteCatalog() async {
final catalogFile = await _catalogFileFuture;
if (await catalogFile.exists()) {
await catalogFile.delete();
}
}
@override
void build() {}
}
@@ -0,0 +1,64 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'url_cleaner_catalog_file.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(UrlCleanerCatalogFileService)
final urlCleanerCatalogFileServiceProvider =
UrlCleanerCatalogFileServiceProvider._();
final class UrlCleanerCatalogFileServiceProvider
extends $NotifierProvider<UrlCleanerCatalogFileService, void> {
UrlCleanerCatalogFileServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'urlCleanerCatalogFileServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$urlCleanerCatalogFileServiceHash();
@$internal
@override
UrlCleanerCatalogFileService create() => UrlCleanerCatalogFileService();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$urlCleanerCatalogFileServiceHash() =>
r'ea4d0d775e087704cc219dbf6d30ac34510b0b22';
abstract class _$UrlCleanerCatalogFileService extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,191 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_file.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_rule.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
part 'url_cleaner_catalog_service.g.dart';
@Riverpod(keepAlive: true)
class UrlCleanerCatalogService extends _$UrlCleanerCatalogService {
Future<List<UrlCleanerRule>> _loadCatalog() async {
final fileService = ref.read(urlCleanerCatalogFileServiceProvider.notifier);
final storedCatalog = await fileService.readCatalog();
if (storedCatalog != null && storedCatalog.isNotEmpty) {
return _parseRules(storedCatalog);
}
final bundledCatalog = await rootBundle.loadString(
'assets/preferences/url_cleaner_data.minify.json',
);
try {
await fileService.writeCatalog(bundledCatalog);
} catch (_) {
// Continue using bundled rules if local persistence is unavailable.
}
return _parseRules(bundledCatalog);
}
List<UrlCleanerRule> _parseRules(String rawJson) {
final catalog = jsonDecode(rawJson) as Map<String, dynamic>;
final providers = catalog['providers'] as Map<String, dynamic>?;
if (providers == null) return [];
final rules = <UrlCleanerRule>[];
for (final entry in providers.entries) {
rules.add(
UrlCleanerRule.fromCatalogEntry(
entry.key,
entry.value as Map<String, dynamic>,
),
);
}
return rules;
}
Future<void> updateCatalog({bool lastUpdateWasAuto = false}) async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
final catalogUrl = settings.urlCleanerCatalogUrl;
final hashUrl = settings.urlCleanerHashUrl;
// Fetch catalog and hash in parallel since they are independent.
final catalogFuture = http
.get(Uri.parse(catalogUrl))
.timeout(const Duration(seconds: 30));
final hashFuture = hashUrl.isNotEmpty
? http.get(Uri.parse(hashUrl)).timeout(const Duration(seconds: 15))
: null;
final results = await Future.wait([
catalogFuture,
if (hashFuture != null) hashFuture,
]);
final response = results[0];
if (response.statusCode != 200) {
throw Exception('Failed to fetch catalog: ${response.statusCode}');
}
final body = response.body;
// Verify hash if hashUrl is provided
if (hashFuture != null) {
final hashResponse = results[1];
if (hashResponse.statusCode != 200) {
throw Exception(
'Failed to fetch catalog hash: ${hashResponse.statusCode}',
);
}
final expectedHash = hashResponse.body.trim();
final actualHash = sha256.convert(utf8.encode(body)).toString();
if (actualHash != expectedHash) {
throw Exception('Catalog hash mismatch');
}
}
// Validate JSON parses correctly
final newRules = _parseRules(body);
if (newRules.isEmpty) {
throw Exception('Catalog contains no providers');
}
await ref
.read(urlCleanerCatalogFileServiceProvider.notifier)
.writeCatalog(body);
final now = DateTime.now().millisecondsSinceEpoch;
await ref
.read(generalSettingsRepositoryProvider.notifier)
.updateSettings(
(current) => current.copyWith(
urlCleanerLastCheckEpochMs: now,
urlCleanerLastUpdateWasAuto: lastUpdateWasAuto,
),
);
// Reload rules from updated catalog
ref.invalidateSelf();
}
Future<DateTime?> lastUpdate() {
return ref.read(urlCleanerCatalogFileServiceProvider.notifier).lastUpdate();
}
Future<void> restoreBundledCatalog() async {
await ref
.read(urlCleanerCatalogFileServiceProvider.notifier)
.deleteCatalog();
ref.invalidateSelf();
}
Future<bool> updateIfNecessary() async {
final autoUpdate = ref
.read(generalSettingsWithDefaultsProvider)
.urlCleanerAutoUpdate;
if (!autoUpdate) return false;
final lastUpdate = await this.lastUpdate();
final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7));
if (lastUpdate != null && lastUpdate.isAfter(sevenDaysAgo)) return false;
await updateCatalog(lastUpdateWasAuto: true);
return true;
}
Future<void> _runAutoUpdateIfNecessary() async {
try {
await updateIfNecessary();
} catch (e, s) {
logger.w(
'Failed auto-updating URL cleaner catalog',
error: e,
stackTrace: s,
);
}
}
@override
Future<List<UrlCleanerRule>> build() async {
final rules = await _loadCatalog();
// Run auto-update checks in the background so the UI gets local rules fast.
unawaited(_runAutoUpdateIfNecessary());
return rules;
}
}
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'url_cleaner_catalog_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(UrlCleanerCatalogService)
final urlCleanerCatalogServiceProvider = UrlCleanerCatalogServiceProvider._();
final class UrlCleanerCatalogServiceProvider
extends
$AsyncNotifierProvider<UrlCleanerCatalogService, List<UrlCleanerRule>> {
UrlCleanerCatalogServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'urlCleanerCatalogServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$urlCleanerCatalogServiceHash();
@$internal
@override
UrlCleanerCatalogService create() => UrlCleanerCatalogService();
}
String _$urlCleanerCatalogServiceHash() =>
r'5ee38420014da6f250a3dc25d85d4f1116a4f1c3';
abstract class _$UrlCleanerCatalogService
extends $AsyncNotifier<List<UrlCleanerRule>> {
FutureOr<List<UrlCleanerRule>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<AsyncValue<List<UrlCleanerRule>>, List<UrlCleanerRule>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<List<UrlCleanerRule>>,
List<UrlCleanerRule>
>,
AsyncValue<List<UrlCleanerRule>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,138 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'url_cleaner_rule.g.dart';
@JsonSerializable()
class UrlCleanerRule with FastEquatable {
final String name;
final UrlCleanerRuleData data;
UrlCleanerRule({required this.name, required this.data});
factory UrlCleanerRule.fromCatalogEntry(
String name,
Map<String, dynamic> data,
) {
return UrlCleanerRule(name: name, data: UrlCleanerRuleData.fromJson(data));
}
factory UrlCleanerRule.fromJson(Map<String, dynamic> json) =>
_$UrlCleanerRuleFromJson(json);
Map<String, dynamic> toJson() => _$UrlCleanerRuleToJson(this);
@override
List<Object?> get hashParameters => [name, data];
}
@JsonSerializable()
class UrlCleanerRuleData with FastEquatable {
final String? urlPattern;
final bool completeProvider;
final bool forceRedirection;
@JsonKey(fromJson: _stringListFromJson)
final List<String> exceptions;
@JsonKey(fromJson: _stringListFromJson)
final List<String> redirections;
@JsonKey(fromJson: _stringListFromJson)
final List<String> rawRules;
@JsonKey(fromJson: _stringListFromJson)
final List<String> rules;
@JsonKey(fromJson: _stringListFromJson)
final List<String> referralMarketing;
static List<String> _stringListFromJson(dynamic value) {
if (value is! List) return const [];
return value.whereType<String>().toList(growable: false);
}
// Lazily compiled regexes — derived from the source strings above,
// not identity-bearing state, so intentionally excluded from hashParameters.
// ignore: fast_equatable_lint/missing_field_in_equatable_props
@JsonKey(includeFromJson: false, includeToJson: false)
late final RegExp? compiledUrlPattern = _tryCompile(urlPattern);
// ignore: fast_equatable_lint/missing_field_in_equatable_props
@JsonKey(includeFromJson: false, includeToJson: false)
late final List<RegExp> compiledExceptions = exceptions
.map((e) => _tryCompile(e))
.whereType<RegExp>()
.toList();
// ignore: fast_equatable_lint/missing_field_in_equatable_props
@JsonKey(includeFromJson: false, includeToJson: false)
late final List<RegExp> compiledRedirections = redirections
.map((e) => _tryCompile(e))
.whereType<RegExp>()
.toList();
// ignore: fast_equatable_lint/missing_field_in_equatable_props
@JsonKey(includeFromJson: false, includeToJson: false)
late final List<RegExp?> compiledRawRules = rawRules
.map((e) => _tryCompile(e))
.toList();
// ignore: fast_equatable_lint/missing_field_in_equatable_props
@JsonKey(includeFromJson: false, includeToJson: false)
late final List<RegExp?> compiledRules = rules
.map((e) => _tryCompile(e))
.toList();
// ignore: fast_equatable_lint/missing_field_in_equatable_props
@JsonKey(includeFromJson: false, includeToJson: false)
late final List<RegExp?> compiledReferralMarketing = referralMarketing
.map((e) => _tryCompile(e))
.toList();
static RegExp? _tryCompile(String? pattern) {
if (pattern == null || pattern.isEmpty) return null;
try {
return RegExp(pattern, caseSensitive: false);
} on FormatException {
return null;
}
}
UrlCleanerRuleData({
this.urlPattern,
this.completeProvider = false,
this.forceRedirection = false,
this.exceptions = const [],
this.redirections = const [],
this.rawRules = const [],
this.rules = const [],
this.referralMarketing = const [],
});
factory UrlCleanerRuleData.fromJson(Map<String, dynamic> json) =>
_$UrlCleanerRuleDataFromJson(json);
Map<String, dynamic> toJson() => _$UrlCleanerRuleDataToJson(this);
@override
List<Object?> get hashParameters => [
urlPattern,
completeProvider,
forceRedirection,
exceptions,
redirections,
rawRules,
rules,
referralMarketing,
];
}
@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'url_cleaner_rule.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UrlCleanerRule _$UrlCleanerRuleFromJson(Map<String, dynamic> json) =>
UrlCleanerRule(
name: json['name'] as String,
data: UrlCleanerRuleData.fromJson(json['data'] as Map<String, dynamic>),
);
Map<String, dynamic> _$UrlCleanerRuleToJson(UrlCleanerRule instance) =>
<String, dynamic>{'name': instance.name, 'data': instance.data.toJson()};
UrlCleanerRuleData _$UrlCleanerRuleDataFromJson(Map<String, dynamic> json) =>
UrlCleanerRuleData(
urlPattern: json['urlPattern'] as String?,
completeProvider: json['completeProvider'] as bool? ?? false,
forceRedirection: json['forceRedirection'] as bool? ?? false,
exceptions: json['exceptions'] == null
? const []
: UrlCleanerRuleData._stringListFromJson(json['exceptions']),
redirections: json['redirections'] == null
? const []
: UrlCleanerRuleData._stringListFromJson(json['redirections']),
rawRules: json['rawRules'] == null
? const []
: UrlCleanerRuleData._stringListFromJson(json['rawRules']),
rules: json['rules'] == null
? const []
: UrlCleanerRuleData._stringListFromJson(json['rules']),
referralMarketing: json['referralMarketing'] == null
? const []
: UrlCleanerRuleData._stringListFromJson(json['referralMarketing']),
);
Map<String, dynamic> _$UrlCleanerRuleDataToJson(UrlCleanerRuleData instance) =>
<String, dynamic>{
'urlPattern': instance.urlPattern,
'completeProvider': instance.completeProvider,
'forceRedirection': instance.forceRedirection,
'exceptions': instance.exceptions,
'redirections': instance.redirections,
'rawRules': instance.rawRules,
'rules': instance.rules,
'referralMarketing': instance.referralMarketing,
};
@@ -0,0 +1,283 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_rule.dart';
UrlCleanerResult cleanUrl(
String url,
List<UrlCleanerRule> rules, {
bool allowReferral = false,
}) {
var currentUrl = url;
var blocked = false;
final removedParams = <RemovedParam>[];
final matchedProviders = <String>[];
final uniqueMatchedProviders = <String>{};
String? redirectedFrom;
var redirectionCount = 0;
final maxRedirections = rules.length * 4;
for (var ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) {
final rule = rules[ruleIndex];
final name = rule.name;
final data = rule.data;
final patternRegex = data.compiledUrlPattern;
if (patternRegex == null) continue;
if (!patternRegex.hasMatch(currentUrl)) continue;
if (uniqueMatchedProviders.add(name)) {
matchedProviders.add(name);
}
// 1. Check completeProvider
if (data.completeProvider) {
blocked = true;
continue;
}
// 2. Check exceptions
var exceptionMatched = false;
for (final exceptionRegex in data.compiledExceptions) {
if (exceptionRegex.hasMatch(currentUrl)) {
exceptionMatched = true;
break;
}
}
if (exceptionMatched) continue;
// 3. Apply redirections
var redirected = false;
var redirectionMatched = false;
for (final redirectRegex in data.compiledRedirections) {
final match = redirectRegex.firstMatch(currentUrl);
if (match != null && match.groupCount >= 1) {
redirectionMatched = true;
final extracted = Uri.decodeComponent(match.group(1)!);
if (extracted != currentUrl && _hasSafeScheme(extracted)) {
redirectedFrom ??= currentUrl;
currentUrl = extracted;
redirected = true;
}
break;
}
}
if (redirected) {
redirectionCount++;
if (redirectionCount <= maxRedirections) {
// Restart provider scan so earlier providers also apply on redirected URL.
ruleIndex = -1;
}
continue;
}
if (redirectionMatched) continue;
// 4. Apply rawRules
for (var i = 0; i < data.compiledRawRules.length; i++) {
final rawRegex = data.compiledRawRules[i];
if (rawRegex == null) continue;
final rawRule = data.rawRules[i];
final matches = rawRegex
.allMatches(currentUrl)
.map((m) => m.group(0))
.whereType<String>()
.where((match) => match.isNotEmpty)
.toList();
if (matches.isNotEmpty) {
currentUrl = currentUrl.replaceAll(rawRegex, '');
for (final match in matches) {
removedParams.add(
RemovedParam(
provider: name,
param: rawRule,
match: match,
type: UrlCleanerMatchType.rawRule,
),
);
}
}
}
// 5. Apply rules (query parameter removal)
for (var i = 0; i < data.compiledRules.length; i++) {
final compiledRule = data.compiledRules[i];
if (compiledRule == null) continue;
final paramRule = data.rules[i];
final removalResult = _removeQueryParamWithRegex(
currentUrl,
compiledRule,
);
currentUrl = removalResult.url;
for (final match in removalResult.matches) {
removedParams.add(
RemovedParam(
provider: name,
param: paramRule,
match: match,
type: UrlCleanerMatchType.queryRule,
),
);
}
}
// 6. Apply referralMarketing (if !allowReferral)
for (var i = 0; i < data.compiledReferralMarketing.length; i++) {
final compiledRule = data.compiledReferralMarketing[i];
if (compiledRule == null) continue;
final referralRule = data.referralMarketing[i];
if (!allowReferral) {
final removalResult = _removeQueryParamWithRegex(
currentUrl,
compiledRule,
);
currentUrl = removalResult.url;
for (final match in removalResult.matches) {
removedParams.add(
RemovedParam(
provider: name,
param: referralRule,
match: match,
type: UrlCleanerMatchType.referralRule,
),
);
}
} else {
final referralMatches = _findQueryParamMatchesWithRegex(
currentUrl,
compiledRule,
);
for (final match in referralMatches) {
removedParams.add(
RemovedParam(
provider: name,
param: referralRule,
match: match,
type: UrlCleanerMatchType.referralRule,
),
);
}
}
}
}
currentUrl = _normalizeUrl(currentUrl);
return UrlCleanerResult(
cleanedUrl: currentUrl,
changed: currentUrl != url,
blocked: blocked,
removedParams: removedParams,
matchedProviders: matchedProviders,
redirectedFrom: redirectedFrom,
);
}
String removeUrlCleanerMatch(String url, RemovedParam removedParam) {
if (removedParam.match.isEmpty) return url;
switch (removedParam.type) {
case UrlCleanerMatchType.rawRule:
return _normalizeUrl(url.replaceFirst(removedParam.match, ''));
case UrlCleanerMatchType.queryRule:
case UrlCleanerMatchType.referralRule:
final escapedMatch = RegExp.escape(removedParam.match);
final regex = RegExp(
'([?&#])$escapedMatch(?=(&|#|\$))',
caseSensitive: false,
);
final match = regex.firstMatch(url);
if (match == null) return url;
final result = url.replaceRange(match.start, match.end, match.group(1)!);
return _normalizeUrl(result);
}
}
_QueryParamRemovalResult _removeQueryParamWithRegex(
String url,
RegExp paramRegex,
) {
final regex = RegExp(
'([?&#])(${paramRegex.pattern})=([^&#]*)',
caseSensitive: false,
);
var result = url;
final matches = <String>[];
while (true) {
final match = regex.firstMatch(result);
if (match == null) break;
matches.add('${match.group(2)}=${match.group(3)}');
result = result.replaceRange(match.start, match.end, match.group(1)!);
}
return _QueryParamRemovalResult(url: result, matches: matches);
}
List<String> _findQueryParamMatchesWithRegex(String url, RegExp paramRegex) {
final regex = RegExp(
'([?&#])(${paramRegex.pattern})=([^&#]*)',
caseSensitive: false,
);
return regex
.allMatches(url)
.map((match) => '${match.group(2)}=${match.group(3)}')
.toList();
}
bool _hasSafeScheme(String url) {
final parsed = Uri.tryParse(url);
if (parsed == null || !parsed.hasScheme) return true; // schemeless is ok
final scheme = parsed.scheme.toLowerCase();
return scheme == 'http' || scheme == 'https';
}
// Pre-compiled regexes for URL normalization — avoids recompiling on every call.
final _questionAmpersand = RegExp(r'\?&+');
final _trailingQuestion = RegExp(r'\?$');
final _doubleAmpersand = RegExp('&&+');
final _trailingAmpersand = RegExp(r'&$');
final _hashAmpersand = RegExp('#&+');
final _trailingHash = RegExp(r'#$');
String _normalizeUrl(String url) {
var normalized = url
.replaceAll(_questionAmpersand, '?')
.replaceAll('?#', '#')
.replaceAll(_trailingQuestion, '')
.replaceAll(_doubleAmpersand, '&')
.replaceAll('&#', '#')
.replaceAll(_trailingAmpersand, '')
.replaceAll(_hashAmpersand, '#')
.replaceAll(_trailingHash, '');
// Only prepend https:// for genuinely schemeless URLs.
final parsed = Uri.tryParse(normalized);
if (parsed == null || !parsed.hasScheme) {
normalized = 'https://$normalized';
}
return normalized;
}
class _QueryParamRemovalResult {
final String url;
final List<String> matches;
const _QueryParamRemovalResult({required this.url, required this.matches});
}
@@ -0,0 +1,184 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/data/models/unshorten_response_data.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/unshorten_result.dart';
part 'url_unshortener_service.g.dart';
const _shortenerListAssetPath = 'assets/preferences/url-shortener-list.json';
@Riverpod(keepAlive: true)
class UrlUnshortenerService extends _$UrlUnshortenerService {
Future<Set<String>> loadSupportedShortenerHosts({
AssetBundle? bundle,
String assetPath = _shortenerListAssetPath,
}) async {
try {
final content = await (bundle ?? rootBundle).loadString(assetPath);
return parseSupportedShortenerHosts(content);
} catch (_) {
return {};
}
}
Set<String> parseSupportedShortenerHosts(String rawJson) {
try {
final decoded = jsonDecode(rawJson);
if (decoded is! Map<String, dynamic>) return {};
final list = decoded['list'];
if (list is! List) return {};
return list
.whereType<String>()
.map(_normalizeHost)
.where((host) => host.isNotEmpty)
.toSet();
} catch (_) {
return {};
}
}
bool isSupportedShortenerUrl(String url, Set<String> supportedHosts) {
if (supportedHosts.isEmpty) return false;
final parsed = Uri.tryParse(url);
final host = parsed != null && parsed.host.isNotEmpty
? parsed.host
: Uri.tryParse('https://$url')?.host;
return isSupportedShortenerHost(host, supportedHosts);
}
bool isSupportedShortenerHost(String? host, Set<String> supportedHosts) {
if (supportedHosts.isEmpty) return false;
var candidate = _normalizeHost(host ?? '');
if (candidate.isEmpty) return false;
while (true) {
if (supportedHosts.contains(candidate)) return true;
final dotIndex = candidate.indexOf('.');
if (dotIndex == -1) return false;
candidate = candidate.substring(dotIndex + 1);
}
}
Future<UnshortenResult> unshortenUrl(
String url, {
String token = '',
http.Client? client,
}) async {
final encodedUrl = Uri.encodeComponent(url);
final httpClient = client ?? http.Client();
final http.Response response;
final bool authenticated = token.isNotEmpty;
try {
if (authenticated) {
response = await httpClient
.get(
Uri.parse(
'https://unshorten.me/api/v2/unshorten?url=$encodedUrl',
),
headers: {'Authorization': 'Token $token'},
)
.timeout(const Duration(seconds: 15));
} else {
response = await httpClient
.get(Uri.parse('https://unshorten.me/json/$encodedUrl'))
.timeout(const Duration(seconds: 15));
}
} finally {
if (client == null) httpClient.close();
}
if (response.statusCode != 200) {
return UnshortenResult(
success: false,
error: 'HTTP ${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final responseData = authenticated
? UnshortenResponseData.fromAuthenticatedJson(json)
: UnshortenResponseData.fromUnauthenticatedJson(json);
final result = responseData.toDomain();
// Reject non-HTTP(S) URLs from the API to prevent scheme-based attacks.
if (result.success && result.finalUrl != null) {
final parsed = Uri.tryParse(result.finalUrl!);
if (parsed != null && parsed.hasScheme) {
final scheme = parsed.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') {
return UnshortenResult(
success: false,
error: 'Unsupported URL scheme: $scheme',
);
}
}
}
return result;
}
@override
Future<Set<String>> build() {
return loadSupportedShortenerHosts();
}
}
final _leadingWildcard = RegExp(r'^\*\.');
final _leadingDot = RegExp(r'^\.');
final _trailingDot = RegExp(r'\.$');
String _normalizeHost(String host) {
var normalized = host.trim().toLowerCase();
if (normalized.isEmpty) return '';
final isUrlLike =
normalized.contains('://') ||
normalized.contains('/') ||
normalized.contains('?') ||
normalized.contains('#');
if (isUrlLike) {
final parsed = normalized.contains('://')
? Uri.tryParse(normalized)
: Uri.tryParse('https://$normalized');
final parsedHost = parsed?.host ?? '';
normalized = parsedHost.isNotEmpty ? parsedHost : normalized;
}
normalized = normalized.replaceFirst(_leadingWildcard, '');
normalized = normalized.replaceFirst(_leadingDot, '');
normalized = normalized.replaceFirst(_trailingDot, '');
return normalized;
}
@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'url_unshortener_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(UrlUnshortenerService)
final urlUnshortenerServiceProvider = UrlUnshortenerServiceProvider._();
final class UrlUnshortenerServiceProvider
extends $AsyncNotifierProvider<UrlUnshortenerService, Set<String>> {
UrlUnshortenerServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'urlUnshortenerServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$urlUnshortenerServiceHash();
@$internal
@override
UrlUnshortenerService create() => UrlUnshortenerService();
}
String _$urlUnshortenerServiceHash() =>
r'627fd3cc6f6c2251f8f582fcff27cb5a7e2c7813';
abstract class _$UrlUnshortenerService extends $AsyncNotifier<Set<String>> {
FutureOr<Set<String>> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<Set<String>>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<Set<String>>, Set<String>>,
AsyncValue<Set<String>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,52 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/unshorten_result.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_unshortener_service.dart';
part 'open_shared_content_unshorten_controller.g.dart';
@Riverpod()
class OpenSharedContentUnshortenController
extends _$OpenSharedContentUnshortenController {
@override
AsyncValue<UnshortenResult>? build() {
return null;
}
Future<UnshortenResult?> unshorten({
required String url,
required String token,
}) async {
state = const AsyncLoading();
try {
final result = await ref
.read(urlUnshortenerServiceProvider.notifier)
.unshortenUrl(url, token: token);
state = AsyncData(result);
return result;
} catch (e, s) {
state = AsyncError(e, s);
return null;
}
}
}
@@ -0,0 +1,76 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'open_shared_content_unshorten_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(OpenSharedContentUnshortenController)
final openSharedContentUnshortenControllerProvider =
OpenSharedContentUnshortenControllerProvider._();
final class OpenSharedContentUnshortenControllerProvider
extends
$NotifierProvider<
OpenSharedContentUnshortenController,
AsyncValue<UnshortenResult>?
> {
OpenSharedContentUnshortenControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'openSharedContentUnshortenControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() =>
_$openSharedContentUnshortenControllerHash();
@$internal
@override
OpenSharedContentUnshortenController create() =>
OpenSharedContentUnshortenController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<UnshortenResult>? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<UnshortenResult>?>(value),
);
}
}
String _$openSharedContentUnshortenControllerHash() =>
r'f94bb6f343a8fdb1f122128225f2752fadb259c2';
abstract class _$OpenSharedContentUnshortenController
extends $Notifier<AsyncValue<UnshortenResult>?> {
AsyncValue<UnshortenResult>? build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<AsyncValue<UnshortenResult>?, AsyncValue<UnshortenResult>?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<UnshortenResult>?,
AsyncValue<UnshortenResult>?
>,
AsyncValue<UnshortenResult>?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,588 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:drift/drift.dart' hide Column;
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:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoAppLinksService, GeckoBrowserService;
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_service.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_unshortener_service.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/controllers/open_shared_content_unshorten_controller.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/hooks/debouncer.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/ui_helper.dart';
class OpenSharedContent extends HookConsumerWidget {
final Uri sharedUrl;
static final _appLinksService = GeckoAppLinksService();
const OpenSharedContent({super.key, required this.sharedUrl});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final textController = useTextEditingController(text: sharedUrl.toString());
final appColors = AppColors.of(context);
final selectedContainer = useState<ContainerData?>(null);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
final supportedShortenerHosts = ref.watch(urlUnshortenerServiceProvider);
final currentUrl = useValueListenable(textController).text;
// Debounce the URL to avoid running expensive operations on every keystroke.
final debouncedUrl = useState(currentUrl);
final debouncer = useDebouncer(const Duration(milliseconds: 300));
useEffect(
() {
debouncer.eventOccured(() => debouncedUrl.value = currentUrl);
return null;
},
[currentUrl],
);
final parsedDebouncedUrl = Uri.tryParse(debouncedUrl.value);
final hasExternalApp = useCachedFuture(
// ignore: discarded_futures useFuture
() => parsedDebouncedUrl != null
? _appLinksService.hasExternalApp(parsedDebouncedUrl)
: Future.value(false),
[parsedDebouncedUrl],
);
final shouldShowUnshortenerTile =
settings.unshortenerEnabled &&
ref
.read(urlUnshortenerServiceProvider.notifier)
.isSupportedShortenerUrl(
debouncedUrl.value,
supportedShortenerHosts.value ?? const <String>{},
);
// URL cleaner state
final cleanerResult = useState<UrlCleanerResult?>(null);
final cleanerApplied = useState(false);
final unshortenState = ref.watch(
openSharedContentUnshortenControllerProvider,
);
void runCleaner({bool allowAutoApply = false}) {
final settings = ref.read(generalSettingsWithDefaultsProvider);
if (!settings.urlCleanerEnabled) return;
final rules = catalogAsync.value;
if (rules == null) return;
final result = cleanUrl(
currentUrl,
rules,
allowReferral: settings.urlCleanerAllowReferralMarketing,
);
cleanerResult.value = result;
// Only reset applied state when new tracking params are found,
// preserving the "cleaned" indicator when the URL is already clean.
if (result.removedParams.isNotEmpty) {
cleanerApplied.value = false;
}
if (allowAutoApply && result.changed) {
textController.text = result.cleanedUrl;
cleanerApplied.value = true;
}
}
// Run URL cleaner on build when enabled and catalog is loaded.
// Uses debouncedUrl to avoid running regex matching on every keystroke.
useEffect(
() {
runCleaner(allowAutoApply: settings.urlCleanerAutoApply);
return null;
},
[
debouncedUrl.value,
catalogAsync.value,
settings.urlCleanerEnabled,
settings.urlCleanerAllowReferralMarketing,
settings.urlCleanerAutoApply,
],
);
void applyCleanUrl() {
final result = cleanerResult.value;
if (result != null && result.changed) {
textController.text = result.cleanedUrl;
cleanerApplied.value = true;
showInfoMessage(context, 'URL cleaned');
}
}
void applySelectedTrackingRemovals(String previewUrl) {
if (previewUrl == textController.text) return;
textController.text = previewUrl;
cleanerApplied.value = true;
showInfoMessage(context, 'URL preview applied');
}
Future<void> openTab(bool isPrivate) async {
if (formKey.currentState?.validate() == true) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.parse(textController.text),
private: isPrivate,
container: Value(selectedContainer.value),
launchedFromIntent: true,
selectTab: true,
);
if (context.mounted) {
context.pop(true);
}
}
}
Future<void> openCustomTab(bool isPrivate) async {
if (formKey.currentState?.validate() == true) {
await GeckoBrowserService().openInCustomTab(
url: Uri.parse(textController.text),
private: isPrivate,
contextId: selectedContainer.value?.id,
);
if (context.mounted) {
context.pop(true);
}
}
}
Future<void> openInApp() async {
if (formKey.currentState?.validate() == true) {
final uri = Uri.tryParse(textController.text);
if (uri == null) return;
final success = await _appLinksService.openAppLink(uri);
if (success && context.mounted) {
context.pop(true);
} else if (!success && context.mounted) {
showErrorMessage(context, 'Could not open in app');
}
}
}
return SafeArea(
child: Form(
key: formKey,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Open link', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
ContainerChips(
displayMenu: false,
selectedContainer: selectedContainer.value,
onSelected: (container) {
selectedContainer.value = container;
},
onDeleted: (container) {
selectedContainer.value = null;
},
),
TextFormField(
controller: textController,
keyboardType: TextInputType.url,
minLines: 1,
maxLines: 10,
validator: (value) {
return validateUrl(value, eagerParsing: false);
},
),
const SizedBox(height: 8),
// URL Cleaner tile
if (settings.urlCleanerEnabled &&
cleanerResult.value != null) ...[
if (cleanerResult.value!.blocked)
ListTile(
leading: Icon(
MdiIcons.alertCircle,
color: Theme.of(context).colorScheme.error,
),
title: const Text('URL blocked by ClearURLs'),
dense: true,
)
else if (cleanerResult.value!.removedParams.isNotEmpty)
_UrlCleanerTile(
result: cleanerResult.value!,
currentUrl: currentUrl,
allowReferralMarketing:
settings.urlCleanerAllowReferralMarketing,
onClean: cleanerResult.value!.changed
? applyCleanUrl
: null,
onApplySelectedRemovals: applySelectedTrackingRemovals,
)
else if (cleanerApplied.value)
_UrlCleanerTile(
result: cleanerResult.value!,
currentUrl: currentUrl,
allowReferralMarketing:
settings.urlCleanerAllowReferralMarketing,
applied: true,
),
],
// Unshortener UI
if (shouldShowUnshortenerTile) ...[
if (unshortenState != null)
unshortenState.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: LinearProgressIndicator(),
),
error: (error, _) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'Unshorten failed: $error',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
data: (result) {
if (!result.success) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'Unshorten failed: ${result.error}',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
);
}
final remaining = result.remainingCalls;
final limit = result.usageCount;
if (remaining != null &&
limit != null &&
limit > 0 &&
remaining < limit ~/ 2) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'Remaining calls: $remaining/$limit',
style: Theme.of(context).textTheme.bodySmall,
),
);
}
return const SizedBox.shrink();
},
),
_OpenActionTile(
title: 'Unshorten',
subtitle: 'Resolve shortened URL',
icon: MdiIcons.linkVariant,
showTrailingDivider: false,
trailing: IconButton(
icon: const Icon(Icons.info_outline),
tooltip: 'Unshortener info',
onPressed: () {
unawaited(_showUnshortenerInfoDialog(context));
},
),
onTap: () async {
final result = await ref
.read(
openSharedContentUnshortenControllerProvider.notifier,
)
.unshorten(
url: textController.text,
token: settings.unshortenerToken,
);
if (!context.mounted) return;
if (result?.success == true && result?.finalUrl != null) {
textController.text = result!.finalUrl!;
}
},
),
],
if (hasExternalApp.data == true)
_OpenActionTile(
title: 'Open in App',
subtitle: 'Open in an installed app',
icon: Icons.open_in_new,
onTap: openInApp,
),
_OpenActionTile(
title: 'Open in new tab',
subtitle: 'Add to your browser tabs',
icon: MdiIcons.tab,
trailing: IconButton(
icon: Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 24,
),
tooltip: 'Private',
onPressed: () => openTab(true),
),
onTap: () => openTab(false),
),
_OpenActionTile(
title: 'Open in custom tab',
subtitle: 'Open in a separate window',
icon: MdiIcons.applicationOutline,
trailing: IconButton(
icon: Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 24,
),
tooltip: 'Private',
onPressed: () => openCustomTab(true),
),
onTap: () => openCustomTab(false),
),
],
),
),
),
);
}
}
Future<void> _showUnshortenerInfoDialog(BuildContext context) {
final textColor = Theme.of(context).textTheme.bodyMedium?.color;
final linkStyle = TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: Theme.of(context).colorScheme.primary,
);
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Unshortener Attribution'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: textColor),
children: [
const TextSpan(
text: 'This module will unshort links by sending them to ',
),
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: AttributionLink(
label: 'https://unshorten.me/',
url: 'https://unshorten.me/',
style: linkStyle,
),
),
const TextSpan(
text:
', which evaluates them on their servers and saves the redirection for future requests. '
'Avoid unshortening links with private or sensitive data.',
),
],
),
),
const SizedBox(height: 12),
const Text(
'The free API is rate limited to 10 requests per hour for new checks.',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
RichText(
text: TextSpan(
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: textColor),
children: [
const TextSpan(text: 'Privacy policy: '),
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: AttributionLink(
label: 'https://unshorten.me/privacy-policy',
url: 'https://unshorten.me/privacy-policy',
style: linkStyle,
),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Close'),
),
],
),
);
}
class _UrlCleanerTile extends StatelessWidget {
final UrlCleanerResult result;
final String currentUrl;
final bool allowReferralMarketing;
final VoidCallback? onClean;
final ValueChanged<String>? onApplySelectedRemovals;
final bool applied;
const _UrlCleanerTile({
required this.result,
required this.currentUrl,
required this.allowReferralMarketing,
this.onClean,
this.onApplySelectedRemovals,
this.applied = false,
});
@override
Widget build(BuildContext context) {
final paramCount = result.removedParams.length;
final hasParams = paramCount > 0;
final String subtitle;
if (!hasParams) {
subtitle = 'Tracking parameters removed';
} else if (paramCount == 1) {
subtitle = '1 tracking parameter found';
} else {
subtitle = '$paramCount tracking parameters found';
}
return ListTile(
leading: Icon(
applied ? MdiIcons.checkCircle : MdiIcons.broom,
color: applied
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.error,
),
title: Text(applied ? 'URL cleaned' : 'Tracking detected'),
subtitle: Text(subtitle),
trailing: applied || !hasParams
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 24, child: VerticalDivider(width: 16)),
IconButton(
icon: const Icon(MdiIcons.linkVariantRemove),
tooltip: 'Clean URL',
onPressed: onClean,
),
],
),
dense: true,
onTap: hasParams
? () {
unawaited(
showDialog(
context: context,
builder: (context) => TrackingDetailsDialog(
currentUrl: currentUrl,
result: result,
allowReferralMarketing: allowReferralMarketing,
onApplySelectedRemovals: onApplySelectedRemovals,
),
),
);
}
: null,
);
}
}
class _OpenActionTile extends StatelessWidget {
final String title;
final String? subtitle;
final IconData icon;
final Widget? trailing;
final bool showTrailingDivider;
final VoidCallback onTap;
const _OpenActionTile({
required this.title,
this.subtitle,
required this.icon,
this.trailing,
this.showTrailingDivider = true,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
leading: Icon(icon),
trailing: trailing == null
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showTrailingDivider)
const SizedBox(height: 24, child: VerticalDivider(width: 16)),
trailing!,
],
),
onTap: onTap,
);
}
}
@@ -0,0 +1,238 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_service.dart';
class TrackingDetailsDialog extends StatefulWidget {
final String currentUrl;
final UrlCleanerResult result;
final bool allowReferralMarketing;
final ValueChanged<String>? onApplySelectedRemovals;
const TrackingDetailsDialog({
super.key,
required this.currentUrl,
required this.result,
required this.allowReferralMarketing,
this.onApplySelectedRemovals,
});
@override
State<TrackingDetailsDialog> createState() => _TrackingDetailsDialogState();
}
class _TrackingDetailsDialogState extends State<TrackingDetailsDialog> {
late final List<RemovedParam> _items;
late final List<bool> _selected;
@override
void initState() {
super.initState();
_items = widget.result.removedParams;
_selected = _items.map(_isInitiallySelected).toList();
}
bool _isInitiallySelected(RemovedParam item) {
if (!widget.allowReferralMarketing) return true;
return item.type != UrlCleanerMatchType.referralRule;
}
String get _previewUrl {
var url = widget.currentUrl;
for (var i = 0; i < _items.length; i++) {
if (_selected[i]) {
url = removeUrlCleanerMatch(url, _items[i]);
}
}
return url;
}
({String key, String? value}) _splitMatch(String match) {
final separatorIndex = match.indexOf('=');
if (separatorIndex <= 0 || separatorIndex >= match.length - 1) {
return (key: match, value: null);
}
return (
key: match.substring(0, separatorIndex),
value: match.substring(separatorIndex + 1),
);
}
@override
Widget build(BuildContext context) {
final canApply = widget.onApplySelectedRemovals != null;
final selectedCount = _selected.where((isSelected) => isSelected).length;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final subtitleColor = textTheme.bodySmall?.color;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Remove Tracking Parameters', style: textTheme.titleLarge),
const SizedBox(height: 8),
Text(
'Select parameters to strip from this URL.',
style: textTheme.bodyMedium?.copyWith(color: subtitleColor),
),
const SizedBox(height: 16),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: ListView.separated(
shrinkWrap: true,
itemCount: _items.length,
separatorBuilder: (context, index) =>
Divider(height: 1, color: colorScheme.outlineVariant),
itemBuilder: (context, index) {
final item = _items[index];
final display = _splitMatch(item.match);
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: colorScheme.primary,
checkColor: colorScheme.onPrimary,
title: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
Expanded(
child: Text(
display.key,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (item.type == UrlCleanerMatchType.referralRule)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Referral marketing',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontSize: 11,
),
),
),
],
),
),
subtitle: display.value == null
? null
: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
display.value!,
style: textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
),
value: _selected[index],
onChanged: canApply
? (checked) {
setState(() {
_selected[index] = checked ?? false;
});
}
: null,
);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'$selectedCount of ${_items.length} selected for removal',
style: textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
),
Text(
'Cleaned URL:',
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.outlineVariant),
),
child: SelectableText(
_previewUrl,
style: textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
height: 1.4,
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(canApply ? 'Cancel' : 'Close'),
),
if (canApply) ...[
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
widget.onApplySelectedRemovals!(_previewUrl);
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
child: const Text('Apply Changes'),
),
],
],
),
],
),
),
),
);
}
}
@@ -0,0 +1,42 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
Future<bool?> showUrlCleanerRestoreDefaultsDialog(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Restore defaults?'),
content: const Text(
'This will reset URL cleaner settings and remove the locally stored catalog.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Restore'),
),
],
),
);
}
@@ -0,0 +1,218 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:fading_scroll/fading_scroll.dart';
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/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';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class UnshortenerSettingsScreen extends StatelessWidget {
const UnshortenerSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Unshortener')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
SettingSection(name: 'Unshortener'),
_UnshortenerDescriptionTile(),
SettingSection(name: 'Behavior'),
_UnshortenerEnabledTile(),
_UnshortenerTokenField(),
SettingSection(name: 'Attribution'),
_UnshortenerAttributionTile(),
],
);
},
),
),
);
}
}
class _UnshortenerEnabledTile extends HookConsumerWidget {
const _UnshortenerEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enabled = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.unshortenerEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Enable Unshortener'),
subtitle: const Text('Resolve shortened URLs to their destination'),
secondary: const Icon(MdiIcons.linkVariant),
value: enabled,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((current) => current.copyWith(unshortenerEnabled: value));
},
);
}
}
class _UnshortenerDescriptionTile extends StatelessWidget {
const _UnshortenerDescriptionTile();
@override
Widget build(BuildContext context) {
return const ListTile(
leading: Icon(MdiIcons.linkVariant),
title: Text('Description'),
subtitle: Text(
'This module will unshort links by sending them to unshorten.me, '
'which evaluates them on their servers and saves the redirection for future requests. '
'Avoid unshortening links with private or sensitive data.',
),
);
}
}
class _UnshortenerAttributionTile extends StatelessWidget {
const _UnshortenerAttributionTile();
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(MdiIcons.informationOutline),
title: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'The free API is rate limited to 10 requests per hour for new checks.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
const _AttributionLinkRow(
label: 'Service',
url: 'https://unshorten.me/',
),
const SizedBox(height: 8),
const _AttributionLinkRow(
label: 'Privacy policy',
url: 'https://unshorten.me/privacy-policy',
),
],
),
),
);
}
}
class _UnshortenerTokenField extends HookConsumerWidget {
const _UnshortenerTokenField();
@override
Widget build(BuildContext context, WidgetRef ref) {
final token = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.unshortenerToken),
);
final controller = useTextEditingController(text: token);
final focusNode = useFocusNode();
Future<void> saveToken() async {
final value = controller.text;
if (value == token) return;
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((current) => current.copyWith(unshortenerToken: value));
}
useOnListenableChange(focusNode, () {
if (!focusNode.hasFocus) unawaited(saveToken());
});
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(
labelText: 'API Token',
hintText: 'Optional token for higher limits',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
obscureText: true,
onSubmitted: (_) => saveToken(),
),
);
}
}
class _AttributionLinkRow extends StatelessWidget {
final String label;
final String url;
const _AttributionLinkRow({required this.label, required this.url});
@override
Widget build(BuildContext context) {
final linkStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: Theme.of(context).colorScheme.primary,
);
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () {
unawaited(openInPrivateCustomTab(context, url));
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(label, style: Theme.of(context).textTheme.bodySmall),
),
Expanded(child: Text(url, style: linkStyle)),
const SizedBox(width: 8),
Icon(
Icons.open_in_new,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
);
}
}
@@ -0,0 +1,338 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:fading_scroll/fading_scroll.dart';
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/core/providers/format.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/url_cleaner_restore_defaults_dialog.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart';
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/ui_helper.dart';
class UrlCleanerSettingsScreen extends StatelessWidget {
const UrlCleanerSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('URL Cleaner')),
body: SafeArea(
child: FadingScroll(
fadingSize: 25,
builder: (context, controller) {
return ListView(
controller: controller,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
children: const [
SettingSection(name: 'URL Cleaner'),
_UrlCleanerDescriptionTile(),
SettingSection(name: 'Behavior'),
_UrlCleanerEnabledTile(),
_UrlCleanerAutoApplyTile(),
_UrlCleanerAllowReferralTile(),
SettingSection(name: 'Catalog'),
_UrlCleanerAutoUpdateTile(),
_UrlCleanerUpdateButton(),
_UrlCleanerRestoreDefaultsButton(),
SettingSection(name: 'Attribution'),
_UrlCleanerAttributionTile(),
],
);
},
),
),
);
}
}
class _UrlCleanerDescriptionTile extends StatelessWidget {
const _UrlCleanerDescriptionTile();
@override
Widget build(BuildContext context) {
return const ListTile(
title: Text('Description'),
subtitle: Text(
'This module removes tracking, referrer and other useless parameters from the URL. '
'It also allows for common offline URL redirections.',
),
leading: Icon(MdiIcons.broom),
);
}
}
class _UrlCleanerEnabledTile extends HookConsumerWidget {
const _UrlCleanerEnabledTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final enabled = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.urlCleanerEnabled),
);
return SwitchListTile.adaptive(
title: const Text('Enable URL Cleaner'),
subtitle: const Text('Remove tracking parameters from URLs'),
secondary: const Icon(MdiIcons.broom),
value: enabled,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((current) => current.copyWith(urlCleanerEnabled: value));
},
);
}
}
class _UrlCleanerAutoApplyTile extends HookConsumerWidget {
const _UrlCleanerAutoApplyTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoApply = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.urlCleanerAutoApply),
);
return SwitchListTile.adaptive(
title: const Text('Auto-apply'),
subtitle: const Text('Automatically replace URL with cleaned version'),
secondary: const Icon(MdiIcons.autoFix),
value: autoApply,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((current) => current.copyWith(urlCleanerAutoApply: value));
},
);
}
}
class _UrlCleanerAllowReferralTile extends HookConsumerWidget {
const _UrlCleanerAllowReferralTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final allowReferral = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.urlCleanerAllowReferralMarketing,
),
);
return SwitchListTile.adaptive(
title: const Text('Allow referral marketing'),
subtitle: const Text('Keep referral and affiliate tracking parameters'),
secondary: const Icon(MdiIcons.cashMultiple),
value: allowReferral,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(current) =>
current.copyWith(urlCleanerAllowReferralMarketing: value),
);
},
);
}
}
class _UrlCleanerAutoUpdateTile extends HookConsumerWidget {
const _UrlCleanerAutoUpdateTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoUpdate = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.urlCleanerAutoUpdate),
);
return SwitchListTile.adaptive(
title: const Text('Auto-update catalog'),
subtitle: const Text('Check for rule updates weekly'),
secondary: const Icon(MdiIcons.update),
value: autoUpdate,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save((current) => current.copyWith(urlCleanerAutoUpdate: value));
},
);
}
}
class _UrlCleanerUpdateButton extends HookConsumerWidget {
const _UrlCleanerUpdateButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isUpdating = useState(false);
final lastCheckEpochMs = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.urlCleanerLastCheckEpochMs,
),
);
final lastUpdateWasAuto = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.urlCleanerLastUpdateWasAuto,
),
);
final lastUpdateAsync = useFuture(
useMemoized(
() => ref.read(urlCleanerCatalogServiceProvider.notifier).lastUpdate(),
[lastCheckEpochMs],
),
);
final lastUpdate = lastUpdateAsync.data;
final lastCheck = lastCheckEpochMs != null
? DateTime.fromMillisecondsSinceEpoch(lastCheckEpochMs)
: null;
final subtitle = StringBuffer(
lastUpdate == null
? 'Last update: not available'
: 'Last update: ${ref.read(formatProvider.notifier).shortDate(lastUpdate)}',
);
if (lastCheck != null) {
subtitle.write(
'\nLast check: ${ref.read(formatProvider.notifier).shortDate(lastCheck)}',
);
}
if (lastUpdateWasAuto) {
subtitle.write(' (auto)');
}
return ListTile(
title: const Text('Update catalog'),
subtitle: Text(subtitle.toString()),
leading: const Icon(MdiIcons.cloudDownload),
trailing: isUpdating.value
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: isUpdating.value
? null
: () async {
isUpdating.value = true;
try {
await ref
.read(urlCleanerCatalogServiceProvider.notifier)
.updateCatalog();
if (context.mounted) {
showInfoMessage(context, 'Catalog updated');
}
} catch (e) {
if (context.mounted) {
showErrorMessage(context, 'Update failed: $e');
}
} finally {
isUpdating.value = false;
}
},
);
}
}
class _UrlCleanerRestoreDefaultsButton extends HookConsumerWidget {
const _UrlCleanerRestoreDefaultsButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
title: const Text('Restore defaults'),
subtitle: const Text('Reset to bundled catalog and default settings'),
leading: const Icon(MdiIcons.restore),
onTap: () async {
final confirmed = await showUrlCleanerRestoreDefaultsDialog(context);
if (confirmed == true) {
final defaultSettings = GeneralSettings.withDefaults();
await ref
.read(urlCleanerCatalogServiceProvider.notifier)
.restoreBundledCatalog();
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(current) => current.copyWith(
urlCleanerEnabled: defaultSettings.urlCleanerEnabled,
urlCleanerAutoApply: defaultSettings.urlCleanerAutoApply,
urlCleanerAllowReferralMarketing:
defaultSettings.urlCleanerAllowReferralMarketing,
urlCleanerCatalogUrl: defaultSettings.urlCleanerCatalogUrl,
urlCleanerHashUrl: defaultSettings.urlCleanerHashUrl,
urlCleanerAutoUpdate: defaultSettings.urlCleanerAutoUpdate,
urlCleanerLastCheckEpochMs:
defaultSettings.urlCleanerLastCheckEpochMs,
urlCleanerLastUpdateWasAuto:
defaultSettings.urlCleanerLastUpdateWasAuto,
),
);
}
},
);
}
}
class _UrlCleanerAttributionTile extends StatelessWidget {
const _UrlCleanerAttributionTile();
@override
Widget build(BuildContext context) {
final textColor = Theme.of(context).textTheme.bodyMedium?.color;
final linkStyle = TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: Theme.of(context).colorScheme.primary,
);
return ListTile(
title: RichText(
text: TextSpan(
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: textColor),
children: [
const TextSpan(text: 'This module is based on the ClearURL rules:'),
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: AttributionLink(
label: 'https://docs.clearurls.xyz/latest/specs/rules/',
url: 'https://docs.clearurls.xyz/latest/specs/rules/',
style: linkStyle,
),
),
],
),
),
leading: const Icon(MdiIcons.informationOutline),
dense: false,
);
}
}
@@ -0,0 +1,36 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoBrowserService;
import 'package:weblibre/utils/ui_helper.dart';
Future<void> openInPrivateCustomTab(BuildContext context, String url) async {
try {
await GeckoBrowserService().openInCustomTab(
url: Uri.parse(url),
private: true,
);
} catch (e) {
if (context.mounted) {
showErrorMessage(context, 'Could not open link: $url');
}
}
}
@@ -0,0 +1,46 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart';
class AttributionLink extends StatelessWidget {
final String label;
final String url;
final TextStyle style;
const AttributionLink({
super.key,
required this.label,
required this.url,
required this.style,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
unawaited(openInPrivateCustomTab(context, url));
},
child: Text(label, style: style),
);
}
}
@@ -21,7 +21,6 @@ import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
@@ -50,6 +49,7 @@ class PrivacySecuritySettingsScreen extends StatelessWidget {
children: const [
_PrivacyModesSection(),
_TrackingProtectionSection(),
_OpenLinkModulesSection(),
_ConnectionSecuritySection(),
_DataManagementSection(),
_AdvancedSection(),
@@ -104,7 +104,9 @@ class _TrackingProtectionExceptionsTile extends StatelessWidget {
title: const Text('Tracking Protection Exceptions'),
subtitle: const Text('Sites where tracking protection is disabled'),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/settings/tracking_protection_exceptions'),
onTap: () async {
await TrackingProtectionExceptionsRoute().push(context);
},
);
}
}
@@ -124,6 +126,55 @@ class _ConnectionSecuritySection extends StatelessWidget {
}
}
class _OpenLinkModulesSection extends StatelessWidget {
const _OpenLinkModulesSection();
@override
Widget build(BuildContext context) {
return const Column(
children: [
SettingSection(name: 'Open Link Modules'),
_UrlCleanerSettingsTile(),
_UnshortenerSettingsTile(),
],
);
}
}
class _UrlCleanerSettingsTile extends StatelessWidget {
const _UrlCleanerSettingsTile();
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(MdiIcons.broom),
title: const Text('URL Cleaner'),
subtitle: const Text('Tracking removal rules and catalog updates'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await UrlCleanerSettingsRoute().push(context);
},
);
}
}
class _UnshortenerSettingsTile extends StatelessWidget {
const _UnshortenerSettingsTile();
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(MdiIcons.linkVariant),
title: const Text('Unshortener'),
subtitle: const Text('Short link resolver and API token'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await UnshortenerSettingsRoute().push(context);
},
);
}
}
class _DataManagementSection extends StatelessWidget {
const _DataManagementSection();
@@ -91,6 +91,16 @@ class GeneralSettings with FastEquatable {
final bool drawerGestureEnabled;
final String syncServerOverride;
final String syncTokenServerOverride;
final bool urlCleanerEnabled;
final bool urlCleanerAutoApply;
final bool urlCleanerAllowReferralMarketing;
final String urlCleanerCatalogUrl;
final String urlCleanerHashUrl;
final bool urlCleanerAutoUpdate;
final int? urlCleanerLastCheckEpochMs;
final bool urlCleanerLastUpdateWasAuto;
final bool unshortenerEnabled;
final String unshortenerToken;
GeneralSettings({
required this.themeMode,
@@ -124,6 +134,16 @@ class GeneralSettings with FastEquatable {
required this.drawerGestureEnabled,
required this.syncServerOverride,
required this.syncTokenServerOverride,
required this.urlCleanerEnabled,
required this.urlCleanerAutoApply,
required this.urlCleanerAllowReferralMarketing,
required this.urlCleanerCatalogUrl,
required this.urlCleanerHashUrl,
required this.urlCleanerAutoUpdate,
required this.urlCleanerLastCheckEpochMs,
required this.urlCleanerLastUpdateWasAuto,
required this.unshortenerEnabled,
required this.unshortenerToken,
});
GeneralSettings.withDefaults({
@@ -158,6 +178,16 @@ class GeneralSettings with FastEquatable {
bool? drawerGestureEnabled,
String? syncServerOverride,
String? syncTokenServerOverride,
bool? urlCleanerEnabled,
bool? urlCleanerAutoApply,
bool? urlCleanerAllowReferralMarketing,
String? urlCleanerCatalogUrl,
String? urlCleanerHashUrl,
bool? urlCleanerAutoUpdate,
this.urlCleanerLastCheckEpochMs,
bool? urlCleanerLastUpdateWasAuto,
bool? unshortenerEnabled,
String? unshortenerToken,
}) : themeMode = themeMode ?? ThemeMode.dark,
enableReadability = enableReadability ?? true,
enforceReadability = enforceReadability ?? false,
@@ -192,7 +222,21 @@ class GeneralSettings with FastEquatable {
quickTabSwitcherShowTitles = quickTabSwitcherShowTitles ?? true,
drawerGestureEnabled = drawerGestureEnabled ?? false,
syncServerOverride = syncServerOverride ?? '',
syncTokenServerOverride = syncTokenServerOverride ?? '';
syncTokenServerOverride = syncTokenServerOverride ?? '',
urlCleanerEnabled = urlCleanerEnabled ?? true,
urlCleanerAutoApply = urlCleanerAutoApply ?? false,
urlCleanerAllowReferralMarketing =
urlCleanerAllowReferralMarketing ?? false,
urlCleanerCatalogUrl =
urlCleanerCatalogUrl ??
'https://rules2.clearurls.xyz/data.minify.json',
urlCleanerHashUrl =
urlCleanerHashUrl ??
'https://rules2.clearurls.xyz/rules.minify.hash',
urlCleanerAutoUpdate = urlCleanerAutoUpdate ?? false,
urlCleanerLastUpdateWasAuto = urlCleanerLastUpdateWasAuto ?? false,
unshortenerEnabled = unshortenerEnabled ?? false,
unshortenerToken = unshortenerToken ?? '';
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
_$GeneralSettingsFromJson(json);
@@ -232,5 +276,15 @@ class GeneralSettings with FastEquatable {
drawerGestureEnabled,
syncServerOverride,
syncTokenServerOverride,
urlCleanerEnabled,
urlCleanerAutoApply,
urlCleanerAllowReferralMarketing,
urlCleanerCatalogUrl,
urlCleanerHashUrl,
urlCleanerAutoUpdate,
urlCleanerLastCheckEpochMs,
urlCleanerLastUpdateWasAuto,
unshortenerEnabled,
unshortenerToken,
];
}
@@ -81,6 +81,28 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride);
GeneralSettings urlCleanerEnabled(bool urlCleanerEnabled);
GeneralSettings urlCleanerAutoApply(bool urlCleanerAutoApply);
GeneralSettings urlCleanerAllowReferralMarketing(
bool urlCleanerAllowReferralMarketing,
);
GeneralSettings urlCleanerCatalogUrl(String urlCleanerCatalogUrl);
GeneralSettings urlCleanerHashUrl(String urlCleanerHashUrl);
GeneralSettings urlCleanerAutoUpdate(bool urlCleanerAutoUpdate);
GeneralSettings urlCleanerLastCheckEpochMs(int? urlCleanerLastCheckEpochMs);
GeneralSettings urlCleanerLastUpdateWasAuto(bool urlCleanerLastUpdateWasAuto);
GeneralSettings unshortenerEnabled(bool unshortenerEnabled);
GeneralSettings unshortenerToken(String unshortenerToken);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
///
@@ -120,6 +142,16 @@ abstract class _$GeneralSettingsCWProxy {
bool drawerGestureEnabled,
String syncServerOverride,
String syncTokenServerOverride,
bool urlCleanerEnabled,
bool urlCleanerAutoApply,
bool urlCleanerAllowReferralMarketing,
String urlCleanerCatalogUrl,
String urlCleanerHashUrl,
bool urlCleanerAutoUpdate,
int? urlCleanerLastCheckEpochMs,
bool urlCleanerLastUpdateWasAuto,
bool unshortenerEnabled,
String unshortenerToken,
});
}
@@ -259,6 +291,48 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride) =>
call(syncTokenServerOverride: syncTokenServerOverride);
@override
GeneralSettings urlCleanerEnabled(bool urlCleanerEnabled) =>
call(urlCleanerEnabled: urlCleanerEnabled);
@override
GeneralSettings urlCleanerAutoApply(bool urlCleanerAutoApply) =>
call(urlCleanerAutoApply: urlCleanerAutoApply);
@override
GeneralSettings urlCleanerAllowReferralMarketing(
bool urlCleanerAllowReferralMarketing,
) => call(urlCleanerAllowReferralMarketing: urlCleanerAllowReferralMarketing);
@override
GeneralSettings urlCleanerCatalogUrl(String urlCleanerCatalogUrl) =>
call(urlCleanerCatalogUrl: urlCleanerCatalogUrl);
@override
GeneralSettings urlCleanerHashUrl(String urlCleanerHashUrl) =>
call(urlCleanerHashUrl: urlCleanerHashUrl);
@override
GeneralSettings urlCleanerAutoUpdate(bool urlCleanerAutoUpdate) =>
call(urlCleanerAutoUpdate: urlCleanerAutoUpdate);
@override
GeneralSettings urlCleanerLastCheckEpochMs(int? urlCleanerLastCheckEpochMs) =>
call(urlCleanerLastCheckEpochMs: urlCleanerLastCheckEpochMs);
@override
GeneralSettings urlCleanerLastUpdateWasAuto(
bool urlCleanerLastUpdateWasAuto,
) => call(urlCleanerLastUpdateWasAuto: urlCleanerLastUpdateWasAuto);
@override
GeneralSettings unshortenerEnabled(bool unshortenerEnabled) =>
call(unshortenerEnabled: unshortenerEnabled);
@override
GeneralSettings unshortenerToken(String unshortenerToken) =>
call(unshortenerToken: unshortenerToken);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
@@ -299,6 +373,16 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? drawerGestureEnabled = const $CopyWithPlaceholder(),
Object? syncServerOverride = const $CopyWithPlaceholder(),
Object? syncTokenServerOverride = const $CopyWithPlaceholder(),
Object? urlCleanerEnabled = const $CopyWithPlaceholder(),
Object? urlCleanerAutoApply = const $CopyWithPlaceholder(),
Object? urlCleanerAllowReferralMarketing = const $CopyWithPlaceholder(),
Object? urlCleanerCatalogUrl = const $CopyWithPlaceholder(),
Object? urlCleanerHashUrl = const $CopyWithPlaceholder(),
Object? urlCleanerAutoUpdate = const $CopyWithPlaceholder(),
Object? urlCleanerLastCheckEpochMs = const $CopyWithPlaceholder(),
Object? urlCleanerLastUpdateWasAuto = const $CopyWithPlaceholder(),
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
Object? unshortenerToken = const $CopyWithPlaceholder(),
}) {
return GeneralSettings(
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
@@ -483,6 +567,65 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.syncTokenServerOverride
// ignore: cast_nullable_to_non_nullable
: syncTokenServerOverride as String,
urlCleanerEnabled:
urlCleanerEnabled == const $CopyWithPlaceholder() ||
urlCleanerEnabled == null
? _value.urlCleanerEnabled
// ignore: cast_nullable_to_non_nullable
: urlCleanerEnabled as bool,
urlCleanerAutoApply:
urlCleanerAutoApply == const $CopyWithPlaceholder() ||
urlCleanerAutoApply == null
? _value.urlCleanerAutoApply
// ignore: cast_nullable_to_non_nullable
: urlCleanerAutoApply as bool,
urlCleanerAllowReferralMarketing:
urlCleanerAllowReferralMarketing == const $CopyWithPlaceholder() ||
urlCleanerAllowReferralMarketing == null
? _value.urlCleanerAllowReferralMarketing
// ignore: cast_nullable_to_non_nullable
: urlCleanerAllowReferralMarketing as bool,
urlCleanerCatalogUrl:
urlCleanerCatalogUrl == const $CopyWithPlaceholder() ||
urlCleanerCatalogUrl == null
? _value.urlCleanerCatalogUrl
// ignore: cast_nullable_to_non_nullable
: urlCleanerCatalogUrl as String,
urlCleanerHashUrl:
urlCleanerHashUrl == const $CopyWithPlaceholder() ||
urlCleanerHashUrl == null
? _value.urlCleanerHashUrl
// ignore: cast_nullable_to_non_nullable
: urlCleanerHashUrl as String,
urlCleanerAutoUpdate:
urlCleanerAutoUpdate == const $CopyWithPlaceholder() ||
urlCleanerAutoUpdate == null
? _value.urlCleanerAutoUpdate
// ignore: cast_nullable_to_non_nullable
: urlCleanerAutoUpdate as bool,
urlCleanerLastCheckEpochMs:
urlCleanerLastCheckEpochMs == const $CopyWithPlaceholder()
? _value.urlCleanerLastCheckEpochMs
// ignore: cast_nullable_to_non_nullable
: urlCleanerLastCheckEpochMs as int?,
urlCleanerLastUpdateWasAuto:
urlCleanerLastUpdateWasAuto == const $CopyWithPlaceholder() ||
urlCleanerLastUpdateWasAuto == null
? _value.urlCleanerLastUpdateWasAuto
// ignore: cast_nullable_to_non_nullable
: urlCleanerLastUpdateWasAuto as bool,
unshortenerEnabled:
unshortenerEnabled == const $CopyWithPlaceholder() ||
unshortenerEnabled == null
? _value.unshortenerEnabled
// ignore: cast_nullable_to_non_nullable
: unshortenerEnabled as bool,
unshortenerToken:
unshortenerToken == const $CopyWithPlaceholder() ||
unshortenerToken == null
? _value.unshortenerToken
// ignore: cast_nullable_to_non_nullable
: unshortenerToken as String,
);
}
}
@@ -564,6 +707,18 @@ GeneralSettings _$GeneralSettingsFromJson(
drawerGestureEnabled: json['drawerGestureEnabled'] as bool?,
syncServerOverride: json['syncServerOverride'] as String?,
syncTokenServerOverride: json['syncTokenServerOverride'] as String?,
urlCleanerEnabled: json['urlCleanerEnabled'] as bool?,
urlCleanerAutoApply: json['urlCleanerAutoApply'] as bool?,
urlCleanerAllowReferralMarketing:
json['urlCleanerAllowReferralMarketing'] as bool?,
urlCleanerCatalogUrl: json['urlCleanerCatalogUrl'] as String?,
urlCleanerHashUrl: json['urlCleanerHashUrl'] as String?,
urlCleanerAutoUpdate: json['urlCleanerAutoUpdate'] as bool?,
urlCleanerLastCheckEpochMs: (json['urlCleanerLastCheckEpochMs'] as num?)
?.toInt(),
urlCleanerLastUpdateWasAuto: json['urlCleanerLastUpdateWasAuto'] as bool?,
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
unshortenerToken: json['unshortenerToken'] as String?,
);
Map<String, dynamic> _$GeneralSettingsToJson(
@@ -609,6 +764,16 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'drawerGestureEnabled': instance.drawerGestureEnabled,
'syncServerOverride': instance.syncServerOverride,
'syncTokenServerOverride': instance.syncTokenServerOverride,
'urlCleanerEnabled': instance.urlCleanerEnabled,
'urlCleanerAutoApply': instance.urlCleanerAutoApply,
'urlCleanerAllowReferralMarketing': instance.urlCleanerAllowReferralMarketing,
'urlCleanerCatalogUrl': instance.urlCleanerCatalogUrl,
'urlCleanerHashUrl': instance.urlCleanerHashUrl,
'urlCleanerAutoUpdate': instance.urlCleanerAutoUpdate,
'urlCleanerLastCheckEpochMs': instance.urlCleanerLastCheckEpochMs,
'urlCleanerLastUpdateWasAuto': instance.urlCleanerLastUpdateWasAuto,
'unshortenerEnabled': instance.unshortenerEnabled,
'unshortenerToken': instance.unshortenerToken,
};
const _$ThemeModeEnumMap = {
@@ -163,6 +163,43 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.string,
db.typeMapping,
),
'urlCleanerEnabled': settings['urlCleanerEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'urlCleanerAutoApply': settings['urlCleanerAutoApply']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'urlCleanerAllowReferralMarketing':
settings['urlCleanerAllowReferralMarketing']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'urlCleanerCatalogUrl': settings['urlCleanerCatalogUrl']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'urlCleanerHashUrl': settings['urlCleanerHashUrl']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'urlCleanerAutoUpdate': settings['urlCleanerAutoUpdate']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'urlCleanerLastCheckEpochMs': settings['urlCleanerLastCheckEpochMs']
?.readAs(DriftSqlType.int, db.typeMapping),
'urlCleanerLastUpdateWasAuto': settings['urlCleanerLastUpdateWasAuto']
?.readAs(DriftSqlType.bool, db.typeMapping),
'unshortenerEnabled': settings['unshortenerEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'unshortenerToken': settings['unshortenerToken']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
});
}
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'ed5bca200b840e0802980b85a0c0f682d7b24a8c';
r'83aed230cdc33b3e15b91fdb433427faaa8c7cc7';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {
+1
View File
@@ -15,6 +15,7 @@ dependencies:
copy_with_extension: ^11.0.0
country_codes: ^3.3.0
country_flags: ^4.1.2
crypto: ^3.0.6
cryptography_flutter: ^2.3.4
device_info_plus: ^12.3.0
drift: ^2.31.0
@@ -0,0 +1,355 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:riverpod/riverpod.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_unshortener_service.dart';
void main() {
late ProviderContainer container;
late UrlUnshortenerService service;
setUp(() {
container = ProviderContainer();
service = container.read(urlUnshortenerServiceProvider.notifier);
});
tearDown(() {
container.dispose();
});
group('warning list asset compatibility', () {
test('parses current MISP url-shortener list format', () {
final rawJson = File(
'assets/preferences/url-shortener-list.json',
).readAsStringSync();
final decoded = jsonDecode(rawJson) as Map<String, dynamic>;
expect(decoded['type'], 'hostname');
expect(decoded['matching_attributes'], isA<List<dynamic>>());
expect(decoded['list'], isA<List<dynamic>>());
final hosts = service.parseSupportedShortenerHosts(rawJson);
expect(hosts.length, greaterThan(200));
expect(hosts, contains('bit.ly'));
expect(service.isSupportedShortenerHost('www.bit.ly', hosts), isTrue);
expect(
service.isSupportedShortenerUrl('https://example.com', hosts),
isFalse,
);
});
});
group('supported shortener host checks', () {
test('parses list hosts from warning list json', () {
final hosts = service.parseSupportedShortenerHosts(
jsonEncode({
'list': ['bit.ly', 't.co', 'TinyURL.com', '*.short.cm'],
}),
);
expect(hosts, containsAll({'bit.ly', 't.co', 'tinyurl.com', 'short.cm'}));
});
test('normalizes URL-like entries to hostnames', () {
final hosts = service.parseSupportedShortenerHosts(
jsonEncode({
'list': ['https://bit.ly/abc', 'tinyurl.com/path?a=1', 't.co/#frag'],
}),
);
expect(hosts, containsAll({'bit.ly', 'tinyurl.com', 't.co'}));
});
test('matches exact and subdomain hosts', () {
const supportedHosts = {'bit.ly', 't.co'};
expect(
service.isSupportedShortenerUrl('https://bit.ly/abc', supportedHosts),
isTrue,
);
expect(
service.isSupportedShortenerUrl('https://www.t.co/abc', supportedHosts),
isTrue,
);
expect(
service.isSupportedShortenerUrl(
'https://example.com/abc',
supportedHosts,
),
isFalse,
);
});
test('matches urls without a scheme', () {
const supportedHosts = {'tinyurl.com'};
expect(
service.isSupportedShortenerUrl('tinyurl.com/abc', supportedHosts),
isTrue,
);
expect(
service.isSupportedShortenerUrl('notinyurl.com/abc', supportedHosts),
isFalse,
);
});
});
group('unshortenUrl', () {
group('unauthenticated requests', () {
test('resolves shortened URL successfully', () async {
final client = MockClient((request) async {
expect(request.url.host, 'unshorten.me');
expect(request.url.pathSegments, contains('json'));
expect(request.headers, isNot(contains('Authorization')));
return http.Response(
jsonEncode({
'success': true,
'resolved_url': 'https://example.com/full-article',
'remaining_calls': 8,
'usage_count': 10,
}),
200,
);
});
final result = await service.unshortenUrl(
'https://bit.ly/abc123',
client: client,
);
expect(result.success, isTrue);
expect(result.finalUrl, 'https://example.com/full-article');
expect(result.remainingCalls, 8);
expect(result.usageCount, 10);
expect(result.error, isNull);
});
test('encodes URL in request path', () async {
late Uri capturedUri;
final client = MockClient((request) async {
capturedUri = request.url;
return http.Response(
jsonEncode({
'success': true,
'resolved_url': 'https://example.com',
}),
200,
);
});
await service.unshortenUrl(
'https://bit.ly/test?a=1&b=2',
client: client,
);
expect(
capturedUri.toString(),
contains(Uri.encodeComponent('https://bit.ly/test?a=1&b=2')),
);
});
test('returns error on API failure', () async {
final client = MockClient((request) async {
return http.Response(
jsonEncode({'success': false, 'error': 'Could not resolve URL'}),
200,
);
});
final result = await service.unshortenUrl(
'https://invalid-short.url/x',
client: client,
);
expect(result.success, isFalse);
expect(result.error, 'Could not resolve URL');
expect(result.finalUrl, isNull);
});
test('returns error on HTTP error status', () async {
final client = MockClient((request) async {
return http.Response('Server Error', 500);
});
final result = await service.unshortenUrl(
'https://bit.ly/abc',
client: client,
);
expect(result.success, isFalse);
expect(result.error, 'HTTP 500');
});
test('returns error on rate limit', () async {
final client = MockClient((request) async {
return http.Response('Too Many Requests', 429);
});
final result = await service.unshortenUrl(
'https://bit.ly/abc',
client: client,
);
expect(result.success, isFalse);
expect(result.error, 'HTTP 429');
});
test('handles missing success field', () async {
final client = MockClient((request) async {
return http.Response(
jsonEncode({'resolved_url': 'https://example.com'}),
200,
);
});
final result = await service.unshortenUrl(
'https://bit.ly/abc',
client: client,
);
// success defaults to false when missing
expect(result.success, isFalse);
});
test('handles missing error field on failure', () async {
final client = MockClient((request) async {
return http.Response(jsonEncode({'success': false}), 200);
});
final result = await service.unshortenUrl(
'https://bit.ly/abc',
client: client,
);
expect(result.success, isFalse);
expect(result.error, 'Unknown error');
});
});
group('authenticated requests', () {
test('sends token in Authorization header', () async {
late Map<String, String> capturedHeaders;
final client = MockClient((request) async {
capturedHeaders = request.headers;
return http.Response(
jsonEncode({
'unshortened_url': 'https://example.com/page',
'remaining_calls': 95,
'usage_count': 100,
}),
200,
);
});
await service.unshortenUrl(
'https://bit.ly/abc',
token: 'my-api-token',
client: client,
);
expect(capturedHeaders['Authorization'], 'Token my-api-token');
});
test('uses v2 API endpoint with token', () async {
late Uri capturedUri;
final client = MockClient((request) async {
capturedUri = request.url;
return http.Response(
jsonEncode({'unshortened_url': 'https://example.com'}),
200,
);
});
await service.unshortenUrl(
'https://bit.ly/abc',
token: 'token123',
client: client,
);
expect(capturedUri.pathSegments, contains('v2'));
expect(capturedUri.pathSegments, contains('unshorten'));
expect(capturedUri.queryParameters['url'], isNotNull);
});
test('resolves URL with token successfully', () async {
final client = MockClient((request) async {
return http.Response(
jsonEncode({
'unshortened_url': 'https://example.com/target',
'remaining_calls': 50,
'usage_count': 100,
}),
200,
);
});
final result = await service.unshortenUrl(
'https://t.co/abc',
token: 'valid-token',
client: client,
);
expect(result.success, isTrue);
expect(result.finalUrl, 'https://example.com/target');
expect(result.remainingCalls, 50);
expect(result.usageCount, 100);
});
test('returns error from authenticated API', () async {
final client = MockClient((request) async {
return http.Response(jsonEncode({'error': 'Invalid token'}), 200);
});
final result = await service.unshortenUrl(
'https://bit.ly/abc',
token: 'bad-token',
client: client,
);
expect(result.success, isFalse);
expect(result.error, 'Invalid token');
});
test('handles empty error field as success', () async {
final client = MockClient((request) async {
return http.Response(
jsonEncode({'error': '', 'unshortened_url': 'https://example.com'}),
200,
);
});
final result = await service.unshortenUrl(
'https://bit.ly/abc',
token: 'token',
client: client,
);
expect(result.success, isTrue);
expect(result.finalUrl, 'https://example.com');
});
});
});
}
File diff suppressed because it is too large Load Diff
+100 -50
View File
@@ -15,7 +15,11 @@ PlatformException _createConnectionError(String channelName) {
);
}
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
if (empty) {
return <Object?>[];
}
@@ -24,37 +28,48 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
}
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
}
if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
return a.length == b.length &&
a.entries.every(
(MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]));
_deepEquals(entry.value, b[entry.key]),
);
}
return a == b;
}
/// Transport types for Tor connections
enum TransportType {
/// Direct Tor connection (no bridges)
none,
/// obfs4 pluggable transport
obfs4,
/// Snowflake pluggable transport (default broker)
snowflake,
/// Snowflake via AMP cache
snowflakeAmp,
/// Meek pluggable transport
meek,
/// Meek via Azure CDN
meekAzure,
/// WebTunnel pluggable transport
webtunnel,
/// Custom bridge lines (passthrough)
custom,
}
@@ -95,7 +110,8 @@ class TorConfiguration {
}
Object encode() {
return _toList(); }
return _toList();
}
static TorConfiguration decode(Object result) {
result as List<Object?>;
@@ -122,8 +138,7 @@ class TorConfiguration {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
int get hashCode => Object.hashAll(_toList());
}
/// Current Tor status
@@ -162,7 +177,8 @@ class TorStatus {
}
Object encode() {
return _toList(); }
return _toList();
}
static TorStatus decode(Object result) {
result as List<Object?>;
@@ -189,8 +205,7 @@ class TorStatus {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
int get hashCode => Object.hashAll(_toList());
}
/// Log message from Tor
@@ -211,15 +226,12 @@ class TorLogMessage {
int timestamp;
List<Object?> _toList() {
return <Object?>[
severity,
message,
timestamp,
];
return <Object?>[severity, message, timestamp];
}
Object encode() {
return _toList(); }
return _toList();
}
static TorLogMessage decode(Object result) {
result as List<Object?>;
@@ -244,11 +256,9 @@ class TorLogMessage {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -298,7 +308,9 @@ class TorApi {
/// BinaryMessenger will be used which routes to the host platform.
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -308,13 +320,16 @@ class TorApi {
/// Start Tor with the given configuration
/// Returns a Future to avoid blocking the main thread
Future<int> startTor(TorConfiguration config) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[config]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[config],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
@@ -336,7 +351,8 @@ class TorApi {
/// Stop Tor
Future<void> stopTor() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -359,7 +375,8 @@ class TorApi {
/// Get current status
Future<TorStatus> getStatus() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -387,7 +404,8 @@ class TorApi {
/// Request a new Tor identity (new circuit)
Future<void> requestNewIdentity() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -419,54 +437,76 @@ abstract class TorLogApi {
/// Called when status changes
void onStatusChanged(TorStatus status);
static void setUp(TorLogApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
static void setUp(
TorLogApi? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null.');
assert(
message != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null.',
);
final List<Object?> args = (message as List<Object?>?)!;
final TorLogMessage? arg_log = (args[0] as TorLogMessage?);
assert(arg_log != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null, expected non-null TorLogMessage.');
assert(
arg_log != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null, expected non-null TorLogMessage.',
);
try {
api.onLogMessage(arg_log!);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null.');
assert(
message != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null.',
);
final List<Object?> args = (message as List<Object?>?)!;
final TorStatus? arg_status = (args[0] as TorStatus?);
assert(arg_status != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null, expected non-null TorStatus.');
assert(
arg_status != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null, expected non-null TorStatus.',
);
try {
api.onStatusChanged(arg_status!);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
@@ -478,9 +518,13 @@ class IPtProxyController {
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
IPtProxyController({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -488,13 +532,16 @@ class IPtProxyController {
final String pigeonVar_messageChannelSuffix;
Future<int> start(TransportType proxyType, String proxy) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType, proxy]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[proxyType, proxy],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
@@ -515,13 +562,16 @@ class IPtProxyController {
}
Future<void> stop(TransportType proxyType) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[proxyType],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
@@ -14,40 +14,39 @@ PlatformException _createConnectionError(String channelName) {
message: 'Unable to establish connection on channel: "$channelName".',
);
}
bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
}
if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
return a.length == b.length &&
a.entries.every(
(MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]));
_deepEquals(entry.value, b[entry.key]),
);
}
return a == b;
}
class LocalizedResult {
LocalizedResult({
required this.languageName,
this.countryName,
});
LocalizedResult({required this.languageName, this.countryName});
String languageName;
String? countryName;
List<Object?> _toList() {
return <Object?>[
languageName,
countryName,
];
return <Object?>[languageName, countryName];
}
Object encode() {
return _toList(); }
return _toList();
}
static LocalizedResult decode(Object result) {
result as List<Object?>;
@@ -71,11 +70,9 @@ class LocalizedResult {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -106,23 +103,33 @@ class LocaleResolver {
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
LocaleResolver({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<LocalizedResult> resolve(String languageTag, String targetLangouageTag) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
Future<LocalizedResult> resolve(
String languageTag,
String targetLangouageTag,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[languageTag, targetLangouageTag]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[languageTag, targetLangouageTag],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
@@ -8,7 +8,11 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart';
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
if (empty) {
return <Object?>[];
}
@@ -17,21 +21,25 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
}
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
}
if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
return a.length == b.length &&
a.entries.every(
(MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]));
_deepEquals(entry.value, b[entry.key]),
);
}
return a == b;
}
class Intent {
Intent({
this.fromPackageName,
@@ -66,7 +74,8 @@ class Intent {
}
Object encode() {
return _toList(); }
return _toList();
}
static Intent decode(Object result) {
result as List<Object?>;
@@ -94,11 +103,9 @@ class Intent {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList())
;
int get hashCode => Object.hashAll(_toList());
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -130,32 +137,48 @@ abstract class IntentEvents {
void onIntentReceived(int sequence, Intent intent);
static void setUp(IntentEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
static void setUp(
IntentEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null,
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null.');
assert(
message != null,
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null.',
);
final List<Object?> args = (message as List<Object?>?)!;
final int? arg_sequence = (args[0] as int?);
assert(arg_sequence != null,
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null int.');
assert(
arg_sequence != null,
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null int.',
);
final Intent? arg_intent = (args[1] as Intent?);
assert(arg_intent != null,
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null Intent.');
assert(
arg_intent != null,
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null Intent.',
);
try {
api.onIntentReceived(arg_sequence!, arg_intent!);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
@@ -15,7 +15,11 @@ PlatformException _createConnectionError(String channelName) {
);
}
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
if (empty) {
return <Object?>[];
}
@@ -25,7 +29,6 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
return <Object?>[error.code, error.message, error.details];
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -52,9 +55,13 @@ class SpeechToTextApi {
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
SpeechToTextApi({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -69,13 +76,16 @@ class SpeechToTextApi {
/// The [locale] parameter specifies the language locale for recognition
/// (e.g., 'en-US', 'de-DE'). If null, uses the device default.
Future<bool> showDialog({String? locale}) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[locale]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[locale],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
@@ -106,29 +116,43 @@ abstract class SpeechToTextEvents {
/// recognition failed or was cancelled.
void onTextReceived(String text);
static void setUp(SpeechToTextEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
static void setUp(
SpeechToTextEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null,
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null.');
assert(
message != null,
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null.',
);
final List<Object?> args = (message as List<Object?>?)!;
final String? arg_text = (args[0] as String?);
assert(arg_text != null,
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null, expected non-null String.');
assert(
arg_text != null,
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null, expected non-null String.',
);
try {
api.onTextReceived(arg_text!);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env sh
set -eu
BASE=$(dirname "$0")
cd "$BASE/../app/assets/preferences" || exit
TARGET="url_cleaner_data.minify.json"
TMP_FILE="${TARGET}.tmp"
if wget -O "$TMP_FILE" "https://rules2.clearurls.xyz/data.minify.json"; then
mv "$TMP_FILE" "$TARGET"
echo "Updated $TARGET"
else
rm -f "$TMP_FILE"
echo "Failed to fetch $TARGET, keeping existing file" >&2
fi
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env sh
set -eu
BASE=$(dirname "$0")
cd "$BASE/../app/assets/preferences" || exit
TARGET="url-shortener-list.json"
TMP_FILE="${TARGET}.tmp"
if wget -O "$TMP_FILE" https://raw.githubusercontent.com/MISP/misp-warninglists/refs/heads/main/lists/url-shortener/list.json; then
mv "$TMP_FILE" "$TARGET"
echo "Updated $TARGET"
else
rm -f "$TMP_FILE"
echo "Failed to fetch $TARGET, keeping existing file" >&2
fi