From 25c9faa3ff8d0ce435226fd07c80e9bbef1ff443 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Fri, 7 Mar 2025 20:35:35 +0100 Subject: [PATCH] good state --- app/android/build.gradle | 7 + app/lib/core/routing/routes.browser.dart | 4 +- app/lib/core/routing/routes.feeds.dart | 4 +- app/lib/domain/services/generic_website.dart | 32 +- .../domain/services/generic_website.g.dart | 2 +- .../geckoview/domain/entities/states/tab.dart | 2 +- .../domain/providers/tab_session.dart | 4 + .../domain/providers/tab_session.g.dart | 2 +- .../geckoview/domain/repositories/tab.dart | 113 +++++- .../geckoview/domain/repositories/tab.g.dart | 2 +- .../browser/domain/services/browser_data.dart | 13 +- .../domain/services/browser_data.g.dart | 6 +- .../browser/presentation/screens/browser.dart | 331 ++++++++++-------- .../browser_modules/bottom_app_bar.dart | 15 +- .../widgets/browser_modules/browser_view.dart | 28 +- .../widgets/sheets/view_tabs.dart | 53 ++- .../widgets/search_modules/feed_search.dart | 82 +++-- .../widgets/search_modules/tab_search.dart | 4 + .../tabs/data/database/daos/container.dart | 14 + .../features/tabs/data/database/daos/tab.dart | 14 - .../features/tabs/domain/providers.dart | 2 +- .../features/tabs/domain/providers.g.dart | 2 +- .../domain/providers/selected_container.dart | 16 +- .../providers/selected_container.g.dart | 2 +- .../tabs/domain/repositories/container.dart | 12 + .../tabs/domain/repositories/container.g.dart | 2 +- .../tabs/domain/repositories/tab.dart | 4 +- .../tabs/domain/repositories/tab.g.dart | 2 +- .../web_feed/data/database/database.g.dart | 16 +- .../web_feed/data/models/feed_article.g.dart | 15 + .../features/web_feed/domain/providers.g.dart | 2 +- .../domain/providers/add_dialog_blocking.dart | 43 +++ .../providers/add_dialog_blocking.g.dart | 28 ++ .../services/article_content_processor.dart | 9 +- .../services/article_content_processor.g.dart | 2 +- app/lib/features/web_feed/extensions/rss.dart | 5 +- .../presentation/add_feed_dialog.dart | 11 + app/lib/main.dart | 4 + .../FlutterMozillaComponentsPlugin.kt | 172 +-------- .../api/GeckoBrowserApiImpl.kt | 163 ++++++++- .../api/GeckoContainerProxyApiImpl.kt | 17 + .../feature/ContainerProxyFeature.kt | 52 +++ .../middleware/FlutterEventMiddleware.kt | 2 +- .../pigeons/Gecko.g.kt | 36 ++ .../container_proxy/src/background/index.ts | 13 +- .../lib/flutter_mozilla_components.dart | 1 + .../src/domain/services/gecko_browser.dart | 4 + .../services/gecko_container_proxy.dart | 8 + .../lib/src/pigeons/gecko.g.dart | 51 +++ .../pigeons/gecko.dart | 4 + 50 files changed, 993 insertions(+), 439 deletions(-) create mode 100644 app/lib/features/web_feed/domain/providers/add_dialog_blocking.dart create mode 100644 app/lib/features/web_feed/domain/providers/add_dialog_blocking.g.dart diff --git a/app/android/build.gradle b/app/android/build.gradle index 16b200b0..89f64dac 100644 --- a/app/android/build.gradle +++ b/app/android/build.gradle @@ -1,3 +1,9 @@ +//Required by background_fetch build +ext { + compileSdkVersion = 35 + targetSdkVersion = 35 +} + buildscript { ext.kotlin_version = '1.9.22' } @@ -6,6 +12,7 @@ allprojects { repositories { google() mavenCentral() + //Required by background_fetch build maven { url "${project(':background_fetch').projectDir}/libs" } } } diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index 573a865f..604adcf8 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -1,7 +1,7 @@ part of 'routes.dart'; @TypedGoRoute( - name: 'BrowserRoute', + name: BrowserRoute.name, path: '/', routes: [ TypedGoRoute(name: 'WebPageRoute', path: 'page/:url'), @@ -28,6 +28,8 @@ part of 'routes.dart'; ], ) class BrowserRoute extends GoRouteData { + static const name = 'BrowserRoute'; + @override Widget build(BuildContext context, GoRouterState state) { return const BrowserScreen(); diff --git a/app/lib/core/routing/routes.feeds.dart b/app/lib/core/routing/routes.feeds.dart index 241b04cf..f7b4e330 100644 --- a/app/lib/core/routing/routes.feeds.dart +++ b/app/lib/core/routing/routes.feeds.dart @@ -4,7 +4,7 @@ part of 'routes.dart'; name: 'FeedListRoute', path: '/feeds', routes: [ - TypedGoRoute(name: 'FeedAddRoute', path: 'add'), + TypedGoRoute(name: FeedAddRoute.name, path: 'add'), TypedGoRoute( name: 'FeedArticleListRoute', path: 'articles/:feedId', @@ -73,6 +73,8 @@ class FeedEditRoute extends GoRouteData { class FeedAddRoute extends GoRouteData { final Uri? $extra; + static const name = 'FeedAddRoute'; + const FeedAddRoute({this.$extra}); @override diff --git a/app/lib/domain/services/generic_website.dart b/app/lib/domain/services/generic_website.dart index 0bed9a80..ab12055e 100644 --- a/app/lib/domain/services/generic_website.dart +++ b/app/lib/domain/services/generic_website.dart @@ -247,7 +247,7 @@ class GenericWebsiteService extends _$GenericWebsiteService { return WebPageInfo( url: url, - title: result['title'] as String?, + title: (result['title'] as String?)?.trim(), favicon: favicon, feeds: Set.from( (result['feeds']! as List).map((url) => Uri.tryParse(url)), @@ -257,21 +257,23 @@ class GenericWebsiteService extends _$GenericWebsiteService { } Future getCachedIcon(Uri url) async { - final cachedBrowserIcon = _browserIconCache.get(url.origin); - if (cachedBrowserIcon != null) { - return cachedBrowserIcon; - } + if (url.scheme.startsWith('http')) { + final cachedBrowserIcon = _browserIconCache.get(url.origin); + if (cachedBrowserIcon != null) { + return cachedBrowserIcon; + } - final cachedIcon = await _cacheRepository.getCachedIcon(url.origin); - if (cachedIcon != null) { - return _browserIconCache.set( - url.origin, - await BrowserIcon.fromBytes( - cachedIcon, - dominantColor: null, - source: IconSource.disk, - ), - ); + final cachedIcon = await _cacheRepository.getCachedIcon(url.origin); + if (cachedIcon != null) { + return _browserIconCache.set( + url.origin, + await BrowserIcon.fromBytes( + cachedIcon, + dominantColor: null, + source: IconSource.disk, + ), + ); + } } return null; diff --git a/app/lib/domain/services/generic_website.g.dart b/app/lib/domain/services/generic_website.g.dart index 8e4f8faa..89ae6936 100644 --- a/app/lib/domain/services/generic_website.g.dart +++ b/app/lib/domain/services/generic_website.g.dart @@ -7,7 +7,7 @@ part of 'generic_website.dart'; // ************************************************************************** String _$genericWebsiteServiceHash() => - r'9bba5a6fc01aa788b2674db6ae3869065d6a528d'; + r'9c8020d3c8d85342972db8934b37c53b31064b64'; /// See also [GenericWebsiteService]. @ProviderFor(GenericWebsiteService) diff --git a/app/lib/features/geckoview/domain/entities/states/tab.dart b/app/lib/features/geckoview/domain/entities/states/tab.dart index 2763a3e7..26c76ef7 100644 --- a/app/lib/features/geckoview/domain/entities/states/tab.dart +++ b/app/lib/features/geckoview/domain/entities/states/tab.dart @@ -60,7 +60,7 @@ class TabState extends WebPageInfo { required this.historyState, required this.readerableState, required this.findResultState, - }) : super(title: title); + }) : super(title: title.trim()); factory TabState.$default(String tabId) => TabState( id: tabId, diff --git a/app/lib/features/geckoview/domain/providers/tab_session.dart b/app/lib/features/geckoview/domain/providers/tab_session.dart index 3f9cbfb3..75249229 100644 --- a/app/lib/features/geckoview/domain/providers/tab_session.dart +++ b/app/lib/features/geckoview/domain/providers/tab_session.dart @@ -22,6 +22,10 @@ class TabSession extends _$TabSession { ); } + Future stopLoading() { + return _sessionService.stopLoading(); + } + Future reload() { return _sessionService.reload(); } diff --git a/app/lib/features/geckoview/domain/providers/tab_session.g.dart b/app/lib/features/geckoview/domain/providers/tab_session.g.dart index 4ec35565..7322e3ed 100644 --- a/app/lib/features/geckoview/domain/providers/tab_session.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_session.g.dart @@ -26,7 +26,7 @@ final selectedTabSessionNotifierProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef SelectedTabSessionNotifierRef = AutoDisposeProviderRef>; -String _$tabSessionHash() => r'e4fbbcd430037cc01adb799783fb7974a11a27bf'; +String _$tabSessionHash() => r'33d68f3860b3bc9a92db386535c7482bf4416008'; /// Copied from Dart SDK class _SystemHash { diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart index 31b32f03..c8ca502b 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:lensai/core/logger.dart'; import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/features/bangs/domain/providers/bangs.dart'; import 'package:lensai/features/geckoview/domain/entities/states/tab.dart'; @@ -14,6 +16,7 @@ import 'package:lensai/features/geckoview/features/browser/domain/providers/inte import 'package:lensai/features/geckoview/features/tabs/data/providers.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:lensai/features/share_intent/domain/entities/shared_content.dart'; import 'package:lensai/utils/debouncer.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -80,15 +83,117 @@ class TabRepository extends _$TabRepository { ); } - Future selectTab(String tabId) { - return _tabsService.selectTab(tabId: tabId); + Future selectTab(String tabId) async { + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(tabId); + + final containerData = await containerId.mapNotNull( + (containerId) => ref + .read(containerRepositoryProvider.notifier) + .getContainerData(containerId), + ); + + if (containerData != null) { + if (containerData.metadata.authSettings.authenticationRequired) { + if (containerId != ref.read(selectedContainerProvider)) { + logger.w( + 'Tried to open authenticated tab $tabId but container not selected', + ); + return false; + } + } + + if (containerData.metadata.useProxy) { + final proxyPluginHealthy = + await GeckoContainerProxyService().healthcheck(); + + if (!proxyPluginHealthy) { + logger.w( + 'Tried to open proxied tab $tabId but proxy plugin not responding', + ); + return false; + } + } + } + + await _tabsService.selectTab(tabId: tabId); + return true; } - Future closeTab(String tabId) { + Future _selectNextTab(String tabId) async { + if (ref.read(tabListProvider).value.length == 1) { + return; + } + + final currentContainerId = await ref + .read(tabDataRepositoryProvider.notifier) + .containerTabId(tabId); + + final sameContainerTabs = await ref + .read(containerRepositoryProvider.notifier) + .getContainerTabIds(currentContainerId); + + final nextAvailabeInContainer = sameContainerTabs.firstWhereOrNull( + (tab) => tab != tabId, + ); + + if (nextAvailabeInContainer != null) { + return _tabsService.selectTab(tabId: sameContainerTabs.first); + } + + final unassignedTabs = await ref + .read(containerRepositoryProvider.notifier) + .getContainerTabIds(currentContainerId); + + if (unassignedTabs.isNotEmpty) { + return _tabsService.selectTab(tabId: unassignedTabs.first); + } + + //We only take containers without authentication! + final availableContainers = + await ref + .read(containerRepositoryProvider.notifier) + .getAllContainersWithCount(); + + final nextAvailableContainerUnauthenticated = availableContainers + .firstWhereOrNull( + (container) => + container.metadata.authSettings.authenticationRequired == false, + ); + + final nextContainerTabs = await nextAvailableContainerUnauthenticated + .mapNotNull( + (container) => ref + .read(containerRepositoryProvider.notifier) + .getContainerTabIds(container.id), + ); + + if (nextContainerTabs.isNotEmpty) { + return _tabsService.selectTab(tabId: nextContainerTabs!.first); + } + + if (availableContainers.isNotEmpty) { + //Last resort push new tab to avoid any authenticated tab is selected + // ignore: avoid_redundant_argument_values + await addTab(selectTab: true); + } + } + + Future closeTab(String tabId) async { + if (ref.read(selectedTabProvider) == tabId) { + await _selectNextTab(tabId); + } + return _tabsService.removeTab(tabId: tabId); } - Future closeTabs(List tabIds) { + Future closeTabs(List tabIds) async { + final selectedTab = ref.read(selectedTabProvider); + if (selectedTab.mapNotNull(tabIds.contains) ?? false) { + await _selectNextTab(selectedTab!); + } + return _tabsService.removeTabs(ids: tabIds); } diff --git a/app/lib/features/geckoview/domain/repositories/tab.g.dart b/app/lib/features/geckoview/domain/repositories/tab.g.dart index faed5375..2f2366a8 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.g.dart @@ -6,7 +6,7 @@ part of 'tab.dart'; // RiverpodGenerator // ************************************************************************** -String _$tabRepositoryHash() => r'35c078a841d815aa2452c07b52c4cf0cbcec7e68'; +String _$tabRepositoryHash() => r'a798385b50c101e41b9789e92181cbd5b6188246'; /// See also [TabRepository]. @ProviderFor(TabRepository) diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_data.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_data.dart index 91ebd758..842d1c94 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/browser_data.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_data.dart @@ -4,10 +4,21 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'browser_data.g.dart'; -@Riverpod() +@Riverpod(keepAlive: true) class BrowserDataService extends _$BrowserDataService { final _service = GeckoDeleteBrowserDataService(); + var _onStartDeleted = false; + + Future deleteDataOnEngineStart( + Set? types, + ) async { + if (!_onStartDeleted) { + _onStartDeleted = true; + return deleteData(types); + } + } + Future deleteData(Set? types) async { if (types != null) { for (final type in types) { diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart index 1a8e7bb2..3addd72b 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_data.g.dart @@ -7,12 +7,12 @@ part of 'browser_data.dart'; // ************************************************************************** String _$browserDataServiceHash() => - r'f56c60635505e06dbd5b05f1bcee9c25c4d0eaaf'; + r'0a00db5b143d3851f2c171f4f939940f959b67bb'; /// See also [BrowserDataService]. @ProviderFor(BrowserDataService) final browserDataServiceProvider = - AutoDisposeNotifierProvider.internal( + NotifierProvider.internal( BrowserDataService.new, name: r'browserDataServiceProvider', debugGetCreateSourceHash: @@ -23,6 +23,6 @@ final browserDataServiceProvider = allTransitiveDependencies: null, ); -typedef _$BrowserDataService = AutoDisposeNotifier; +typedef _$BrowserDataService = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index a102ad3f..f8719895 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -61,161 +63,189 @@ class BrowserScreen extends HookConsumerWidget { //We need this for BackButtonListener to work downstream //No direct pop result will be handled here canPop: false, - child: Scaffold( - bottomNavigationBar: Consumer( - builder: (context, ref, child) { - final tabInFullScreen = ref.watch( - selectedTabStateProvider.select( - (value) => value?.isFullScreen ?? false, - ), - ); - - return Visibility( - visible: !tabInFullScreen, - child: BrowserBottomAppBar( - selectedTabId: selectedTabId, - displayedSheet: displayedSheet, - ), - ); - }, + child: Theme( + data: Theme.of(context).copyWith( + bottomSheetTheme: BottomSheetThemeData( + //Remove M3 default of 640p + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width - + math.max( + MediaQuery.of(context).padding.left * 2, + MediaQuery.of(context).padding.right * 2, + ), + ), + ), ), - body: DragTarget( - onMove: (details) { - ref - .read(willAcceptDropProvider.notifier) - .setData(DeleteDropData(details.data.tabId)); - }, - onLeave: (data) { - ref.read(willAcceptDropProvider.notifier).clear(); - }, - onAcceptWithDetails: (details) async { - await ref - .read(tabRepositoryProvider.notifier) - .closeTab(details.data.tabId); - }, - builder: (context, _, _) { - return OverlayPortal( - controller: overlayController, - overlayChildBuilder: (context) { - return displayedOverlay!; - }, - child: Listener( - onPointerDown: - (displayedSheet != null) - ? (_) { - ref - .read(bottomSheetControllerProvider.notifier) - .dismiss(); - } - : null, - child: BackButtonListener( - onBackButtonPressed: () async { - final tabState = - (selectedTabId != null) - ? ref.read(tabStateProvider(selectedTabId)) - : null; + child: Scaffold( + bottomNavigationBar: Consumer( + builder: (context, ref, child) { + final tabInFullScreen = ref.watch( + selectedTabStateProvider.select( + (value) => value?.isFullScreen ?? false, + ), + ); - final tabCount = ref.read( - tabListProvider.select((tabs) => tabs.value.length), - ); + return Visibility( + visible: !tabInFullScreen, + child: BrowserBottomAppBar( + selectedTabId: selectedTabId, + displayedSheet: displayedSheet, + ), + ); + }, + ), + body: DragTarget( + onMove: (details) { + ref + .read(willAcceptDropProvider.notifier) + .setData(DeleteDropData(details.data.tabId)); + }, + onLeave: (data) { + ref.read(willAcceptDropProvider.notifier).clear(); + }, + onAcceptWithDetails: (details) async { + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(details.data.tabId); + }, + builder: (context, _, _) { + return OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) { + return displayedOverlay!; + }, + child: Listener( + onPointerDown: + (displayedSheet != null) + ? (_) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + } + : null, + child: BackButtonListener( + onBackButtonPressed: () async { + final tabState = + (selectedTabId != null) + ? ref.read(tabStateProvider(selectedTabId)) + : null; - //Don't do anything if a child route is active - if (GoRouterState.of(context).topRoute?.path != - BrowserRoute().location) { - return false; - } - - if (displayedSheet != null) { - ref - .read(bottomSheetControllerProvider.notifier) - .dismiss(); - return true; - } - - if (displayedOverlay != null) { - ref.read(overlayControllerProvider.notifier).dismiss(); - return true; - } - - if (tabState?.isFullScreen == true) { - await ref - .read(selectedTabSessionNotifierProvider) - .exitFullscreen(); - return true; - } - - if (tabState?.historyState.canGoBack == true) { - lastBackButtonPress.value = null; - - final controller = ref.read( - tabSessionProvider(tabId: selectedTabId).notifier, + final tabCount = ref.read( + tabListProvider.select((tabs) => tabs.value.length), ); - await controller.goBack(); - return true; - } - - //Go router has routes to go back to - if (context.canPop()) { - return true; - } - - if (lastBackButtonPress.value != null && - DateTime.now().difference(lastBackButtonPress.value!) < - const Duration(seconds: 2)) { - lastBackButtonPress.value = null; - - if (tabState != null && tabCount > 1) { - await ref - .read(tabRepositoryProvider.notifier) - .closeTab(tabState.id); - return true; - } else { - //Mark back as unhandled and navigator will pop + //Don't do anything if a child route is active + if (GoRouterState.of(context).topRoute?.name != + BrowserRoute.name) { return false; } - } else { - lastBackButtonPress.value = DateTime.now(); - ui_helper.showTabBackButtonMessage(context, tabCount); - return true; - } - }, - child: _BrowserView(displayedSheet: displayedSheet), + if (displayedSheet != null) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + return true; + } + + if (displayedOverlay != null) { + ref.read(overlayControllerProvider.notifier).dismiss(); + return true; + } + + if (tabState?.isFullScreen == true) { + await ref + .read(selectedTabSessionNotifierProvider) + .exitFullscreen(); + return true; + } + + if (tabState?.isLoading == true) { + lastBackButtonPress.value = null; + + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); + + await controller.stopLoading(); + return true; + } else if (tabState?.historyState.canGoBack == true) { + lastBackButtonPress.value = null; + + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); + + await controller.goBack(); + return true; + } + + //Go router has routes to go back to + if (context.canPop()) { + return true; + } + + if (lastBackButtonPress.value != null && + DateTime.now().difference( + lastBackButtonPress.value!, + ) < + const Duration(seconds: 2)) { + lastBackButtonPress.value = null; + + if (tabState != null && tabCount > 1) { + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(tabState.id); + return true; + } else { + //Mark back as unhandled and navigator will pop + return false; + } + } else { + lastBackButtonPress.value = DateTime.now(); + ui_helper.showTabBackButtonMessage(context, tabCount); + + return true; + } + }, + child: _BrowserView(displayedSheet: displayedSheet), + ), ), - ), - ); - }, - ), - floatingActionButton: ReaderAppearanceButton(), - bottomSheet: - (displayedSheet != null) - ? NotificationListener( - onNotification: (notification) { - if (notification.extent <= 0.1) { - ref - .read(bottomSheetControllerProvider.notifier) - .dismiss(); - return true; - } else { - ref - .read(bottomSheetExtendProvider.notifier) - .add(notification.extent); - } + ); + }, + ), + floatingActionButton: ReaderAppearanceButton(), + bottomSheet: + (displayedSheet != null) + ? NotificationListener( + onNotification: (notification) { + if (notification.extent <= 0.1) { + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + return true; + } else { + ref + .read(bottomSheetExtendProvider.notifier) + .add(notification.extent); + } - return false; - }, - child: switch (displayedSheet) { - ViewTabsSheet() => _ViewTabsSheet( - key: ValueKey(displayedSheet), - ), - final TabQaChatSheet parameter => _QaSheet( - key: ValueKey(displayedSheet), - chatId: parameter.chatId, - ), - }, - ) - : null, + return false; + }, + child: switch (displayedSheet) { + ViewTabsSheet() => _ViewTabsSheet( + key: ValueKey(displayedSheet), + maxChildSize: MediaQuery.of(context).relativeSafeArea(), + ), + final TabQaChatSheet parameter => _QaSheet( + key: ValueKey(displayedSheet), + maxChildSize: MediaQuery.of(context).relativeSafeArea(), + chatId: parameter.chatId, + ), + }, + ) + : null, + ), ), ); } @@ -295,8 +325,9 @@ class _BrowserView extends StatelessWidget { class _QaSheet extends HookConsumerWidget { final String chatId; + final double maxChildSize; - const _QaSheet({super.key, required this.chatId}); + const _QaSheet({super.key, required this.chatId, this.maxChildSize = 1.0}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -306,7 +337,7 @@ class _QaSheet extends HookConsumerWidget { controller: draggableScrollableController, expand: false, minChildSize: 0.1, - maxChildSize: MediaQuery.of(context).relativeSafeArea(), + maxChildSize: maxChildSize, builder: (context, scrollController) { return ClipRRect( borderRadius: const BorderRadius.only( @@ -349,7 +380,9 @@ class _QaSheet extends HookConsumerWidget { } class _ViewTabsSheet extends HookConsumerWidget { - const _ViewTabsSheet({super.key}); + final double maxChildSize; + + const _ViewTabsSheet({super.key, this.maxChildSize = 1.0}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -359,7 +392,7 @@ class _ViewTabsSheet extends HookConsumerWidget { controller: draggableScrollableController, expand: false, minChildSize: 0.1, - maxChildSize: MediaQuery.of(context).relativeSafeArea(), + maxChildSize: maxChildSize, builder: (context, scrollController) { return ClipRRect( borderRadius: const BorderRadius.only( diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 30b06a3b..9f61a22f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -311,11 +311,17 @@ class BrowserBottomAppBar extends HookConsumerWidget { ).select((value) => value?.historyState), ); + final isLoading = ref.watch( + selectedTabStateProvider.select( + (state) => state?.isLoading ?? false, + ), + ); + return Row( children: [ Expanded( child: - (history?.canGoBack == true) + (history?.canGoBack == true || isLoading) ? IconButton( onPressed: () async { final controller = ref.read( @@ -324,7 +330,12 @@ class BrowserBottomAppBar extends HookConsumerWidget { ).notifier, ); - await controller.goBack(); + if (isLoading) { + await controller.stopLoading(); + } else { + await controller.goBack(); + } + trippleDotMenuController.close(); }, icon: const Icon(Icons.arrow_back), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart index 402c3480..5e20d4cb 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.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:lensai/core/logger.dart'; import 'package:lensai/core/routing/routes.dart'; @@ -20,7 +21,9 @@ import 'package:lensai/features/geckoview/features/tabs/features/vector_store/do import 'package:lensai/features/user/domain/repositories/cache.dart'; import 'package:lensai/features/user/domain/repositories/general_settings.dart'; import 'package:lensai/features/user/domain/services/local_authentication.dart'; +import 'package:lensai/features/web_feed/domain/providers/add_dialog_blocking.dart'; import 'package:lensai/features/web_feed/domain/services/article_content_processor.dart'; +import 'package:lensai/presentation/hooks/on_initialization.dart'; class BrowserView extends StatefulHookConsumerWidget { final Duration screenshotPeriod; @@ -56,6 +59,16 @@ class _BrowserViewState extends ConsumerState @override Widget build(BuildContext context) { + useOnInitialization(() async { + await ref.read(generalSettingsRepositoryProvider.notifier).fetch().then(( + settings, + ) { + ref + .read(browserDataServiceProvider.notifier) + .deleteDataOnEngineStart(settings.deleteBrowsingDataOnQuit); + }); + }); + final hasTab = ref.watch( selectedTabProvider.select((value) => value != null), ); @@ -84,7 +97,11 @@ class _BrowserViewState extends ConsumerState ref.listen(feedRequestedProvider, (previous, next) async { if (next.valueOrNull.mapNotNull(Uri.tryParse) case final Uri url) { - await FeedAddRoute($extra: url).push(context); + if (GoRouterState.of(context).topRoute?.name != FeedAddRoute.name) { + if (ref.read(addFeedDialogBlockingProvider.notifier).canPush(url)) { + await FeedAddRoute($extra: url).push(context); + } + } } }); @@ -93,15 +110,6 @@ class _BrowserViewState extends ConsumerState replacement: SizedBox.expand(child: Container(color: Colors.grey[800])), child: GeckoView( preInitializationStep: () async { - await ref - .read(generalSettingsRepositoryProvider.notifier) - .fetch() - .then((settings) { - ref - .read(browserDataServiceProvider.notifier) - .deleteData(settings.deleteBrowsingDataOnQuit); - }); - await ref .read(eventServiceProvider) .viewReadyStateEvents diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart index 6d000efc..82193c52 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -166,6 +167,20 @@ class ViewTabsSheetWidget extends HookConsumerWidget { super.key, }); + int _calculateCrossAxisItemCount({ + required double screenWidth, + required double horizontalPadding, + required double crossAxisSpacing, + }) { + final totalHorizontalPadding = horizontalPadding * 2; + final availableWidth = + screenWidth - totalHorizontalPadding - crossAxisSpacing; + + final crossAxisCount = availableWidth ~/ 180.0; + + return crossAxisCount; + } + double _calculateItemHeight({ required double screenWidth, required double childAspectRatio, @@ -212,6 +227,25 @@ class ViewTabsSheetWidget extends HookConsumerWidget { final activeTab = ref.watch(selectedTabProvider); + final crossAxisCount = useMemoized( + () { + final calculatedCount = _calculateCrossAxisItemCount( + screenWidth: MediaQuery.of(context).size.width, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + ); + + return math.max( + math.min(calculatedCount, filteredTabIds.value.length), + 1, + ); + }, + [ + MediaQuery.of(context).size.width, + filteredTabIds.value.length, + ], + ); + final itemHeight = useMemoized( () => _calculateItemHeight( screenWidth: MediaQuery.of(context).size.width, @@ -219,9 +253,9 @@ class ViewTabsSheetWidget extends HookConsumerWidget { horizontalPadding: 4.0, mainAxisSpacing: 8.0, crossAxisSpacing: 8.0, - crossAxisCount: 2, + crossAxisCount: crossAxisCount, ), - [MediaQuery.of(context).size.width], + [MediaQuery.of(context).size.width, crossAxisCount], ); useEffect(() { @@ -349,14 +383,13 @@ class ViewTabsSheetWidget extends HookConsumerWidget { builder: (children) { return GridView.builder( controller: sheetScrollController, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - //Sync values for itemHeight calculation _calculateItemHeight - childAspectRatio: 0.75, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: 2, - ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), itemCount: children.length, itemBuilder: (context, index) => children[index], ); diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart index a3a3d0ee..de5f1146 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/providers/format.dart'; import 'package:lensai/core/routing/routes.dart'; import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/extensions/uri.dart'; @@ -80,6 +81,8 @@ class FeedSearch extends HookConsumerWidget { _ => null, }; + final articleDate = article.updated ?? article.created; + return ListTile( leading: RepaintBoundary( child: UrlIcon([ @@ -108,38 +111,55 @@ class FeedSearch extends HookConsumerWidget { article.displayTitle, style: theme.textTheme.titleMedium, ), - subtitle: - (searchSnippet.isNotEmpty) - ? MarkdownBody( - data: searchSnippet!, - styleSheet: MarkdownStyleSheet( - p: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith( - color: - Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - a: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith( - color: - Theme.of( - context, - ).colorScheme.onSurfaceVariant, - decoration: TextDecoration.none, - ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (searchSnippet.isNotEmpty) + MarkdownBody( + data: searchSnippet!, + styleSheet: MarkdownStyleSheet( + p: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), - ) - : ((article.summaryPlain != null) - ? Text( - article.summaryPlain!, - style: theme.textTheme.bodySmall, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ) - : null), + a: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + decoration: TextDecoration.none, + ), + ), + ) + else + (article.summaryPlain != null) + ? Text( + article.summaryPlain!, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox.shrink(), + if (articleDate != null) + Align( + alignment: Alignment.topRight, + child: Text( + ref + .read(formatProvider.notifier) + .fullDateTimeWithTimezone(articleDate), + style: theme.textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), onTap: () { FeedArticleRoute( articleId: article.id, diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index ba5d47c0..2d9b3723 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -58,6 +58,10 @@ class TabSearch extends HookConsumerWidget { ); }); + if (tabs.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + return MultiSliver( children: [ SliverToBoxAdapter( diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart index ecc564c3..938c9dd6 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/container.dart @@ -52,6 +52,20 @@ class ContainerDao extends DatabaseAccessor ); } + Selectable getContainerTabIds(String? containerId) { + final query = + selectOnly(db.tab) + ..addColumns([db.tab.id]) + ..where( + (containerId != null) + ? db.tab.containerId.equals(containerId) + : db.tab.containerId.isNull(), + ) + ..orderBy([OrderingTerm.asc(db.tab.orderKey)]); + + return query.map((row) => row.read(db.tab.id)!); + } + SingleSelectable generateLeadingOrderKey( String? containerId, { int bucket = 0, diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index aeeb845b..065c0f52 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -20,20 +20,6 @@ class TabDao extends DatabaseAccessor with _$TabDaoMixin { SingleOrNullSelectable getTabDataById(String id) => db.tab.select()..where((t) => t.id.equals(id)); - Selectable containerTabIds(String? containerId) { - final query = - selectOnly(db.tab) - ..addColumns([db.tab.id]) - ..where( - (containerId != null) - ? db.tab.containerId.equals(containerId) - : db.tab.containerId.isNull(), - ) - ..orderBy([OrderingTerm.asc(db.tab.orderKey)]); - - return query.map((row) => row.read(db.tab.id)!); - } - Selectable getAllTabIds() { final query = selectOnly(db.tab) diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart index eb00ef9e..98704434 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.dart @@ -43,7 +43,7 @@ Stream> containerTabIds(Ref ref, ContainerFilter containerFilter) { switch (containerFilter) { case ContainerFilterById(:final containerId): - return db.tabDao.containerTabIds(containerId).watch(); + return db.containerDao.getContainerTabIds(containerId).watch(); case ContainerFilterDisabled(): return db.tabDao.getAllTabIds().watch(); } diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart index 4c390192..fd84330e 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -183,7 +183,7 @@ class _MatchSortedContainersWithCountProviderElement (origin as MatchSortedContainersWithCountProvider).searchText; } -String _$containerTabIdsHash() => r'355c65523db29f376faa293ab1f1ed8273696548'; +String _$containerTabIdsHash() => r'61716a2ae74ffa590c1498d259382e69e24ad7f1'; /// See also [containerTabIds]. @ProviderFor(containerTabIds) diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart index 8717fda2..81479e9f 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart @@ -1,3 +1,4 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:lensai/features/geckoview/features/tabs/data/providers.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/providers.dart'; @@ -48,13 +49,18 @@ class SelectedContainer extends _$SelectedContainer { } if (passAuth) { - state = id; - if (container.metadata.useProxy) { - return SetContainerResult.successHasProxy; - } + final proxyPluginHealthy = + await GeckoContainerProxyService().healthcheck(); - return SetContainerResult.success; + if (proxyPluginHealthy) { + state = id; + return SetContainerResult.successHasProxy; + } + } else { + state = id; + return SetContainerResult.success; + } } } diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart index 4cf1faf3..eda3a1fb 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart @@ -26,7 +26,7 @@ final selectedContainerDataProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef SelectedContainerDataRef = AutoDisposeStreamProviderRef; -String _$selectedContainerHash() => r'f3dab491337396b8d56daed37a0d85c85aa8d64a'; +String _$selectedContainerHash() => r'f3fbe035aff6902c8e140f02a3efeca20a75a70f'; /// See also [SelectedContainer]. @ProviderFor(SelectedContainer) diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart index ddf823db..f8ce8625 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart @@ -15,6 +15,10 @@ class ContainerRepository extends _$ContainerRepository { return ref.read(tabDatabaseProvider).containerDao.addContainer(container); } + Future> getAllContainersWithCount() { + return ref.read(tabDatabaseProvider).containersWithCount().get(); + } + Future replaceContainer(ContainerData container) { return ref .read(tabDatabaseProvider) @@ -30,6 +34,14 @@ class ContainerRepository extends _$ContainerRepository { .getSingleOrNull(); } + Future> getContainerTabIds(String? id) { + return ref + .read(tabDatabaseProvider) + .containerDao + .getContainerTabIds(id) + .get(); + } + Future deleteContainer(String id) async { await ref.read(tabDataRepositoryProvider.notifier).closeAllTabs(id); diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart index 62ff992c..d838e411 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.g.dart @@ -7,7 +7,7 @@ part of 'container.dart'; // ************************************************************************** String _$containerRepositoryHash() => - r'7bb9aa1b2d88e79b7d5e69df1817fb4c97a2a542'; + r'9fff10b7896ea8865282bc315df21abde18b9d69'; /// See also [ContainerRepository]. @ProviderFor(ContainerRepository) diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart index 20bedacc..a5011ebf 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -24,8 +24,8 @@ class TabDataRepository extends _$TabDataRepository { final tabIds = await ref .read(tabDatabaseProvider) - .tabDao - .containerTabIds(containerId) + .containerDao + .getContainerTabIds(containerId) .get(); if (tabIds.isNotEmpty) { diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index 4bf9934a..74317ea4 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -6,7 +6,7 @@ part of 'tab.dart'; // RiverpodGenerator // ************************************************************************** -String _$tabDataRepositoryHash() => r'6c6f4aa5eaf4033541700bf18a592555a4b946d3'; +String _$tabDataRepositoryHash() => r'b5193e58aa331c7d0dc374e01032fe3a44955c99'; /// See also [TabDataRepository]. @ProviderFor(TabDataRepository) diff --git a/app/lib/features/web_feed/data/database/database.g.dart b/app/lib/features/web_feed/data/database/database.g.dart index f3db572e..c5ebda29 100644 --- a/app/lib/features/web_feed/data/database/database.g.dart +++ b/app/lib/features/web_feed/data/database/database.g.dart @@ -975,6 +975,7 @@ class ArticleView extends ViewInfo contentMarkdown, contentPlain, icon, + siteLink, ]; @override String get aliasedName => _alias ?? entityName; @@ -983,7 +984,7 @@ class ArticleView extends ViewInfo @override Map get createViewStatements => { SqlDialect.sqlite: - 'CREATE VIEW article_view AS SELECT a.*, f.icon FROM article AS a INNER JOIN feed AS f ON f.url = a.feed_id', + 'CREATE VIEW article_view AS SELECT a.*, f.icon, f.site_link FROM article AS a INNER JOIN feed AS f ON f.url = a.feed_id', }; @override ArticleView get asDslTable => this; @@ -1071,6 +1072,12 @@ class ArticleView extends ViewInfo data['${effectivePrefix}icon'], ), ), + siteLink: Feed.$convertersiteLinkn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}site_link'], + ), + ), ); } @@ -1181,6 +1188,13 @@ class ArticleView extends ViewInfo true, type: DriftSqlType.string, ).withConverter(Feed.$convertericonn); + late final GeneratedColumnWithTypeConverter siteLink = + GeneratedColumn( + 'site_link', + aliasedName, + true, + type: DriftSqlType.string, + ).withConverter(Feed.$convertersiteLinkn); @override ArticleView createAlias(String alias) { return ArticleView(attachedDatabase, alias); diff --git a/app/lib/features/web_feed/data/models/feed_article.g.dart b/app/lib/features/web_feed/data/models/feed_article.g.dart index ffe24b6e..57508fd2 100644 --- a/app/lib/features/web_feed/data/models/feed_article.g.dart +++ b/app/lib/features/web_feed/data/models/feed_article.g.dart @@ -41,6 +41,8 @@ abstract class _$FeedArticleCWProxy { FeedArticle icon(Uri? icon); + FeedArticle siteLink(Uri? siteLink); + /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FeedArticle(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. /// /// Usage @@ -65,6 +67,7 @@ abstract class _$FeedArticleCWProxy { String? contentMarkdown, String? contentPlain, Uri? icon, + Uri? siteLink, }); } @@ -131,6 +134,9 @@ class _$FeedArticleCWProxyImpl implements _$FeedArticleCWProxy { @override FeedArticle icon(Uri? icon) => this(icon: icon); + @override + FeedArticle siteLink(Uri? siteLink) => this(siteLink: siteLink); + @override /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `FeedArticle(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. /// @@ -156,6 +162,7 @@ class _$FeedArticleCWProxyImpl implements _$FeedArticleCWProxy { Object? contentMarkdown = const $CopyWithPlaceholder(), Object? contentPlain = const $CopyWithPlaceholder(), Object? icon = const $CopyWithPlaceholder(), + Object? siteLink = const $CopyWithPlaceholder(), }) { return FeedArticle( id: @@ -243,6 +250,11 @@ class _$FeedArticleCWProxyImpl implements _$FeedArticleCWProxy { ? _value.icon // ignore: cast_nullable_to_non_nullable : icon as Uri?, + siteLink: + siteLink == const $CopyWithPlaceholder() + ? _value.siteLink + // ignore: cast_nullable_to_non_nullable + : siteLink as Uri?, ); } } @@ -293,6 +305,8 @@ FeedArticle _$FeedArticleFromJson(Map json) => FeedArticle( contentMarkdown: json['contentMarkdown'] as String?, contentPlain: json['contentPlain'] as String?, icon: json['icon'] == null ? null : Uri.parse(json['icon'] as String), + siteLink: + json['siteLink'] == null ? null : Uri.parse(json['siteLink'] as String), ); Map _$FeedArticleToJson(FeedArticle instance) => @@ -314,4 +328,5 @@ Map _$FeedArticleToJson(FeedArticle instance) => 'contentMarkdown': instance.contentMarkdown, 'contentPlain': instance.contentPlain, 'icon': instance.icon?.toString(), + 'siteLink': instance.siteLink?.toString(), }; diff --git a/app/lib/features/web_feed/domain/providers.g.dart b/app/lib/features/web_feed/domain/providers.g.dart index 0e942592..59f15464 100644 --- a/app/lib/features/web_feed/domain/providers.g.dart +++ b/app/lib/features/web_feed/domain/providers.g.dart @@ -684,7 +684,7 @@ class _FetchWebFeedProviderElement Uri get url => (origin as FetchWebFeedProvider).url; } -String _$articleSearchHash() => r'8bf2c4aa8d8b3918be8a416909535682abfbab35'; +String _$articleSearchHash() => r'cc59a5d3c4b926db9f03ed96db592965ff925b04'; abstract class _$ArticleSearch extends BuildlessAutoDisposeStreamNotifier> { diff --git a/app/lib/features/web_feed/domain/providers/add_dialog_blocking.dart b/app/lib/features/web_feed/domain/providers/add_dialog_blocking.dart new file mode 100644 index 00000000..50d93a83 --- /dev/null +++ b/app/lib/features/web_feed/domain/providers/add_dialog_blocking.dart @@ -0,0 +1,43 @@ +import 'package:lensai/core/logger.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'add_dialog_blocking.g.dart'; + +@Riverpod(keepAlive: true) +class AddFeedDialogBlocking extends _$AddFeedDialogBlocking { + DateTime? _lastIgnore; + final _ignoredUrls = {}; + + void ignore(Uri url) { + final date = DateTime.now(); + + _lastIgnore = date; + _ignoredUrls[url] = date; + } + + bool canPush(Uri url) { + if (_lastIgnore.mapNotNull( + (last) => + DateTime.now().difference(last) <= const Duration(seconds: 30), + ) ?? + false) { + logger.i('Blocking add feed default timeout for $url'); + return false; + } + + if (_ignoredUrls[url].mapNotNull( + (last) => + DateTime.now().difference(last) <= const Duration(minutes: 5), + ) ?? + false) { + logger.i('Blocking add feed url specific for $url'); + return false; + } + + return true; + } + + @override + void build() {} +} diff --git a/app/lib/features/web_feed/domain/providers/add_dialog_blocking.g.dart b/app/lib/features/web_feed/domain/providers/add_dialog_blocking.g.dart new file mode 100644 index 00000000..05727c2c --- /dev/null +++ b/app/lib/features/web_feed/domain/providers/add_dialog_blocking.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'add_dialog_blocking.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$addFeedDialogBlockingHash() => + r'513071d5e507dd292df6b4a3ee3ef7d2217db5bb'; + +/// See also [AddFeedDialogBlocking]. +@ProviderFor(AddFeedDialogBlocking) +final addFeedDialogBlockingProvider = + NotifierProvider.internal( + AddFeedDialogBlocking.new, + name: r'addFeedDialogBlockingProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$addFeedDialogBlockingHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$AddFeedDialogBlocking = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/web_feed/domain/services/article_content_processor.dart b/app/lib/features/web_feed/domain/services/article_content_processor.dart index 76e1ebe4..18a8f329 100644 --- a/app/lib/features/web_feed/domain/services/article_content_processor.dart +++ b/app/lib/features/web_feed/domain/services/article_content_processor.dart @@ -1,7 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:lensai/core/logger.dart'; -import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/features/web_feed/data/models/feed_article.dart'; import 'package:lensai/features/web_feed/data/providers.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -29,10 +28,10 @@ class ArticleContentProcessorService extends _$ArticleContentProcessorService { articles .mapIndexed( (index, article) => article.copyWith( - contentMarkdown: content[index].markdown.whenNotEmpty, - contentPlain: content[index].plain.whenNotEmpty, - summaryMarkdown: summary[index].markdown.whenNotEmpty, - summaryPlain: summary[index].plain.whenNotEmpty, + contentMarkdown: content[index].markdown ?? '', + contentPlain: content[index].plain, + summaryMarkdown: summary[index].markdown ?? '', + summaryPlain: summary[index].plain, ), ) .toList(), diff --git a/app/lib/features/web_feed/domain/services/article_content_processor.g.dart b/app/lib/features/web_feed/domain/services/article_content_processor.g.dart index 391ad6df..984f0f80 100644 --- a/app/lib/features/web_feed/domain/services/article_content_processor.g.dart +++ b/app/lib/features/web_feed/domain/services/article_content_processor.g.dart @@ -7,7 +7,7 @@ part of 'article_content_processor.dart'; // ************************************************************************** String _$articleContentProcessorServiceHash() => - r'182cd89e38a0a9a86b7425c16ad2bd1fc39c6704'; + r'e7cc3da71f6dcf39b0c10df4dcd061f816d5c12e'; /// See also [ArticleContentProcessorService]. @ProviderFor(ArticleContentProcessorService) diff --git a/app/lib/features/web_feed/extensions/rss.dart b/app/lib/features/web_feed/extensions/rss.dart index 8f725263..9168ad49 100644 --- a/app/lib/features/web_feed/extensions/rss.dart +++ b/app/lib/features/web_feed/extensions/rss.dart @@ -16,8 +16,9 @@ extension ParseRssCategory on List { List toFeedCategories() { return where((category) => category.value.isNotEmpty) .map( - (category) => - FeedCategory(id: '${category.domain} ${category.value}'.trim()), + (category) => FeedCategory( + id: '${category.domain ?? ''} ${category.value ?? ''}'.trim(), + ), ) .toList(); } diff --git a/app/lib/features/web_feed/presentation/add_feed_dialog.dart b/app/lib/features/web_feed/presentation/add_feed_dialog.dart index ecefb072..67b7f5ca 100644 --- a/app/lib/features/web_feed/presentation/add_feed_dialog.dart +++ b/app/lib/features/web_feed/presentation/add_feed_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/routing/routes.dart'; +import 'package:lensai/features/web_feed/domain/providers/add_dialog_blocking.dart'; import 'package:lensai/utils/form_validators.dart'; import 'package:lensai/utils/uri_parser.dart' as uri_parser; @@ -38,6 +39,16 @@ class AddFeedDialog extends HookConsumerWidget { ), ), actions: [ + if (initialUri != null) + TextButton( + onPressed: () { + ref + .read(addFeedDialogBlockingProvider.notifier) + .ignore(initialUri!); + context.pop(); + }, + child: const Text('Ignore'), + ), TextButton( onPressed: () { context.pop(); diff --git a/app/lib/main.dart b/app/lib/main.dart index 6fcb307d..818ad1bf 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,6 +1,8 @@ import 'package:background_fetch/background_fetch.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' + show GeckoBrowserService; import 'package:home_widget/home_widget.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/error_observer.dart'; @@ -16,6 +18,8 @@ import 'package:lensai/presentation/main_app.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await GeckoBrowserService().initialize(); + await BackgroundFetch.registerHeadlessTask(backgroundFetch); await HomeWidget.setAppGroupId('bang_navigator'); diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt index ed8b677f..a3c90851 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/FlutterMozillaComponentsPlugin.kt @@ -1,171 +1,20 @@ package eu.lensai.flutter_mozilla_components -import android.app.Activity -import android.content.Intent -import android.view.View -import androidx.fragment.app.FragmentActivity -import eu.lensai.flutter_mozilla_components.activities.NotificationActivity -import eu.lensai.flutter_mozilla_components.api.GeckoAddonsApiImpl import eu.lensai.flutter_mozilla_components.api.GeckoBrowserApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoContainerProxyApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoCookieApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoDeleteBrowsingDataControllerImpl -import eu.lensai.flutter_mozilla_components.api.GeckoDownloadsApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoFindApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoIconsApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoPrefApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoSelectionActionControllerImpl -import eu.lensai.flutter_mozilla_components.api.GeckoSessionApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoSuggestionApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoTabsApiImpl -import eu.lensai.flutter_mozilla_components.api.GeckoBrowserExtensionApiImpl -import eu.lensai.flutter_mozilla_components.feature.DefaultSelectionActionDelegate -import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents -import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonEvents -import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonsApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserExtensionApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoContainerProxyApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoCookieApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoDeleteBrowsingDataController -import eu.lensai.flutter_mozilla_components.pigeons.GeckoDownloadsApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoFindApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoIconsApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoPrefApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoSelectionActionController -import eu.lensai.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents -import eu.lensai.flutter_mozilla_components.pigeons.GeckoSessionApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents -import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionApi -import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents -import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabContentEvents -import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabsApi -import eu.lensai.flutter_mozilla_components.pigeons.ReaderViewController -import eu.lensai.flutter_mozilla_components.pigeons.ReaderViewEvents import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding -import mozilla.components.support.base.log.Log -import mozilla.components.support.base.log.sink.AndroidLogSink /** FlutterMozillaComponentsPlugin */ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { - companion object { - private const val FRAGMENT_CONTAINER_ID = 0xBEEF - - private var isGeckoInitialized = false - } - - private val components by lazy { - requireNotNull(GlobalComponents.components) { "Components not initialized" } - } - - private var activity: Activity? = null - - private lateinit var _flutterPluginBinding: FlutterPlugin.FlutterPluginBinding; - private lateinit var _flutterEvents : GeckoStateEvents - - private var isPlatformViewRegistered = false + private val browserApi = GeckoBrowserApiImpl() override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - synchronized(this) { - if(!isGeckoInitialized) { - Log.addSink(AndroidLogSink()) - - setupGeckoEngine(flutterPluginBinding) - isGeckoInitialized = true - } - } - } - - private fun setupGeckoEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - _flutterPluginBinding = flutterPluginBinding - - _flutterEvents = GeckoStateEvents(_flutterPluginBinding.binaryMessenger) - val selectionActionEvents = GeckoSelectionActionEvents(_flutterPluginBinding.binaryMessenger) - - val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) - - val readerViewController = - ReaderViewController(_flutterPluginBinding.binaryMessenger) - - val extensionEvents = BrowserExtensionEvents(_flutterPluginBinding.binaryMessenger) - - val addonEvents = GeckoAddonEvents(_flutterPluginBinding.binaryMessenger) - val tabContentEvents = GeckoTabContentEvents(_flutterPluginBinding.binaryMessenger) - - val suggestionEvents = GeckoSuggestionEvents(_flutterPluginBinding.binaryMessenger) - GeckoSuggestionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSuggestionApiImpl(suggestionEvents)) - - GlobalComponents.setUp( - flutterPluginBinding.applicationContext, - _flutterEvents, - readerViewController, - selectionActionDelegate, - addonEvents, - tabContentEvents, - extensionEvents - ) - - GeckoBrowserApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserApiImpl { - showNativeFragment() - }) - - GeckoEngineSettingsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoEngineSettingsApiImpl()) - GeckoAddonsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAddonsApiImpl(flutterPluginBinding.applicationContext)) - GeckoSessionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSessionApiImpl()) - GeckoTabsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTabsApiImpl()) - GeckoIconsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoIconsApiImpl()) - GeckoCookieApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoCookieApiImpl()) - GeckoPrefApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPrefApiImpl()) - GeckoContainerProxyApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoContainerProxyApiImpl()) - GeckoFindApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoFindApiImpl()) - GeckoSelectionActionController.setUp(_flutterPluginBinding.binaryMessenger, GeckoSelectionActionControllerImpl( - selectionActionDelegate - )) - GeckoDeleteBrowsingDataController.setUp(_flutterPluginBinding.binaryMessenger, GeckoDeleteBrowsingDataControllerImpl()) - GeckoDownloadsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoDownloadsApiImpl()) - GeckoBrowserExtensionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserExtensionApiImpl()) - - ReaderViewEvents.setUp( - _flutterPluginBinding.binaryMessenger, - components.events.readerViewEvents - ) - - val intent = Intent(flutterPluginBinding.applicationContext, NotificationActivity::class.java) - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - flutterPluginBinding.applicationContext.startActivity(intent) - } - - private fun showNativeFragment(): Boolean { - if (!isPlatformViewRegistered) { - return false - } - - if (activity == null || activity !is FragmentActivity) { - return false - } - - val fragmentActivity = activity as FragmentActivity - - // Check if the container view exists in the view hierarchy - val container = fragmentActivity.findViewById(FRAGMENT_CONTAINER_ID) - if (container == null) { - // Container doesn't exist yet, retry later - return false - } - - val nativeFragment = BrowserFragment.create() - val fm = fragmentActivity.supportFragmentManager - fm.beginTransaction() - .replace(FRAGMENT_CONTAINER_ID, nativeFragment) - .commitAllowingStateLoss() - - return true + browserApi.attachBinding(flutterPluginBinding) + GeckoBrowserApi.setUp(flutterPluginBinding.binaryMessenger, browserApi) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -173,17 +22,7 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - this.activity = binding.activity - - _flutterPluginBinding.platformViewRegistry.registerViewFactory( - "eu.lensai/gecko", GeckoViewFactory( - binding.activity, - FRAGMENT_CONTAINER_ID, - _flutterEvents - ) - ) - - isPlatformViewRegistered = true + browserApi.attachActivity(binding.activity) } override fun onDetachedFromActivityForConfigChanges() { @@ -195,7 +34,6 @@ class FlutterMozillaComponentsPlugin: FlutterPlugin, ActivityAware { } override fun onDetachedFromActivity() { - this.activity = null - isPlatformViewRegistered = false + browserApi.detachActivity() } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index 8163a642..17915064 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -1,17 +1,99 @@ package eu.lensai.flutter_mozilla_components.api +import android.app.Activity +import android.content.Intent +import android.view.View +import androidx.fragment.app.FragmentActivity +import eu.lensai.flutter_mozilla_components.BrowserFragment +import eu.lensai.flutter_mozilla_components.GeckoViewFactory import eu.lensai.flutter_mozilla_components.GlobalComponents +import eu.lensai.flutter_mozilla_components.activities.NotificationActivity +import eu.lensai.flutter_mozilla_components.feature.DefaultSelectionActionDelegate +import eu.lensai.flutter_mozilla_components.pigeons.BrowserExtensionEvents +import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonEvents +import eu.lensai.flutter_mozilla_components.pigeons.GeckoAddonsApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoBrowserExtensionApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoContainerProxyApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoCookieApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoDeleteBrowsingDataController +import eu.lensai.flutter_mozilla_components.pigeons.GeckoDownloadsApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoFindApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoIconsApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoPrefApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoSelectionActionController +import eu.lensai.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents +import eu.lensai.flutter_mozilla_components.pigeons.GeckoSessionApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionApi +import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents +import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabContentEvents +import eu.lensai.flutter_mozilla_components.pigeons.GeckoTabsApi +import eu.lensai.flutter_mozilla_components.pigeons.ReaderViewController +import eu.lensai.flutter_mozilla_components.pigeons.ReaderViewEvents +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding import mozilla.components.browser.state.action.SystemAction import mozilla.components.feature.addons.logger +import mozilla.components.support.base.log.Log +import mozilla.components.support.base.log.sink.AndroidLogSink /** * Implementation of GeckoBrowserApi that handles browser-related operations * @param showFragmentCallback Callback function to show native fragment */ -class GeckoBrowserApiImpl(private val showFragmentCallback: () -> Boolean) : GeckoBrowserApi { +class GeckoBrowserApiImpl : GeckoBrowserApi { companion object { private const val TAG = "GeckoBrowserApiImpl" + private const val FRAGMENT_CONTAINER_ID = 0xBEEF + + private var isGeckoInitialized = false + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + private var activity: Activity? = null + private var isPlatformViewRegistered = false + + private lateinit var _flutterPluginBinding: FlutterPlugin.FlutterPluginBinding + private lateinit var _flutterEvents : GeckoStateEvents + + fun attachBinding(flutterPluginBinding: FlutterPluginBinding) { + _flutterPluginBinding = flutterPluginBinding + _flutterEvents = GeckoStateEvents(_flutterPluginBinding.binaryMessenger) + } + + fun attachActivity(activity: Activity) { + this.activity = activity + + _flutterPluginBinding.platformViewRegistry.registerViewFactory( + "eu.lensai/gecko", GeckoViewFactory( + activity, + FRAGMENT_CONTAINER_ID, + _flutterEvents + ) + ) + + isPlatformViewRegistered = true + } + + fun detachActivity() { + this.activity = null + isPlatformViewRegistered = false + } + + override fun initialize() { + synchronized(this) { + if(!isGeckoInitialized) { + Log.addSink(AndroidLogSink()) + + setupGeckoEngine() + isGeckoInitialized = true + } + } } override fun showNativeFragment(): Boolean { @@ -24,6 +106,85 @@ class GeckoBrowserApiImpl(private val showFragmentCallback: () -> Boolean) : Gec return false } + private fun setupGeckoEngine() { + val selectionActionEvents = GeckoSelectionActionEvents(_flutterPluginBinding.binaryMessenger) + + val selectionActionDelegate = DefaultSelectionActionDelegate(selectionActionEvents) + + val readerViewController = + ReaderViewController(_flutterPluginBinding.binaryMessenger) + + val extensionEvents = BrowserExtensionEvents(_flutterPluginBinding.binaryMessenger) + + val addonEvents = GeckoAddonEvents(_flutterPluginBinding.binaryMessenger) + val tabContentEvents = GeckoTabContentEvents(_flutterPluginBinding.binaryMessenger) + + val suggestionEvents = GeckoSuggestionEvents(_flutterPluginBinding.binaryMessenger) + GeckoSuggestionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSuggestionApiImpl(suggestionEvents)) + + GlobalComponents.setUp( + _flutterPluginBinding.applicationContext, + _flutterEvents, + readerViewController, + selectionActionDelegate, + addonEvents, + tabContentEvents, + extensionEvents + ) + + GeckoEngineSettingsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoEngineSettingsApiImpl()) + GeckoAddonsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAddonsApiImpl(_flutterPluginBinding.applicationContext)) + GeckoSessionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSessionApiImpl()) + GeckoTabsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTabsApiImpl()) + GeckoIconsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoIconsApiImpl()) + GeckoCookieApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoCookieApiImpl()) + GeckoPrefApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPrefApiImpl()) + GeckoContainerProxyApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoContainerProxyApiImpl()) + GeckoFindApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoFindApiImpl()) + GeckoSelectionActionController.setUp(_flutterPluginBinding.binaryMessenger, GeckoSelectionActionControllerImpl( + selectionActionDelegate + )) + GeckoDeleteBrowsingDataController.setUp(_flutterPluginBinding.binaryMessenger, GeckoDeleteBrowsingDataControllerImpl()) + GeckoDownloadsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoDownloadsApiImpl()) + GeckoBrowserExtensionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoBrowserExtensionApiImpl()) + + ReaderViewEvents.setUp( + _flutterPluginBinding.binaryMessenger, + components.events.readerViewEvents + ) + + val intent = Intent(_flutterPluginBinding.applicationContext, NotificationActivity::class.java) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + _flutterPluginBinding.applicationContext.startActivity(intent) + } + + private fun showFragmentCallback(): Boolean { + if (!isPlatformViewRegistered) { + return false + } + + if (activity == null || activity !is FragmentActivity) { + return false + } + + val fragmentActivity = activity as FragmentActivity + + // Check if the container view exists in the view hierarchy + val container = fragmentActivity.findViewById(FRAGMENT_CONTAINER_ID) + if (container == null) { + // Container doesn't exist yet, retry later + return false + } + + val nativeFragment = BrowserFragment.create() + val fm = fragmentActivity.supportFragmentManager + fm.beginTransaction() + .replace(FRAGMENT_CONTAINER_ID, nativeFragment) + .commitAllowingStateLoss() + + return true + } + override fun onTrimMemory(level: Long) { requireNotNull(GlobalComponents.components) { "Components not initialized" } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt index e6a89ff6..a472a272 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt @@ -1,7 +1,10 @@ package eu.lensai.flutter_mozilla_components.api +import eu.lensai.flutter_mozilla_components.feature.BrowserExtensionFeature import eu.lensai.flutter_mozilla_components.feature.ContainerProxyFeature +import eu.lensai.flutter_mozilla_components.feature.ResultConsumer import eu.lensai.flutter_mozilla_components.pigeons.GeckoContainerProxyApi +import org.json.JSONObject class GeckoContainerProxyApiImpl : GeckoContainerProxyApi { override fun setProxyPort(port: Long) { @@ -15,4 +18,18 @@ class GeckoContainerProxyApiImpl : GeckoContainerProxyApi { override fun removeContainerProxy(contextId: String) { ContainerProxyFeature.scheduleRequest("removeContainerProxy", contextId) } + + override fun healthcheck(callback: (Result) -> Unit) { + ContainerProxyFeature.scheduleRequestWithResponse("healthcheck", Unit, object : + ResultConsumer { + override fun success(result: JSONObject) { + val resultStatus = result.getBoolean("result") + callback(Result.success(resultStatus)) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } } \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/ContainerProxyFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/ContainerProxyFeature.kt index 85e354d8..85a80725 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/ContainerProxyFeature.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/feature/ContainerProxyFeature.kt @@ -20,6 +20,10 @@ object ContainerProxyFeature { private const val CONTAINER_PROXY_REPORTER_EXTENSION_URL = "resource://android/assets/extensions/container_proxy/" private const val CONTAINER_PROXY_REPORTER_MESSAGING_ID = "containerProxy" + private var nextRequestId: Int = 0 + private val requestHandlers = HashMap>() + private val mutex = Mutex() + @VisibleForTesting // This is an internal var to make it mutable for unit testing purposes only internal var extensionController = WebExtensionController( @@ -40,7 +44,55 @@ object ContainerProxyFeature { } } + fun scheduleRequestWithResponse( + command: String, + args: Any, + callback: ResultConsumer + ) { + val message = JSONObject() + message.put("action", command); + message.put("args", args) + + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + message.put("id", nextRequestId) + + requestHandlers[nextRequestId] = callback + + nextRequestId += 1 + + extensionController.sendBackgroundMessage(message) + } + } + } + } + private class ContainerProxyBackgroundMessageHandler() : MessageHandler { + override fun onPortMessage(message: Any, port: Port) { + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + val messageJSON = message as JSONObject; + val type = messageJSON.getString("type") + + if (type == "healthcheck") { + val requestId = messageJSON.getInt("id") + val status = messageJSON.getString("status") + if (status == "success") { + requestHandlers[requestId]?.success(message) + } else { + requestHandlers[requestId]?.error( + "Container Proxy", + "Failed to perform operation", + message.getString("error") + ) + } + } + } + } + } + } } /** diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt index 34b710ad..4a82da8a 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt @@ -50,7 +50,7 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd ) { when (action) { is ContentAction.UpdateThumbnailAction -> { - val resized = action.thumbnail.resize(maxWidth = 640, maxHeight = 480); + val resized = action.thumbnail.resize(maxWidth = 1280, maxHeight = 800); val bytes = resized.toWebPBytes() runOnUiThread { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt index a2331697..052c7c67 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -1994,6 +1994,7 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoBrowserApi { + fun initialize() fun showNativeFragment(): Boolean fun onTrimMemory(level: Long) @@ -2006,6 +2007,22 @@ interface GeckoBrowserApi { @JvmOverloads fun setUp(binaryMessenger: BinaryMessenger, api: GeckoBrowserApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.initialize$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.initialize() + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.showNativeFragment$separatedMessageChannelSuffix", codec) if (api != null) { @@ -3019,6 +3036,7 @@ interface GeckoContainerProxyApi { fun setProxyPort(port: Long) fun addContainerProxy(contextId: String) fun removeContainerProxy(contextId: String) + fun healthcheck(callback: (Result) -> Unit) companion object { /** The codec used by GeckoContainerProxyApi. */ @@ -3083,6 +3101,24 @@ interface GeckoContainerProxyApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.healthcheck{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts b/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts index e4b9c89a..6fb845cd 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts @@ -7,7 +7,8 @@ console.log('Background script started') const store = new Store() interface Message { - action: 'setProxyPort' | 'addContainerProxy' | 'removeContainerProxy'; + id: String | undefined; + action: 'setProxyPort' | 'addContainerProxy' | 'removeContainerProxy' | 'healthcheck'; args: any; } @@ -35,8 +36,14 @@ port.onMessage.addListener((raw: unknown): void => { case "removeContainerProxy": store.removeContainerProxyRelation(message.args, "tor") break - - + case "healthcheck": + port.postMessage({ + "type": "healthcheck", + "id": message.id, + "status": "success", + "result": true + }); + break } }); diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index 4d3bc798..44f48053 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -3,6 +3,7 @@ export 'src/data/models/load_url_flags.dart'; export 'src/data/models/source.dart'; export 'src/domain/entities/default_selection_actions.dart'; export 'src/domain/services/gecko_addon.dart'; +export 'src/domain/services/gecko_browser.dart'; export 'src/domain/services/gecko_browser_extension.dart'; export 'src/domain/services/gecko_container_proxy.dart'; export 'src/domain/services/gecko_cookie.dart'; diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart index 2db71825..c0452c91 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_browser.dart @@ -7,6 +7,10 @@ class GeckoBrowserService { GeckoBrowserService({GeckoBrowserApi? api}) : _api = api ?? _apiInstance; + Future initialize() { + return _api.initialize(); + } + Future showNativeFragment() { return _api.showNativeFragment(); } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart index 69c567d0..05476c4b 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart @@ -14,4 +14,12 @@ class GeckoContainerProxyService { Future removeContainerProxy(String contextId) { return _apiInstance.removeContainerProxy(contextId); } + + Future healthcheck() { + try { + return _apiInstance.healthcheck(); + } catch (_) { + return Future.value(false); + } + } } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 7281d6a8..01e06e1e 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -1983,6 +1983,29 @@ class GeckoBrowserApi { final String pigeonVar_messageChannelSuffix; + Future initialize() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.initialize$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + Future showNativeFragment() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.showNativeFragment$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -3251,6 +3274,34 @@ class GeckoContainerProxyApi { return; } } + + Future healthcheck() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } } class GeckoCookieApi { diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index c3b16b82..5b8f8439 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -736,6 +736,7 @@ class ShareInternetResourceState { ) @HostApi() abstract class GeckoBrowserApi { + void initialize(); bool showNativeFragment(); void onTrimMemory(int level); } @@ -953,6 +954,9 @@ abstract class GeckoContainerProxyApi { void setProxyPort(int port); void addContainerProxy(String contextId); void removeContainerProxy(String contextId); + + @async + bool healthcheck(); } @HostApi()