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