url tools initial
This commit is contained in:
@@ -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
@@ -31,6 +31,10 @@ class Format extends _$Format {
|
||||
return pattern.format(date);
|
||||
}
|
||||
|
||||
String shortDate(DateTime date) {
|
||||
return DateFormat('yyyy-MM-dd').format(date);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> build() async {
|
||||
await initializeDateFormatting();
|
||||
|
||||
@@ -32,7 +32,7 @@ final class FormatProvider extends $AsyncNotifierProvider<Format, void> {
|
||||
Format create() => Format();
|
||||
}
|
||||
|
||||
String _$formatHash() => r'f132aabb20bf4df77d771d6c866bc413c00bd9ff';
|
||||
String _$formatHash() => r'695b94cc4b25596e4ef953d08ba954424a5ec33e';
|
||||
|
||||
abstract class _$Format extends $AsyncNotifier<void> {
|
||||
FutureOr<void> build();
|
||||
|
||||
@@ -39,13 +39,15 @@ import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/b
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/open_shared_content.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/screens/browser.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/screens/tab_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/history/presentation/screens/history.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/screens/unshortener_settings.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/screens/url_cleaner_settings.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/screens/search.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart';
|
||||
|
||||
@@ -1460,6 +1460,16 @@ RouteBase get $settingsRoute => GoRouteData.$route(
|
||||
name: 'SyncSettingsRoute',
|
||||
factory: $SyncSettingsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'url_cleaner',
|
||||
name: 'UrlCleanerSettingsRoute',
|
||||
factory: $UrlCleanerSettingsRoute._fromState,
|
||||
),
|
||||
GoRouteData.$route(
|
||||
path: 'unshortener',
|
||||
name: 'UnshortenerSettingsRoute',
|
||||
factory: $UnshortenerSettingsRoute._fromState,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1845,6 +1855,48 @@ mixin $SyncSettingsRoute on GoRouteData {
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $UrlCleanerSettingsRoute on GoRouteData {
|
||||
static UrlCleanerSettingsRoute _fromState(GoRouterState state) =>
|
||||
UrlCleanerSettingsRoute();
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location('/settings/url_cleaner');
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||
|
||||
@override
|
||||
void pushReplacement(BuildContext context) =>
|
||||
context.pushReplacement(location);
|
||||
|
||||
@override
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
mixin $UnshortenerSettingsRoute on GoRouteData {
|
||||
static UnshortenerSettingsRoute _fromState(GoRouterState state) =>
|
||||
UnshortenerSettingsRoute();
|
||||
|
||||
@override
|
||||
String get location => GoRouteData.$location('/settings/unshortener');
|
||||
|
||||
@override
|
||||
void go(BuildContext context) => context.go(location);
|
||||
|
||||
@override
|
||||
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||
|
||||
@override
|
||||
void pushReplacement(BuildContext context) =>
|
||||
context.pushReplacement(location);
|
||||
|
||||
@override
|
||||
void replace(BuildContext context) => context.replace(location);
|
||||
}
|
||||
|
||||
RouteBase get $torProxyRoute => GoRouteData.$route(
|
||||
path: '/tor',
|
||||
name: 'TorProxyRoute',
|
||||
|
||||
@@ -81,6 +81,14 @@ part of 'routes.dart';
|
||||
),
|
||||
TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'),
|
||||
TypedGoRoute<SyncSettingsRoute>(name: 'SyncSettingsRoute', path: 'sync'),
|
||||
TypedGoRoute<UrlCleanerSettingsRoute>(
|
||||
name: 'UrlCleanerSettingsRoute',
|
||||
path: 'url_cleaner',
|
||||
),
|
||||
TypedGoRoute<UnshortenerSettingsRoute>(
|
||||
name: 'UnshortenerSettingsRoute',
|
||||
path: 'unshortener',
|
||||
),
|
||||
],
|
||||
)
|
||||
class SettingsRoute extends GoRouteData with $SettingsRoute {
|
||||
@@ -222,3 +230,19 @@ class SyncSettingsRoute extends GoRouteData with $SyncSettingsRoute {
|
||||
return const SyncSettingsScreen();
|
||||
}
|
||||
}
|
||||
|
||||
class UrlCleanerSettingsRoute extends GoRouteData
|
||||
with $UrlCleanerSettingsRoute {
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
return const UrlCleanerSettingsScreen();
|
||||
}
|
||||
}
|
||||
|
||||
class UnshortenerSettingsRoute extends GoRouteData
|
||||
with $UnshortenerSettingsRoute {
|
||||
@override
|
||||
Widget build(BuildContext context, GoRouterState state) {
|
||||
return const UnshortenerSettingsScreen();
|
||||
}
|
||||
}
|
||||
|
||||
-171
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+128
@@ -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,
|
||||
};
|
||||
}
|
||||
+29
@@ -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,
|
||||
};
|
||||
+45
@@ -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,
|
||||
];
|
||||
}
|
||||
+67
@@ -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,
|
||||
];
|
||||
}
|
||||
+66
@@ -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() {}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
+62
@@ -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);
|
||||
}
|
||||
}
|
||||
+138
@@ -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,
|
||||
];
|
||||
}
|
||||
+50
@@ -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,
|
||||
};
|
||||
+283
@@ -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});
|
||||
}
|
||||
+184
@@ -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;
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
+588
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+238
@@ -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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+42
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
+218
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+338
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+36
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart';
|
||||
|
||||
class AttributionLink extends StatelessWidget {
|
||||
final String label;
|
||||
final String url;
|
||||
final TextStyle style;
|
||||
|
||||
const AttributionLink({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.url,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
unawaited(openInPrivateCustomTab(context, url));
|
||||
},
|
||||
child: Text(label, style: style),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
@@ -50,6 +49,7 @@ class PrivacySecuritySettingsScreen extends StatelessWidget {
|
||||
children: const [
|
||||
_PrivacyModesSection(),
|
||||
_TrackingProtectionSection(),
|
||||
_OpenLinkModulesSection(),
|
||||
_ConnectionSecuritySection(),
|
||||
_DataManagementSection(),
|
||||
_AdvancedSection(),
|
||||
@@ -104,7 +104,9 @@ class _TrackingProtectionExceptionsTile extends StatelessWidget {
|
||||
title: const Text('Tracking Protection Exceptions'),
|
||||
subtitle: const Text('Sites where tracking protection is disabled'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/settings/tracking_protection_exceptions'),
|
||||
onTap: () async {
|
||||
await TrackingProtectionExceptionsRoute().push(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +126,55 @@ class _ConnectionSecuritySection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenLinkModulesSection extends StatelessWidget {
|
||||
const _OpenLinkModulesSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
children: [
|
||||
SettingSection(name: 'Open Link Modules'),
|
||||
_UrlCleanerSettingsTile(),
|
||||
_UnshortenerSettingsTile(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UrlCleanerSettingsTile extends StatelessWidget {
|
||||
const _UrlCleanerSettingsTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(MdiIcons.broom),
|
||||
title: const Text('URL Cleaner'),
|
||||
subtitle: const Text('Tracking removal rules and catalog updates'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
await UrlCleanerSettingsRoute().push(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnshortenerSettingsTile extends StatelessWidget {
|
||||
const _UnshortenerSettingsTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(MdiIcons.linkVariant),
|
||||
title: const Text('Unshortener'),
|
||||
subtitle: const Text('Short link resolver and API token'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
await UnshortenerSettingsRoute().push(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DataManagementSection extends StatelessWidget {
|
||||
const _DataManagementSection();
|
||||
|
||||
|
||||
@@ -91,6 +91,16 @@ class GeneralSettings with FastEquatable {
|
||||
final bool drawerGestureEnabled;
|
||||
final String syncServerOverride;
|
||||
final String syncTokenServerOverride;
|
||||
final bool urlCleanerEnabled;
|
||||
final bool urlCleanerAutoApply;
|
||||
final bool urlCleanerAllowReferralMarketing;
|
||||
final String urlCleanerCatalogUrl;
|
||||
final String urlCleanerHashUrl;
|
||||
final bool urlCleanerAutoUpdate;
|
||||
final int? urlCleanerLastCheckEpochMs;
|
||||
final bool urlCleanerLastUpdateWasAuto;
|
||||
final bool unshortenerEnabled;
|
||||
final String unshortenerToken;
|
||||
|
||||
GeneralSettings({
|
||||
required this.themeMode,
|
||||
@@ -124,6 +134,16 @@ class GeneralSettings with FastEquatable {
|
||||
required this.drawerGestureEnabled,
|
||||
required this.syncServerOverride,
|
||||
required this.syncTokenServerOverride,
|
||||
required this.urlCleanerEnabled,
|
||||
required this.urlCleanerAutoApply,
|
||||
required this.urlCleanerAllowReferralMarketing,
|
||||
required this.urlCleanerCatalogUrl,
|
||||
required this.urlCleanerHashUrl,
|
||||
required this.urlCleanerAutoUpdate,
|
||||
required this.urlCleanerLastCheckEpochMs,
|
||||
required this.urlCleanerLastUpdateWasAuto,
|
||||
required this.unshortenerEnabled,
|
||||
required this.unshortenerToken,
|
||||
});
|
||||
|
||||
GeneralSettings.withDefaults({
|
||||
@@ -158,6 +178,16 @@ class GeneralSettings with FastEquatable {
|
||||
bool? drawerGestureEnabled,
|
||||
String? syncServerOverride,
|
||||
String? syncTokenServerOverride,
|
||||
bool? urlCleanerEnabled,
|
||||
bool? urlCleanerAutoApply,
|
||||
bool? urlCleanerAllowReferralMarketing,
|
||||
String? urlCleanerCatalogUrl,
|
||||
String? urlCleanerHashUrl,
|
||||
bool? urlCleanerAutoUpdate,
|
||||
this.urlCleanerLastCheckEpochMs,
|
||||
bool? urlCleanerLastUpdateWasAuto,
|
||||
bool? unshortenerEnabled,
|
||||
String? unshortenerToken,
|
||||
}) : themeMode = themeMode ?? ThemeMode.dark,
|
||||
enableReadability = enableReadability ?? true,
|
||||
enforceReadability = enforceReadability ?? false,
|
||||
@@ -192,7 +222,21 @@ class GeneralSettings with FastEquatable {
|
||||
quickTabSwitcherShowTitles = quickTabSwitcherShowTitles ?? true,
|
||||
drawerGestureEnabled = drawerGestureEnabled ?? false,
|
||||
syncServerOverride = syncServerOverride ?? '',
|
||||
syncTokenServerOverride = syncTokenServerOverride ?? '';
|
||||
syncTokenServerOverride = syncTokenServerOverride ?? '',
|
||||
urlCleanerEnabled = urlCleanerEnabled ?? true,
|
||||
urlCleanerAutoApply = urlCleanerAutoApply ?? false,
|
||||
urlCleanerAllowReferralMarketing =
|
||||
urlCleanerAllowReferralMarketing ?? false,
|
||||
urlCleanerCatalogUrl =
|
||||
urlCleanerCatalogUrl ??
|
||||
'https://rules2.clearurls.xyz/data.minify.json',
|
||||
urlCleanerHashUrl =
|
||||
urlCleanerHashUrl ??
|
||||
'https://rules2.clearurls.xyz/rules.minify.hash',
|
||||
urlCleanerAutoUpdate = urlCleanerAutoUpdate ?? false,
|
||||
urlCleanerLastUpdateWasAuto = urlCleanerLastUpdateWasAuto ?? false,
|
||||
unshortenerEnabled = unshortenerEnabled ?? false,
|
||||
unshortenerToken = unshortenerToken ?? '';
|
||||
|
||||
factory GeneralSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$GeneralSettingsFromJson(json);
|
||||
@@ -232,5 +276,15 @@ class GeneralSettings with FastEquatable {
|
||||
drawerGestureEnabled,
|
||||
syncServerOverride,
|
||||
syncTokenServerOverride,
|
||||
urlCleanerEnabled,
|
||||
urlCleanerAutoApply,
|
||||
urlCleanerAllowReferralMarketing,
|
||||
urlCleanerCatalogUrl,
|
||||
urlCleanerHashUrl,
|
||||
urlCleanerAutoUpdate,
|
||||
urlCleanerLastCheckEpochMs,
|
||||
urlCleanerLastUpdateWasAuto,
|
||||
unshortenerEnabled,
|
||||
unshortenerToken,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,6 +81,28 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
|
||||
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride);
|
||||
|
||||
GeneralSettings urlCleanerEnabled(bool urlCleanerEnabled);
|
||||
|
||||
GeneralSettings urlCleanerAutoApply(bool urlCleanerAutoApply);
|
||||
|
||||
GeneralSettings urlCleanerAllowReferralMarketing(
|
||||
bool urlCleanerAllowReferralMarketing,
|
||||
);
|
||||
|
||||
GeneralSettings urlCleanerCatalogUrl(String urlCleanerCatalogUrl);
|
||||
|
||||
GeneralSettings urlCleanerHashUrl(String urlCleanerHashUrl);
|
||||
|
||||
GeneralSettings urlCleanerAutoUpdate(bool urlCleanerAutoUpdate);
|
||||
|
||||
GeneralSettings urlCleanerLastCheckEpochMs(int? urlCleanerLastCheckEpochMs);
|
||||
|
||||
GeneralSettings urlCleanerLastUpdateWasAuto(bool urlCleanerLastUpdateWasAuto);
|
||||
|
||||
GeneralSettings unshortenerEnabled(bool unshortenerEnabled);
|
||||
|
||||
GeneralSettings unshortenerToken(String unshortenerToken);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
@@ -120,6 +142,16 @@ abstract class _$GeneralSettingsCWProxy {
|
||||
bool drawerGestureEnabled,
|
||||
String syncServerOverride,
|
||||
String syncTokenServerOverride,
|
||||
bool urlCleanerEnabled,
|
||||
bool urlCleanerAutoApply,
|
||||
bool urlCleanerAllowReferralMarketing,
|
||||
String urlCleanerCatalogUrl,
|
||||
String urlCleanerHashUrl,
|
||||
bool urlCleanerAutoUpdate,
|
||||
int? urlCleanerLastCheckEpochMs,
|
||||
bool urlCleanerLastUpdateWasAuto,
|
||||
bool unshortenerEnabled,
|
||||
String unshortenerToken,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,6 +291,48 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride) =>
|
||||
call(syncTokenServerOverride: syncTokenServerOverride);
|
||||
|
||||
@override
|
||||
GeneralSettings urlCleanerEnabled(bool urlCleanerEnabled) =>
|
||||
call(urlCleanerEnabled: urlCleanerEnabled);
|
||||
|
||||
@override
|
||||
GeneralSettings urlCleanerAutoApply(bool urlCleanerAutoApply) =>
|
||||
call(urlCleanerAutoApply: urlCleanerAutoApply);
|
||||
|
||||
@override
|
||||
GeneralSettings urlCleanerAllowReferralMarketing(
|
||||
bool urlCleanerAllowReferralMarketing,
|
||||
) => call(urlCleanerAllowReferralMarketing: urlCleanerAllowReferralMarketing);
|
||||
|
||||
@override
|
||||
GeneralSettings urlCleanerCatalogUrl(String urlCleanerCatalogUrl) =>
|
||||
call(urlCleanerCatalogUrl: urlCleanerCatalogUrl);
|
||||
|
||||
@override
|
||||
GeneralSettings urlCleanerHashUrl(String urlCleanerHashUrl) =>
|
||||
call(urlCleanerHashUrl: urlCleanerHashUrl);
|
||||
|
||||
@override
|
||||
GeneralSettings urlCleanerAutoUpdate(bool urlCleanerAutoUpdate) =>
|
||||
call(urlCleanerAutoUpdate: urlCleanerAutoUpdate);
|
||||
|
||||
@override
|
||||
GeneralSettings urlCleanerLastCheckEpochMs(int? urlCleanerLastCheckEpochMs) =>
|
||||
call(urlCleanerLastCheckEpochMs: urlCleanerLastCheckEpochMs);
|
||||
|
||||
@override
|
||||
GeneralSettings urlCleanerLastUpdateWasAuto(
|
||||
bool urlCleanerLastUpdateWasAuto,
|
||||
) => call(urlCleanerLastUpdateWasAuto: urlCleanerLastUpdateWasAuto);
|
||||
|
||||
@override
|
||||
GeneralSettings unshortenerEnabled(bool unshortenerEnabled) =>
|
||||
call(unshortenerEnabled: unshortenerEnabled);
|
||||
|
||||
@override
|
||||
GeneralSettings unshortenerToken(String unshortenerToken) =>
|
||||
call(unshortenerToken: unshortenerToken);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`.
|
||||
@@ -299,6 +373,16 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
Object? drawerGestureEnabled = const $CopyWithPlaceholder(),
|
||||
Object? syncServerOverride = const $CopyWithPlaceholder(),
|
||||
Object? syncTokenServerOverride = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerEnabled = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerAutoApply = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerAllowReferralMarketing = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerCatalogUrl = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerHashUrl = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerAutoUpdate = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerLastCheckEpochMs = const $CopyWithPlaceholder(),
|
||||
Object? urlCleanerLastUpdateWasAuto = const $CopyWithPlaceholder(),
|
||||
Object? unshortenerEnabled = const $CopyWithPlaceholder(),
|
||||
Object? unshortenerToken = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return GeneralSettings(
|
||||
themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null
|
||||
@@ -483,6 +567,65 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
||||
? _value.syncTokenServerOverride
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: syncTokenServerOverride as String,
|
||||
urlCleanerEnabled:
|
||||
urlCleanerEnabled == const $CopyWithPlaceholder() ||
|
||||
urlCleanerEnabled == null
|
||||
? _value.urlCleanerEnabled
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: urlCleanerEnabled as bool,
|
||||
urlCleanerAutoApply:
|
||||
urlCleanerAutoApply == const $CopyWithPlaceholder() ||
|
||||
urlCleanerAutoApply == null
|
||||
? _value.urlCleanerAutoApply
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: urlCleanerAutoApply as bool,
|
||||
urlCleanerAllowReferralMarketing:
|
||||
urlCleanerAllowReferralMarketing == const $CopyWithPlaceholder() ||
|
||||
urlCleanerAllowReferralMarketing == null
|
||||
? _value.urlCleanerAllowReferralMarketing
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: urlCleanerAllowReferralMarketing as bool,
|
||||
urlCleanerCatalogUrl:
|
||||
urlCleanerCatalogUrl == const $CopyWithPlaceholder() ||
|
||||
urlCleanerCatalogUrl == null
|
||||
? _value.urlCleanerCatalogUrl
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: urlCleanerCatalogUrl as String,
|
||||
urlCleanerHashUrl:
|
||||
urlCleanerHashUrl == const $CopyWithPlaceholder() ||
|
||||
urlCleanerHashUrl == null
|
||||
? _value.urlCleanerHashUrl
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: urlCleanerHashUrl as String,
|
||||
urlCleanerAutoUpdate:
|
||||
urlCleanerAutoUpdate == const $CopyWithPlaceholder() ||
|
||||
urlCleanerAutoUpdate == null
|
||||
? _value.urlCleanerAutoUpdate
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: urlCleanerAutoUpdate as bool,
|
||||
urlCleanerLastCheckEpochMs:
|
||||
urlCleanerLastCheckEpochMs == const $CopyWithPlaceholder()
|
||||
? _value.urlCleanerLastCheckEpochMs
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: urlCleanerLastCheckEpochMs as int?,
|
||||
urlCleanerLastUpdateWasAuto:
|
||||
urlCleanerLastUpdateWasAuto == const $CopyWithPlaceholder() ||
|
||||
urlCleanerLastUpdateWasAuto == null
|
||||
? _value.urlCleanerLastUpdateWasAuto
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: urlCleanerLastUpdateWasAuto as bool,
|
||||
unshortenerEnabled:
|
||||
unshortenerEnabled == const $CopyWithPlaceholder() ||
|
||||
unshortenerEnabled == null
|
||||
? _value.unshortenerEnabled
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: unshortenerEnabled as bool,
|
||||
unshortenerToken:
|
||||
unshortenerToken == const $CopyWithPlaceholder() ||
|
||||
unshortenerToken == null
|
||||
? _value.unshortenerToken
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: unshortenerToken as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -564,6 +707,18 @@ GeneralSettings _$GeneralSettingsFromJson(
|
||||
drawerGestureEnabled: json['drawerGestureEnabled'] as bool?,
|
||||
syncServerOverride: json['syncServerOverride'] as String?,
|
||||
syncTokenServerOverride: json['syncTokenServerOverride'] as String?,
|
||||
urlCleanerEnabled: json['urlCleanerEnabled'] as bool?,
|
||||
urlCleanerAutoApply: json['urlCleanerAutoApply'] as bool?,
|
||||
urlCleanerAllowReferralMarketing:
|
||||
json['urlCleanerAllowReferralMarketing'] as bool?,
|
||||
urlCleanerCatalogUrl: json['urlCleanerCatalogUrl'] as String?,
|
||||
urlCleanerHashUrl: json['urlCleanerHashUrl'] as String?,
|
||||
urlCleanerAutoUpdate: json['urlCleanerAutoUpdate'] as bool?,
|
||||
urlCleanerLastCheckEpochMs: (json['urlCleanerLastCheckEpochMs'] as num?)
|
||||
?.toInt(),
|
||||
urlCleanerLastUpdateWasAuto: json['urlCleanerLastUpdateWasAuto'] as bool?,
|
||||
unshortenerEnabled: json['unshortenerEnabled'] as bool?,
|
||||
unshortenerToken: json['unshortenerToken'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GeneralSettingsToJson(
|
||||
@@ -609,6 +764,16 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
||||
'drawerGestureEnabled': instance.drawerGestureEnabled,
|
||||
'syncServerOverride': instance.syncServerOverride,
|
||||
'syncTokenServerOverride': instance.syncTokenServerOverride,
|
||||
'urlCleanerEnabled': instance.urlCleanerEnabled,
|
||||
'urlCleanerAutoApply': instance.urlCleanerAutoApply,
|
||||
'urlCleanerAllowReferralMarketing': instance.urlCleanerAllowReferralMarketing,
|
||||
'urlCleanerCatalogUrl': instance.urlCleanerCatalogUrl,
|
||||
'urlCleanerHashUrl': instance.urlCleanerHashUrl,
|
||||
'urlCleanerAutoUpdate': instance.urlCleanerAutoUpdate,
|
||||
'urlCleanerLastCheckEpochMs': instance.urlCleanerLastCheckEpochMs,
|
||||
'urlCleanerLastUpdateWasAuto': instance.urlCleanerLastUpdateWasAuto,
|
||||
'unshortenerEnabled': instance.unshortenerEnabled,
|
||||
'unshortenerToken': instance.unshortenerToken,
|
||||
};
|
||||
|
||||
const _$ThemeModeEnumMap = {
|
||||
|
||||
@@ -163,6 +163,43 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerEnabled': settings['urlCleanerEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerAutoApply': settings['urlCleanerAutoApply']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerAllowReferralMarketing':
|
||||
settings['urlCleanerAllowReferralMarketing']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerCatalogUrl': settings['urlCleanerCatalogUrl']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerHashUrl': settings['urlCleanerHashUrl']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerAutoUpdate': settings['urlCleanerAutoUpdate']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'urlCleanerLastCheckEpochMs': settings['urlCleanerLastCheckEpochMs']
|
||||
?.readAs(DriftSqlType.int, db.typeMapping),
|
||||
'urlCleanerLastUpdateWasAuto': settings['urlCleanerLastUpdateWasAuto']
|
||||
?.readAs(DriftSqlType.bool, db.typeMapping),
|
||||
'unshortenerEnabled': settings['unshortenerEnabled']?.readAs(
|
||||
DriftSqlType.bool,
|
||||
db.typeMapping,
|
||||
),
|
||||
'unshortenerToken': settings['unshortenerToken']?.readAs(
|
||||
DriftSqlType.string,
|
||||
db.typeMapping,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
|
||||
}
|
||||
|
||||
String _$generalSettingsRepositoryHash() =>
|
||||
r'ed5bca200b840e0802980b85a0c0f682d7b24a8c';
|
||||
r'83aed230cdc33b3e15b91fdb433427faaa8c7cc7';
|
||||
|
||||
abstract class _$GeneralSettingsRepository
|
||||
extends $StreamNotifier<GeneralSettings> {
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies:
|
||||
copy_with_extension: ^11.0.0
|
||||
country_codes: ^3.3.0
|
||||
country_flags: ^4.1.2
|
||||
crypto: ^3.0.6
|
||||
cryptography_flutter: ^2.3.4
|
||||
device_info_plus: ^12.3.0
|
||||
drift: ^2.31.0
|
||||
|
||||
+1171
File diff suppressed because it is too large
Load Diff
+355
@@ -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
@@ -15,7 +15,11 @@ PlatformException _createConnectionError(String channelName) {
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||
List<Object?> wrapResponse({
|
||||
Object? result,
|
||||
PlatformException? error,
|
||||
bool empty = false,
|
||||
}) {
|
||||
if (empty) {
|
||||
return <Object?>[];
|
||||
}
|
||||
@@ -24,37 +28,48 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
|
||||
}
|
||||
return <Object?>[error.code, error.message, error.details];
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
a.indexed.every(
|
||||
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
|
||||
);
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
|
||||
return a.length == b.length &&
|
||||
a.entries.every(
|
||||
(MapEntry<Object?, Object?> entry) =>
|
||||
(b as Map<Object?, Object?>).containsKey(entry.key) &&
|
||||
_deepEquals(entry.value, b[entry.key]));
|
||||
_deepEquals(entry.value, b[entry.key]),
|
||||
);
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
|
||||
/// Transport types for Tor connections
|
||||
enum TransportType {
|
||||
/// Direct Tor connection (no bridges)
|
||||
none,
|
||||
|
||||
/// obfs4 pluggable transport
|
||||
obfs4,
|
||||
|
||||
/// Snowflake pluggable transport (default broker)
|
||||
snowflake,
|
||||
|
||||
/// Snowflake via AMP cache
|
||||
snowflakeAmp,
|
||||
|
||||
/// Meek pluggable transport
|
||||
meek,
|
||||
|
||||
/// Meek via Azure CDN
|
||||
meekAzure,
|
||||
|
||||
/// WebTunnel pluggable transport
|
||||
webtunnel,
|
||||
|
||||
/// Custom bridge lines (passthrough)
|
||||
custom,
|
||||
}
|
||||
@@ -95,7 +110,8 @@ class TorConfiguration {
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static TorConfiguration decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -122,8 +138,7 @@ class TorConfiguration {
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
int get hashCode => Object.hashAll(_toList());
|
||||
}
|
||||
|
||||
/// Current Tor status
|
||||
@@ -162,7 +177,8 @@ class TorStatus {
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static TorStatus decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -189,8 +205,7 @@ class TorStatus {
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
int get hashCode => Object.hashAll(_toList());
|
||||
}
|
||||
|
||||
/// Log message from Tor
|
||||
@@ -211,15 +226,12 @@ class TorLogMessage {
|
||||
int timestamp;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
severity,
|
||||
message,
|
||||
timestamp,
|
||||
];
|
||||
return <Object?>[severity, message, timestamp];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static TorLogMessage decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -244,11 +256,9 @@ class TorLogMessage {
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
int get hashCode => Object.hashAll(_toList());
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -298,7 +308,9 @@ class TorApi {
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
@@ -308,13 +320,16 @@ class TorApi {
|
||||
/// Start Tor with the given configuration
|
||||
/// Returns a Future to avoid blocking the main thread
|
||||
Future<int> startTor(TorConfiguration config) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[config]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[config],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
@@ -336,7 +351,8 @@ class TorApi {
|
||||
|
||||
/// Stop Tor
|
||||
Future<void> stopTor() async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -359,7 +375,8 @@ class TorApi {
|
||||
|
||||
/// Get current status
|
||||
Future<TorStatus> getStatus() async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -387,7 +404,8 @@ class TorApi {
|
||||
|
||||
/// Request a new Tor identity (new circuit)
|
||||
Future<void> requestNewIdentity() async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -419,54 +437,76 @@ abstract class TorLogApi {
|
||||
/// Called when status changes
|
||||
void onStatusChanged(TorStatus status);
|
||||
|
||||
static void setUp(TorLogApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
static void setUp(
|
||||
TorLogApi? api, {
|
||||
BinaryMessenger? binaryMessenger,
|
||||
String messageChannelSuffix = '',
|
||||
}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger);
|
||||
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix',
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger,
|
||||
);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
assert(message != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null.');
|
||||
assert(
|
||||
message != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null.',
|
||||
);
|
||||
final List<Object?> args = (message as List<Object?>?)!;
|
||||
final TorLogMessage? arg_log = (args[0] as TorLogMessage?);
|
||||
assert(arg_log != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null, expected non-null TorLogMessage.');
|
||||
assert(
|
||||
arg_log != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage was null, expected non-null TorLogMessage.',
|
||||
);
|
||||
try {
|
||||
api.onLogMessage(arg_log!);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||
return wrapResponse(
|
||||
error: PlatformException(code: 'error', message: e.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix', pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger);
|
||||
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix',
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger,
|
||||
);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
assert(message != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null.');
|
||||
assert(
|
||||
message != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null.',
|
||||
);
|
||||
final List<Object?> args = (message as List<Object?>?)!;
|
||||
final TorStatus? arg_status = (args[0] as TorStatus?);
|
||||
assert(arg_status != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null, expected non-null TorStatus.');
|
||||
assert(
|
||||
arg_status != null,
|
||||
'Argument for dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged was null, expected non-null TorStatus.',
|
||||
);
|
||||
try {
|
||||
api.onStatusChanged(arg_status!);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||
return wrapResponse(
|
||||
error: PlatformException(code: 'error', message: e.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -478,9 +518,13 @@ class IPtProxyController {
|
||||
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
IPtProxyController({
|
||||
BinaryMessenger? binaryMessenger,
|
||||
String messageChannelSuffix = '',
|
||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
@@ -488,13 +532,16 @@ class IPtProxyController {
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<int> start(TransportType proxyType, String proxy) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType, proxy]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[proxyType, proxy],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
@@ -515,13 +562,16 @@ class IPtProxyController {
|
||||
}
|
||||
|
||||
Future<void> stop(TransportType proxyType) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[proxyType],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
|
||||
@@ -14,40 +14,39 @@ PlatformException _createConnectionError(String channelName) {
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
a.indexed.every(
|
||||
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
|
||||
);
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
|
||||
return a.length == b.length &&
|
||||
a.entries.every(
|
||||
(MapEntry<Object?, Object?> entry) =>
|
||||
(b as Map<Object?, Object?>).containsKey(entry.key) &&
|
||||
_deepEquals(entry.value, b[entry.key]));
|
||||
_deepEquals(entry.value, b[entry.key]),
|
||||
);
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
|
||||
class LocalizedResult {
|
||||
LocalizedResult({
|
||||
required this.languageName,
|
||||
this.countryName,
|
||||
});
|
||||
LocalizedResult({required this.languageName, this.countryName});
|
||||
|
||||
String languageName;
|
||||
|
||||
String? countryName;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
languageName,
|
||||
countryName,
|
||||
];
|
||||
return <Object?>[languageName, countryName];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static LocalizedResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -71,11 +70,9 @@ class LocalizedResult {
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
int get hashCode => Object.hashAll(_toList());
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -106,23 +103,33 @@ class LocaleResolver {
|
||||
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
LocaleResolver({
|
||||
BinaryMessenger? binaryMessenger,
|
||||
String messageChannelSuffix = '',
|
||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<LocalizedResult> resolve(String languageTag, String targetLangouageTag) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
|
||||
Future<LocalizedResult> resolve(
|
||||
String languageTag,
|
||||
String targetLangouageTag,
|
||||
) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[languageTag, targetLangouageTag]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[languageTag, targetLangouageTag],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
|
||||
@@ -8,7 +8,11 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||
List<Object?> wrapResponse({
|
||||
Object? result,
|
||||
PlatformException? error,
|
||||
bool empty = false,
|
||||
}) {
|
||||
if (empty) {
|
||||
return <Object?>[];
|
||||
}
|
||||
@@ -17,21 +21,25 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
|
||||
}
|
||||
return <Object?>[error.code, error.message, error.details];
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
a.indexed.every(
|
||||
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
|
||||
);
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
|
||||
return a.length == b.length &&
|
||||
a.entries.every(
|
||||
(MapEntry<Object?, Object?> entry) =>
|
||||
(b as Map<Object?, Object?>).containsKey(entry.key) &&
|
||||
_deepEquals(entry.value, b[entry.key]));
|
||||
_deepEquals(entry.value, b[entry.key]),
|
||||
);
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
|
||||
class Intent {
|
||||
Intent({
|
||||
this.fromPackageName,
|
||||
@@ -66,7 +74,8 @@ class Intent {
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static Intent decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -94,11 +103,9 @@ class Intent {
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
int get hashCode => Object.hashAll(_toList());
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -130,32 +137,48 @@ abstract class IntentEvents {
|
||||
|
||||
void onIntentReceived(int sequence, Intent intent);
|
||||
|
||||
static void setUp(IntentEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
static void setUp(
|
||||
IntentEvents? api, {
|
||||
BinaryMessenger? binaryMessenger,
|
||||
String messageChannelSuffix = '',
|
||||
}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix', pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger);
|
||||
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix',
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger,
|
||||
);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
assert(message != null,
|
||||
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null.');
|
||||
assert(
|
||||
message != null,
|
||||
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null.',
|
||||
);
|
||||
final List<Object?> args = (message as List<Object?>?)!;
|
||||
final int? arg_sequence = (args[0] as int?);
|
||||
assert(arg_sequence != null,
|
||||
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null int.');
|
||||
assert(
|
||||
arg_sequence != null,
|
||||
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null int.',
|
||||
);
|
||||
final Intent? arg_intent = (args[1] as Intent?);
|
||||
assert(arg_intent != null,
|
||||
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null Intent.');
|
||||
assert(
|
||||
arg_intent != null,
|
||||
'Argument for dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived was null, expected non-null Intent.',
|
||||
);
|
||||
try {
|
||||
api.onIntentReceived(arg_sequence!, arg_intent!);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||
return wrapResponse(
|
||||
error: PlatformException(code: 'error', message: e.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ PlatformException _createConnectionError(String channelName) {
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||
List<Object?> wrapResponse({
|
||||
Object? result,
|
||||
PlatformException? error,
|
||||
bool empty = false,
|
||||
}) {
|
||||
if (empty) {
|
||||
return <Object?>[];
|
||||
}
|
||||
@@ -25,7 +29,6 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
|
||||
return <Object?>[error.code, error.message, error.details];
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -52,9 +55,13 @@ class SpeechToTextApi {
|
||||
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
SpeechToTextApi({
|
||||
BinaryMessenger? binaryMessenger,
|
||||
String messageChannelSuffix = '',
|
||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
@@ -69,13 +76,16 @@ class SpeechToTextApi {
|
||||
/// The [locale] parameter specifies the language locale for recognition
|
||||
/// (e.g., 'en-US', 'de-DE'). If null, uses the device default.
|
||||
Future<bool> showDialog({String? locale}) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[locale]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
||||
<Object?>[locale],
|
||||
);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
@@ -106,29 +116,43 @@ abstract class SpeechToTextEvents {
|
||||
/// recognition failed or was cancelled.
|
||||
void onTextReceived(String text);
|
||||
|
||||
static void setUp(SpeechToTextEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
static void setUp(
|
||||
SpeechToTextEvents? api, {
|
||||
BinaryMessenger? binaryMessenger,
|
||||
String messageChannelSuffix = '',
|
||||
}) {
|
||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
||||
? '.$messageChannelSuffix'
|
||||
: '';
|
||||
{
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix', pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger);
|
||||
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix',
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: binaryMessenger,
|
||||
);
|
||||
if (api == null) {
|
||||
pigeonVar_channel.setMessageHandler(null);
|
||||
} else {
|
||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||
assert(message != null,
|
||||
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null.');
|
||||
assert(
|
||||
message != null,
|
||||
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null.',
|
||||
);
|
||||
final List<Object?> args = (message as List<Object?>?)!;
|
||||
final String? arg_text = (args[0] as String?);
|
||||
assert(arg_text != null,
|
||||
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null, expected non-null String.');
|
||||
assert(
|
||||
arg_text != null,
|
||||
'Argument for dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived was null, expected non-null String.',
|
||||
);
|
||||
try {
|
||||
api.onTextReceived(arg_text!);
|
||||
return wrapResponse(empty: true);
|
||||
} on PlatformException catch (e) {
|
||||
return wrapResponse(error: e);
|
||||
} catch (e) {
|
||||
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||
return wrapResponse(
|
||||
error: PlatformException(code: 'error', message: e.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Executable
+18
@@ -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
|
||||
Executable
+18
@@ -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
|
||||
Reference in New Issue
Block a user