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); return pattern.format(date);
} }
String shortDate(DateTime date) {
return DateFormat('yyyy-MM-dd').format(date);
}
@override @override
Future<void> build() async { Future<void> build() async {
await initializeDateFormatting(); await initializeDateFormatting();
+1 -1
View File
@@ -32,7 +32,7 @@ final class FormatProvider extends $AsyncNotifierProvider<Format, void> {
Format create() => Format(); Format create() => Format();
} }
String _$formatHash() => r'f132aabb20bf4df77d771d6c866bc413c00bd9ff'; String _$formatHash() => r'695b94cc4b25596e4ef953d08ba954424a5ec33e';
abstract class _$Format extends $AsyncNotifier<void> { abstract class _$Format extends $AsyncNotifier<void> {
FutureOr<void> build(); 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_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_folder_edit.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.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/dialogs/tab_tree.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/screens/browser.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/browser/presentation/screens/tab_view.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.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/contextmenu/presentation/context_menu_dialog.dart';
import 'package:weblibre/features/geckoview/features/history/presentation/screens/history.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/search/presentation/screens/search.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.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'; 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', name: 'SyncSettingsRoute',
factory: $SyncSettingsRoute._fromState, 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); 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( RouteBase get $torProxyRoute => GoRouteData.$route(
path: '/tor', path: '/tor',
name: 'TorProxyRoute', name: 'TorProxyRoute',
+24
View File
@@ -81,6 +81,14 @@ part of 'routes.dart';
), ),
TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'), TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'),
TypedGoRoute<SyncSettingsRoute>(name: 'SyncSettingsRoute', path: 'sync'), 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 { class SettingsRoute extends GoRouteData with $SettingsRoute {
@@ -222,3 +230,19 @@ class SyncSettingsRoute extends GoRouteData with $SyncSettingsRoute {
return const SyncSettingsScreen(); 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.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/core/routing/routes.dart';
@@ -50,6 +49,7 @@ class PrivacySecuritySettingsScreen extends StatelessWidget {
children: const [ children: const [
_PrivacyModesSection(), _PrivacyModesSection(),
_TrackingProtectionSection(), _TrackingProtectionSection(),
_OpenLinkModulesSection(),
_ConnectionSecuritySection(), _ConnectionSecuritySection(),
_DataManagementSection(), _DataManagementSection(),
_AdvancedSection(), _AdvancedSection(),
@@ -104,7 +104,9 @@ class _TrackingProtectionExceptionsTile extends StatelessWidget {
title: const Text('Tracking Protection Exceptions'), title: const Text('Tracking Protection Exceptions'),
subtitle: const Text('Sites where tracking protection is disabled'), subtitle: const Text('Sites where tracking protection is disabled'),
trailing: const Icon(Icons.chevron_right), 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 { class _DataManagementSection extends StatelessWidget {
const _DataManagementSection(); const _DataManagementSection();
@@ -91,6 +91,16 @@ class GeneralSettings with FastEquatable {
final bool drawerGestureEnabled; final bool drawerGestureEnabled;
final String syncServerOverride; final String syncServerOverride;
final String syncTokenServerOverride; 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({ GeneralSettings({
required this.themeMode, required this.themeMode,
@@ -124,6 +134,16 @@ class GeneralSettings with FastEquatable {
required this.drawerGestureEnabled, required this.drawerGestureEnabled,
required this.syncServerOverride, required this.syncServerOverride,
required this.syncTokenServerOverride, 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({ GeneralSettings.withDefaults({
@@ -158,6 +178,16 @@ class GeneralSettings with FastEquatable {
bool? drawerGestureEnabled, bool? drawerGestureEnabled,
String? syncServerOverride, String? syncServerOverride,
String? syncTokenServerOverride, 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, }) : themeMode = themeMode ?? ThemeMode.dark,
enableReadability = enableReadability ?? true, enableReadability = enableReadability ?? true,
enforceReadability = enforceReadability ?? false, enforceReadability = enforceReadability ?? false,
@@ -192,7 +222,21 @@ class GeneralSettings with FastEquatable {
quickTabSwitcherShowTitles = quickTabSwitcherShowTitles ?? true, quickTabSwitcherShowTitles = quickTabSwitcherShowTitles ?? true,
drawerGestureEnabled = drawerGestureEnabled ?? false, drawerGestureEnabled = drawerGestureEnabled ?? false,
syncServerOverride = syncServerOverride ?? '', 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) => factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
_$GeneralSettingsFromJson(json); _$GeneralSettingsFromJson(json);
@@ -232,5 +276,15 @@ class GeneralSettings with FastEquatable {
drawerGestureEnabled, drawerGestureEnabled,
syncServerOverride, syncServerOverride,
syncTokenServerOverride, syncTokenServerOverride,
urlCleanerEnabled,
urlCleanerAutoApply,
urlCleanerAllowReferralMarketing,
urlCleanerCatalogUrl,
urlCleanerHashUrl,
urlCleanerAutoUpdate,
urlCleanerLastCheckEpochMs,
urlCleanerLastUpdateWasAuto,
unshortenerEnabled,
unshortenerToken,
]; ];
} }
@@ -81,6 +81,28 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride); 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. /// 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)`. /// 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, bool drawerGestureEnabled,
String syncServerOverride, String syncServerOverride,
String syncTokenServerOverride, 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) => GeneralSettings syncTokenServerOverride(String syncTokenServerOverride) =>
call(syncTokenServerOverride: 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 @override
/// Creates a new instance with the provided field values. /// 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)`. /// 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? drawerGestureEnabled = const $CopyWithPlaceholder(),
Object? syncServerOverride = const $CopyWithPlaceholder(), Object? syncServerOverride = const $CopyWithPlaceholder(),
Object? syncTokenServerOverride = 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( return GeneralSettings(
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
@@ -483,6 +567,65 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.syncTokenServerOverride ? _value.syncTokenServerOverride
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: syncTokenServerOverride as String, : 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?, drawerGestureEnabled: json['drawerGestureEnabled'] as bool?,
syncServerOverride: json['syncServerOverride'] as String?, syncServerOverride: json['syncServerOverride'] as String?,
syncTokenServerOverride: json['syncTokenServerOverride'] 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( Map<String, dynamic> _$GeneralSettingsToJson(
@@ -609,6 +764,16 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'drawerGestureEnabled': instance.drawerGestureEnabled, 'drawerGestureEnabled': instance.drawerGestureEnabled,
'syncServerOverride': instance.syncServerOverride, 'syncServerOverride': instance.syncServerOverride,
'syncTokenServerOverride': instance.syncTokenServerOverride, '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 = { const _$ThemeModeEnumMap = {
@@ -163,6 +163,43 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.string, DriftSqlType.string,
db.typeMapping, 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() => String _$generalSettingsRepositoryHash() =>
r'ed5bca200b840e0802980b85a0c0f682d7b24a8c'; r'83aed230cdc33b3e15b91fdb433427faaa8c7cc7';
abstract class _$GeneralSettingsRepository abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> { extends $StreamNotifier<GeneralSettings> {
+1
View File
@@ -15,6 +15,7 @@ dependencies:
copy_with_extension: ^11.0.0 copy_with_extension: ^11.0.0
country_codes: ^3.3.0 country_codes: ^3.3.0
country_flags: ^4.1.2 country_flags: ^4.1.2
crypto: ^3.0.6
cryptography_flutter: ^2.3.4 cryptography_flutter: ^2.3.4
device_info_plus: ^12.3.0 device_info_plus: ^12.3.0
drift: ^2.31.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
+112 -62
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) { if (empty) {
return <Object?>[]; return <Object?>[];
} }
@@ -24,37 +28,48 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
} }
return <Object?>[error.code, error.message, error.details]; return <Object?>[error.code, error.message, error.details];
} }
bool _deepEquals(Object? a, Object? b) { bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) { if (a is List && b is List) {
return a.length == b.length && return a.length == b.length &&
a.indexed a.indexed.every(
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); ((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
} }
if (a is Map && b is Map) { if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) => return a.length == b.length &&
(b as Map<Object?, Object?>).containsKey(entry.key) && a.entries.every(
_deepEquals(entry.value, b[entry.key])); (MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]),
);
} }
return a == b; return a == b;
} }
/// Transport types for Tor connections /// Transport types for Tor connections
enum TransportType { enum TransportType {
/// Direct Tor connection (no bridges) /// Direct Tor connection (no bridges)
none, none,
/// obfs4 pluggable transport /// obfs4 pluggable transport
obfs4, obfs4,
/// Snowflake pluggable transport (default broker) /// Snowflake pluggable transport (default broker)
snowflake, snowflake,
/// Snowflake via AMP cache /// Snowflake via AMP cache
snowflakeAmp, snowflakeAmp,
/// Meek pluggable transport /// Meek pluggable transport
meek, meek,
/// Meek via Azure CDN /// Meek via Azure CDN
meekAzure, meekAzure,
/// WebTunnel pluggable transport /// WebTunnel pluggable transport
webtunnel, webtunnel,
/// Custom bridge lines (passthrough) /// Custom bridge lines (passthrough)
custom, custom,
} }
@@ -95,7 +110,8 @@ class TorConfiguration {
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static TorConfiguration decode(Object result) { static TorConfiguration decode(Object result) {
result as List<Object?>; result as List<Object?>;
@@ -122,8 +138,7 @@ class TorConfiguration {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(_toList()) int get hashCode => Object.hashAll(_toList());
;
} }
/// Current Tor status /// Current Tor status
@@ -162,7 +177,8 @@ class TorStatus {
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static TorStatus decode(Object result) { static TorStatus decode(Object result) {
result as List<Object?>; result as List<Object?>;
@@ -189,8 +205,7 @@ class TorStatus {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // 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 /// Log message from Tor
@@ -211,15 +226,12 @@ class TorLogMessage {
int timestamp; int timestamp;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[severity, message, timestamp];
severity,
message,
timestamp,
];
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static TorLogMessage decode(Object result) { static TorLogMessage decode(Object result) {
result as List<Object?>; result as List<Object?>;
@@ -244,11 +256,9 @@ class TorLogMessage {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // 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 { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@@ -256,16 +266,16 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) { if (value is int) {
buffer.putUint8(4); buffer.putUint8(4);
buffer.putInt64(value); buffer.putInt64(value);
} else if (value is TransportType) { } else if (value is TransportType) {
buffer.putUint8(129); buffer.putUint8(129);
writeValue(buffer, value.index); writeValue(buffer, value.index);
} else if (value is TorConfiguration) { } else if (value is TorConfiguration) {
buffer.putUint8(130); buffer.putUint8(130);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is TorStatus) { } else if (value is TorStatus) {
buffer.putUint8(131); buffer.putUint8(131);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is TorLogMessage) { } else if (value is TorLogMessage) {
buffer.putUint8(132); buffer.putUint8(132);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else { } else {
@@ -276,14 +286,14 @@ class _PigeonCodec extends StandardMessageCodec {
@override @override
Object? readValueOfType(int type, ReadBuffer buffer) { Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) { switch (type) {
case 129: case 129:
final value = readValue(buffer) as int?; final value = readValue(buffer) as int?;
return value == null ? null : TransportType.values[value]; return value == null ? null : TransportType.values[value];
case 130: case 130:
return TorConfiguration.decode(readValue(buffer)!); return TorConfiguration.decode(readValue(buffer)!);
case 131: case 131:
return TorStatus.decode(readValue(buffer)!); return TorStatus.decode(readValue(buffer)!);
case 132: case 132:
return TorLogMessage.decode(readValue(buffer)!); return TorLogMessage.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
@@ -297,8 +307,10 @@ class TorApi {
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger, : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger; final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -308,13 +320,16 @@ class TorApi {
/// Start Tor with the given configuration /// Start Tor with the given configuration
/// Returns a Future to avoid blocking the main thread /// Returns a Future to avoid blocking the main thread
Future<int> startTor(TorConfiguration config) async { 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?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, 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?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -336,7 +351,8 @@ class TorApi {
/// Stop Tor /// Stop Tor
Future<void> stopTor() async { 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?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -359,7 +375,8 @@ class TorApi {
/// Get current status /// Get current status
Future<TorStatus> getStatus() async { 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?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -387,7 +404,8 @@ class TorApi {
/// Request a new Tor identity (new circuit) /// Request a new Tor identity (new circuit)
Future<void> requestNewIdentity() async { 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?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
@@ -419,54 +437,76 @@ abstract class TorLogApi {
/// Called when status changes /// Called when status changes
void onStatusChanged(TorStatus status); void onStatusChanged(TorStatus status);
static void setUp(TorLogApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { static void setUp(
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; TorLogApi? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{ {
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec, 'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix',
binaryMessenger: binaryMessenger); pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) { if (api == null) {
pigeonVar_channel.setMessageHandler(null); pigeonVar_channel.setMessageHandler(null);
} else { } else {
pigeonVar_channel.setMessageHandler((Object? message) async { pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null, assert(
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null.'); message != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null.',
);
final List<Object?> args = (message as List<Object?>?)!; final List<Object?> args = (message as List<Object?>?)!;
final TorLogMessage? arg_log = (args[0] as TorLogMessage?); final TorLogMessage? arg_log = (args[0] as TorLogMessage?);
assert(arg_log != null, assert(
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null, expected non-null TorLogMessage.'); arg_log != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null, expected non-null TorLogMessage.',
);
try { try {
api.onLogMessage(arg_log!); api.onLogMessage(arg_log!);
return wrapResponse(empty: true); return wrapResponse(empty: true);
} on PlatformException catch (e) { } on PlatformException catch (e) {
return wrapResponse(error: e); return wrapResponse(error: e);
} catch (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?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix', pigeonChannelCodec, 'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix',
binaryMessenger: binaryMessenger); pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) { if (api == null) {
pigeonVar_channel.setMessageHandler(null); pigeonVar_channel.setMessageHandler(null);
} else { } else {
pigeonVar_channel.setMessageHandler((Object? message) async { pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null, assert(
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null.'); message != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null.',
);
final List<Object?> args = (message as List<Object?>?)!; final List<Object?> args = (message as List<Object?>?)!;
final TorStatus? arg_status = (args[0] as TorStatus?); final TorStatus? arg_status = (args[0] as TorStatus?);
assert(arg_status != null, assert(
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null, expected non-null TorStatus.'); arg_status != null,
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null, expected non-null TorStatus.',
);
try { try {
api.onStatusChanged(arg_status!); api.onStatusChanged(arg_status!);
return wrapResponse(empty: true); return wrapResponse(empty: true);
} on PlatformException catch (e) { } on PlatformException catch (e) {
return wrapResponse(error: e); return wrapResponse(error: e);
} catch (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 /// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) IPtProxyController({
: pigeonVar_binaryMessenger = binaryMessenger, BinaryMessenger? binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger; final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -488,13 +532,16 @@ class IPtProxyController {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<int> start(TransportType proxyType, String proxy) async { 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?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, 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?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -515,13 +562,16 @@ class IPtProxyController {
} }
Future<void> stop(TransportType proxyType) async { 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?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, 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?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -14,40 +14,39 @@ PlatformException _createConnectionError(String channelName) {
message: 'Unable to establish connection on channel: "$channelName".', message: 'Unable to establish connection on channel: "$channelName".',
); );
} }
bool _deepEquals(Object? a, Object? b) { bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) { if (a is List && b is List) {
return a.length == b.length && return a.length == b.length &&
a.indexed a.indexed.every(
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); ((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
} }
if (a is Map && b is Map) { if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) => return a.length == b.length &&
(b as Map<Object?, Object?>).containsKey(entry.key) && a.entries.every(
_deepEquals(entry.value, b[entry.key])); (MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]),
);
} }
return a == b; return a == b;
} }
class LocalizedResult { class LocalizedResult {
LocalizedResult({ LocalizedResult({required this.languageName, this.countryName});
required this.languageName,
this.countryName,
});
String languageName; String languageName;
String? countryName; String? countryName;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[languageName, countryName];
languageName,
countryName,
];
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static LocalizedResult decode(Object result) { static LocalizedResult decode(Object result) {
result as List<Object?>; result as List<Object?>;
@@ -71,11 +70,9 @@ class LocalizedResult {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // 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 { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@@ -83,7 +80,7 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) { if (value is int) {
buffer.putUint8(4); buffer.putUint8(4);
buffer.putInt64(value); buffer.putInt64(value);
} else if (value is LocalizedResult) { } else if (value is LocalizedResult) {
buffer.putUint8(129); buffer.putUint8(129);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else { } else {
@@ -94,7 +91,7 @@ class _PigeonCodec extends StandardMessageCodec {
@override @override
Object? readValueOfType(int type, ReadBuffer buffer) { Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) { switch (type) {
case 129: case 129:
return LocalizedResult.decode(readValue(buffer)!); return LocalizedResult.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
@@ -106,23 +103,33 @@ class LocaleResolver {
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is /// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) LocaleResolver({
: pigeonVar_binaryMessenger = binaryMessenger, BinaryMessenger? binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger; final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<LocalizedResult> resolve(String languageTag, String targetLangouageTag) async { Future<LocalizedResult> resolve(
final pigeonVar_channelName = 'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix'; String languageTag,
String targetLangouageTag,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, 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?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); 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/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart'; 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) { if (empty) {
return <Object?>[]; return <Object?>[];
} }
@@ -17,21 +21,25 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
} }
return <Object?>[error.code, error.message, error.details]; return <Object?>[error.code, error.message, error.details];
} }
bool _deepEquals(Object? a, Object? b) { bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) { if (a is List && b is List) {
return a.length == b.length && return a.length == b.length &&
a.indexed a.indexed.every(
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); ((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
} }
if (a is Map && b is Map) { if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) => return a.length == b.length &&
(b as Map<Object?, Object?>).containsKey(entry.key) && a.entries.every(
_deepEquals(entry.value, b[entry.key])); (MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]),
);
} }
return a == b; return a == b;
} }
class Intent { class Intent {
Intent({ Intent({
this.fromPackageName, this.fromPackageName,
@@ -66,7 +74,8 @@ class Intent {
} }
Object encode() { Object encode() {
return _toList(); } return _toList();
}
static Intent decode(Object result) { static Intent decode(Object result) {
result as List<Object?>; result as List<Object?>;
@@ -94,11 +103,9 @@ class Intent {
@override @override
// ignore: avoid_equals_and_hash_code_on_mutable_classes // 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 { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@@ -106,7 +113,7 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) { if (value is int) {
buffer.putUint8(4); buffer.putUint8(4);
buffer.putInt64(value); buffer.putInt64(value);
} else if (value is Intent) { } else if (value is Intent) {
buffer.putUint8(129); buffer.putUint8(129);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else { } else {
@@ -117,7 +124,7 @@ class _PigeonCodec extends StandardMessageCodec {
@override @override
Object? readValueOfType(int type, ReadBuffer buffer) { Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) { switch (type) {
case 129: case 129:
return Intent.decode(readValue(buffer)!); return Intent.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
@@ -130,32 +137,48 @@ abstract class IntentEvents {
void onIntentReceived(int sequence, Intent intent); void onIntentReceived(int sequence, Intent intent);
static void setUp(IntentEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { static void setUp(
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; IntentEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{ {
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix', pigeonChannelCodec, 'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix',
binaryMessenger: binaryMessenger); pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) { if (api == null) {
pigeonVar_channel.setMessageHandler(null); pigeonVar_channel.setMessageHandler(null);
} else { } else {
pigeonVar_channel.setMessageHandler((Object? message) async { pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null, assert(
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null.'); message != null,
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null.',
);
final List<Object?> args = (message as List<Object?>?)!; final List<Object?> args = (message as List<Object?>?)!;
final int? arg_sequence = (args[0] as int?); final int? arg_sequence = (args[0] as int?);
assert(arg_sequence != null, assert(
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null int.'); 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?); final Intent? arg_intent = (args[1] as Intent?);
assert(arg_intent != null, assert(
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null Intent.'); arg_intent != null,
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null Intent.',
);
try { try {
api.onIntentReceived(arg_sequence!, arg_intent!); api.onIntentReceived(arg_sequence!, arg_intent!);
return wrapResponse(empty: true); return wrapResponse(empty: true);
} on PlatformException catch (e) { } on PlatformException catch (e) {
return wrapResponse(error: e); return wrapResponse(error: e);
} catch (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) { if (empty) {
return <Object?>[]; return <Object?>[];
} }
@@ -25,7 +29,6 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
return <Object?>[error.code, error.message, error.details]; return <Object?>[error.code, error.message, error.details];
} }
class _PigeonCodec extends StandardMessageCodec { class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec(); const _PigeonCodec();
@override @override
@@ -52,9 +55,13 @@ class SpeechToTextApi {
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is /// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform. /// BinaryMessenger will be used which routes to the host platform.
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) SpeechToTextApi({
: pigeonVar_binaryMessenger = binaryMessenger, BinaryMessenger? binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger; final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec(); static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -69,13 +76,16 @@ class SpeechToTextApi {
/// The [locale] parameter specifies the language locale for recognition /// The [locale] parameter specifies the language locale for recognition
/// (e.g., 'en-US', 'de-DE'). If null, uses the device default. /// (e.g., 'en-US', 'de-DE'). If null, uses the device default.
Future<bool> showDialog({String? locale}) async { 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?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName, pigeonVar_channelName,
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, 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?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -106,29 +116,43 @@ abstract class SpeechToTextEvents {
/// recognition failed or was cancelled. /// recognition failed or was cancelled.
void onTextReceived(String text); void onTextReceived(String text);
static void setUp(SpeechToTextEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { static void setUp(
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; SpeechToTextEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{ {
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix', pigeonChannelCodec, 'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix',
binaryMessenger: binaryMessenger); pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) { if (api == null) {
pigeonVar_channel.setMessageHandler(null); pigeonVar_channel.setMessageHandler(null);
} else { } else {
pigeonVar_channel.setMessageHandler((Object? message) async { pigeonVar_channel.setMessageHandler((Object? message) async {
assert(message != null, assert(
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null.'); message != null,
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null.',
);
final List<Object?> args = (message as List<Object?>?)!; final List<Object?> args = (message as List<Object?>?)!;
final String? arg_text = (args[0] as String?); final String? arg_text = (args[0] as String?);
assert(arg_text != null, assert(
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null, expected non-null String.'); arg_text != null,
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null, expected non-null String.',
);
try { try {
api.onTextReceived(arg_text!); api.onTextReceived(arg_text!);
return wrapResponse(empty: true); return wrapResponse(empty: true);
} on PlatformException catch (e) { } on PlatformException catch (e) {
return wrapResponse(error: e); return wrapResponse(error: e);
} catch (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