/* * 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 . */ import 'dart:async'; import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/font_size_constants.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/translation_bottom_sheet.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/gestures/data/models/gesture_action.dart'; import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; import 'package:weblibre/features/gestures/data/models/gesture_stroke.dart'; import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/utils/exit_app.dart'; import 'package:weblibre/utils/host_rules.dart'; import 'package:weblibre/utils/move_to_background.dart'; part 'gesture_control.g.dart'; /// Bridges gesture settings and recognized-gesture events to app actions. /// /// On build it (1) keeps the native recognizer's [GestureConfig] in sync with /// the user's [GestureSettings], and (2) subscribes to recognized gestures and /// dispatches the bound [GestureAction] against the currently selected tab. /// /// Must be kept alive (eagerly listened to from the browser view) for the /// lifetime of the browser so the subscription stays active. @Riverpod(keepAlive: true) class GestureControlService extends _$GestureControlService { /// Timestamp (ms since epoch) of the last fired gesture, for cooldown. int _lastDispatchMs = 0; @override void build() { final service = ref.read(gestureServiceProvider); // Keep the native recognizer in sync with the effective configuration // (settings folded together with the current site's exclusion state). ref.listen( fireImmediately: true, gestureNativeConfigProvider, (previous, next) async { await service.setGestureConfig(next); }, onError: (error, stackTrace) { logger.e( 'Error syncing gesture configuration', error: error, stackTrace: stackTrace, ); }, ); final subscription = service.recognizedGestures.listen( (gestureKey) { unawaited(_dispatch(gestureKey)); }, onError: (Object error, StackTrace stackTrace) { logger.e( 'Error handling recognized gesture', error: error, stackTrace: stackTrace, ); }, ); ref.onDispose(subscription.cancel); } Future _dispatch(String gestureKey) async { final settings = ref.read(gestureSettingsWithDefaultsProvider); final action = settings.bindings[gestureKey]; if (action == null) return; // Cooldown: ignore gestures fired within intervalMs of the previous one. final now = DateTime.now().millisecondsSinceEpoch; if (settings.intervalMs > 0 && now - _lastDispatchMs < settings.intervalMs) { return; } _lastDispatchMs = now; final tabId = ref.read(selectedTabProvider); if (tabId == null) return; try { // Feedback is handled live by the gesture overlay while the stroke is // drawn (driven by the native progress events), so nothing to show here. await _execute(action, tabId); } catch (error, stackTrace) { logger.e( 'Error executing gesture action ${action.name}', error: error, stackTrace: stackTrace, ); } } Future _execute(GestureAction action, String tabId) async { final tabRepository = ref.read(tabRepositoryProvider.notifier); switch (action) { case GestureAction.back: await ref.read(tabSessionProvider(tabId: tabId).notifier).goBack(); case GestureAction.forward: await ref.read(tabSessionProvider(tabId: tabId).notifier).goForward(); case GestureAction.reload: await ref.read(tabSessionProvider(tabId: tabId).notifier).reload(); case GestureAction.scrollTop: await ref.read(tabSessionProvider(tabId: tabId).notifier).scrollToTop(); case GestureAction.scrollBottom: await ref .read(tabSessionProvider(tabId: tabId).notifier) .scrollToBottom(); case GestureAction.pageUp: await ref.read(tabSessionProvider(tabId: tabId).notifier).pageUp(); case GestureAction.pageDown: await ref.read(tabSessionProvider(tabId: tabId).notifier).pageDown(); case GestureAction.newTab: await _openNewTab(tabId); case GestureAction.closeTab: await tabRepository.closeTab(tabId); case GestureAction.duplicateTab: final containerData = await ref .read(tabDataRepositoryProvider.notifier) .getTabContainerData(tabId); await tabRepository.duplicateTab( selectTabId: tabId, containerData: containerData, selectTab: true, ); case GestureAction.nextTab: await tabRepository.selectNextTab(tabId); case GestureAction.previousTab: await tabRepository.selectPreviousTab(tabId); case GestureAction.lastUsedTab: await tabRepository.selectPreviouslyOpenedTab(tabId); case GestureAction.togglePinTab: final pinned = ref.read(watchPinnedTabIdsProvider).value?.contains(tabId) ?? false; await ref .read(tabDataRepositoryProvider.notifier) .setPinned(tabId, pinned: !pinned); case GestureAction.toggleReaderMode: final readerActive = ref.read(tabStateProvider(tabId))?.readerableState.active ?? false; await ref .read(readerableScreenControllerProvider.notifier) .toggleReaderView(!readerActive); case GestureAction.toggleDesktopMode: ref.read(desktopModeProvider(tabId).notifier).toggle(); case GestureAction.findInPage: ref.read(findInPageControllerProvider(tabId).notifier).show(); case GestureAction.increaseFontSize: await _adjustFontSize(increase: true); case GestureAction.decreaseFontSize: await _adjustFontSize(increase: false); case GestureAction.showHome: ref.read(forceBrowserHomeProvider.notifier).request(); case GestureAction.toggleTabBar: final controller = ref.read( toolbarVisibilityControllerProvider(tabId).notifier, ); // Dismissing is the only state the user cannot leave by scrolling, so // the same gesture has to bring the bar back. if (ref.read(toolbarVisibilityControllerProvider(tabId)) == ToolbarVisibility.dismissed) { controller.forceShow(); } else { controller.dismiss(); } case GestureAction.showHistory: await _pushLocation(const HistoryRoute().location); case GestureAction.showBookmarks: await _pushLocation( BookmarkListRoute(entryGuid: BookmarkRoot.root.id).location, ); case GestureAction.toggleBookmark: await _toggleBookmark(tabId); case GestureAction.translatePage: final context = await _navigatorContext(); if (context != null && context.mounted) { await showTranslationBottomSheet(context, selectedTabId: tabId); } case GestureAction.moveToBackground: await moveToBackground(); case GestureAction.quitBrowser: final context = await _navigatorContext(); if (context != null && context.mounted) { final confirmed = await showQuitBrowserDialog(context); if (confirmed == true && context.mounted) { await exitApp(ProviderScope.containerOf(context, listen: false)); } } } } /// Adds the current page to bookmarks, or removes it if already bookmarked, /// mirroring the contextual toolbar's bookmark toggle button. Future _toggleBookmark(String tabId) async { final tabState = ref.read(tabStateProvider(tabId)); if (tabState == null) return; final bookmarkUrl = ref.read(sandboxSourceUriForTabProvider(tabId: tabId)) ?? tabState.url; final repository = ref.read(bookmarksRepositoryProvider.notifier); final existingGuids = await repository.bookmarkGuidsForUrl(bookmarkUrl); if (existingGuids.isNotEmpty) { for (final guid in existingGuids) { await repository.delete(guid); } } else { await repository.addBookmark( parentGuid: BookmarkRoot.mobile.id, url: bookmarkUrl, title: tabState.titleOrAuthority, ); } } /// Resolves the root navigator's [BuildContext] for actions that open a /// dialog/sheet. Null when the navigator is not currently mounted. Future _navigatorContext() async { final router = await ref.read(routerProvider.future); if (!ref.mounted) return null; return router.routerDelegate.navigatorKey.currentContext; } Future _pushLocation(String location) async { final router = await ref.read(routerProvider.future); if (!ref.mounted) return; await router.push(location); } Future _adjustFontSize({required bool increase}) async { final settings = ref.read(engineSettingsWithDefaultsProvider); // Manual adjustment is a no-op while automatic font sizing is enabled. if (settings.automaticFontSizeAdjustment) return; final current = settings.fontSizeFactor; final newValue = increase ? (current + fontSizeStep).clamp(fontSizeMin, fontSizeMax) : (current - fontSizeStep).clamp(fontSizeMin, fontSizeMax); final rounded = (newValue * 10).round() / 10; if (rounded == current) return; await ref .read(saveEngineSettingsControllerProvider.notifier) .save( (currentSettings) => currentSettings.copyWith.fontSizeFactor(rounded), ); } Future _openNewTab(String tabId) async { final router = await ref.read(routerProvider.future); if (!ref.mounted) return; final settings = ref.read(generalSettingsWithDefaultsProvider); final selectedTabType = ref .read(tabStatesProvider)[tabId] ?.tabMode .toTabType(); final route = SearchRoute( tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType, ); await router.push(route.location); } } /// Whether gestures are currently disabled because the selected tab's site is /// on the user's exclusion list. @Riverpod(keepAlive: true) bool gestureSiteExcluded(Ref ref) { final excludedSites = ref.watch( gestureSettingsWithDefaultsProvider.select((s) => s.excludedSites), ); if (excludedSites.isEmpty) return false; final tabId = ref.watch(selectedTabProvider); if (tabId == null) return false; final url = ref.watch(tabStateProvider(tabId).select((state) => state?.url)); if (url == null) return false; return hostMatchesRule(url, excludedSites); } /// The effective native recognizer configuration: the user's settings with the /// recognizer disabled while the current site is excluded. @Riverpod(keepAlive: true) GestureConfig gestureNativeConfig(Ref ref) { final settings = ref.watch(gestureSettingsWithDefaultsProvider); final excluded = ref.watch(gestureSiteExcludedProvider); // Recognize as many fingers as any bound gesture requires, so multi-finger // bindings work without a separate finger setting. final requiredFingers = settings.bindings.keys .map((key) => GestureStroke.fromKey(key).fingers) .fold(settings.maxFingers, max); return GestureConfig( enabled: settings.effectiveEnabled && !excluded, strokeSize: settings.strokeSize, timeoutMs: settings.timeoutMs, maxFingers: requiredFingers, minStrokeIntervalMs: settings.minStrokeIntervalMs, activeGestureKeys: settings.bindings.keys.toList(), ); } /// The in-progress stroke shown by the live feedback overlay. /// /// Emits the current partial canonical key (e.g. `R:D`) while a stroke is being /// drawn, and null when nothing should be shown — driven by the native gesture /// progress/reset events. @riverpod Stream gestureProgress(Ref ref) { return ref.watch(gestureServiceProvider).gestureProgress; }