From 5bdc3c60271b93e60561bd1e05b7724dacb5a008 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sat, 21 Feb 2026 08:29:05 +0100 Subject: [PATCH] sync initial --- app/android/app/src/main/AndroidManifest.xml | 13 + .../kotlin/eu/weblibre/gecko/MyApplication.kt | 17 + app/lib/core/providers/format.dart | 2 +- app/lib/core/providers/format.g.dart | 2 +- app/lib/core/routing/routes.dart | 1 + app/lib/core/routing/routes.g.dart | 26 + app/lib/core/routing/routes.settings.dart | 8 + .../browser/presentation/screens/browser.dart | 56 +- .../presentation/screens/tab_view.dart | 63 +- .../browser_modules/bottom_app_bar.dart | 1 + .../browser_modules/navigation_drawer.dart | 80 +- .../widgets/menu_item_buttons.dart | 97 ++ .../presentation/widgets/tab_menu.dart | 1 + .../widgets/tab_view/tab_list_view.dart | 61 +- .../widgets/tab_view/tab_preview.dart | 46 + .../widgets/tab_view/tab_view_header.dart | 451 ++++--- .../widgets/search_modules/feed_search.dart | 2 +- .../tabs/data/database/daos/container.dart | 2 +- .../tabs/domain/repositories/container.dart | 3 +- .../tabs/domain/repositories/container.g.dart | 2 +- .../presentation/widgets/container_chips.dart | 60 +- .../presentation/screens/settings.dart | 26 + .../entities/sync_repository_state.dart | 34 + .../entities/sync_repository_state.g.dart | 122 ++ .../sync/domain/entities/synced_tab_item.dart | 17 + .../sync/domain/repositories/sync.dart | 411 ++++++ .../sync/domain/repositories/sync.g.dart | 560 ++++++++ .../presentation/screens/sync_settings.dart | 463 +++++++ .../user/data/models/general_settings.dart | 12 +- .../user/data/models/general_settings.g.dart | 32 + .../screens/profile_backup_list.dart | 4 +- .../domain/repositories/general_settings.dart | 8 + .../repositories/general_settings.g.dart | 2 +- .../presentation/screens/feed_article.dart | 4 +- app/lib/main.dart | 6 + app/lib/presentation/main_app.dart | 44 + app/lib/utils/ui_helper.dart | 59 +- .../android/build.gradle | 7 + .../ActiveProfile.kt | 54 + .../BaseBrowserFragment.kt | 16 + .../flutter_mozilla_components/Components.kt | 32 +- .../GlobalComponents.kt | 34 +- .../MegazordSetup.kt | 32 + .../ProfileContext.kt | 1 + .../activities/AuthCustomTabActivity.kt | 30 + .../activities/AuthIntentReceiverActivity.kt | 54 + .../activities/ExternalAppBrowserActivity.kt | 2 +- .../api/GeckoBrowserApiImpl.kt | 27 +- .../api/GeckoSyncApiImpl.kt | 346 +++++ .../components/BackgroundServices.kt | 315 +++++ .../components/Core.kt | 12 + .../components/FxaServer.kt | 30 + .../components/Services.kt | 43 + .../components/WebLibreFxAEntryPoint.kt | 7 + .../interceptor/AppRequestInterceptor.kt | 15 +- .../pigeons/Gecko.g.kt | 946 ++++++++++++-- .../sync/SyncedTabsIntegration.kt | 35 + .../lib/flutter_mozilla_components.dart | 8 + .../src/domain/services/gecko_browser.dart | 4 + .../lib/src/domain/services/gecko_sync.dart | 120 ++ .../lib/src/pigeons/gecko.g.dart | 1135 +++++++++++++++-- .../pigeons/gecko.dart | 140 ++ 62 files changed, 5724 insertions(+), 519 deletions(-) create mode 100644 app/lib/features/sync/domain/entities/sync_repository_state.dart create mode 100644 app/lib/features/sync/domain/entities/sync_repository_state.g.dart create mode 100644 app/lib/features/sync/domain/entities/synced_tab_item.dart create mode 100644 app/lib/features/sync/domain/repositories/sync.dart create mode 100644 app/lib/features/sync/domain/repositories/sync.g.dart create mode 100644 app/lib/features/sync/presentation/screens/sync_settings.dart create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfile.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/MegazordSetup.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/AuthCustomTabActivity.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/AuthIntentReceiverActivity.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSyncApiImpl.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/BackgroundServices.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/FxaServer.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/WebLibreFxAEntryPoint.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/sync/SyncedTabsIntegration.kt create mode 100644 packages/flutter_mozilla_components/lib/src/domain/services/gecko_sync.dart diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 89b79531..100753b3 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -179,6 +179,19 @@ android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize|stateAlwaysHidden" /> + + + + { Format create() => Format(); } -String _$formatHash() => r'fe7fcdd19b512784a36f3838c57701030475e429'; +String _$formatHash() => r'f132aabb20bf4df77d771d6c866bc413c00bd9ff'; abstract class _$Format extends $AsyncNotifier { FutureOr build(); diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 03c27cda..11299079 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -70,6 +70,7 @@ import 'package:weblibre/features/settings/presentation/screens/tabs_behavior_se import 'package:weblibre/features/settings/presentation/screens/tracking_protection_exceptions.dart'; import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening.dart'; import 'package:weblibre/features/settings/presentation/screens/web_engine_hardening_group.dart'; +import 'package:weblibre/features/sync/presentation/screens/sync_settings.dart'; import 'package:weblibre/features/tor/presentation/screens/country_picker.dart'; import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/select_profile.dart'; diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 044e2583..7e4dcd7f 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -1427,6 +1427,11 @@ RouteBase get $settingsRoute => GoRouteData.$route( name: 'ErrorLogsRoute', factory: $ErrorLogsRoute._fromState, ), + GoRouteData.$route( + path: 'sync', + name: 'SyncSettingsRoute', + factory: $SyncSettingsRoute._fromState, + ), ], ); @@ -1791,6 +1796,27 @@ mixin $ErrorLogsRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $SyncSettingsRoute on GoRouteData { + static SyncSettingsRoute _fromState(GoRouterState state) => + SyncSettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings/sync'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $torProxyRoute => GoRouteData.$route( path: '/tor', name: 'TorProxyRoute', diff --git a/app/lib/core/routing/routes.settings.dart b/app/lib/core/routing/routes.settings.dart index 92b1b984..d335ffa4 100644 --- a/app/lib/core/routing/routes.settings.dart +++ b/app/lib/core/routing/routes.settings.dart @@ -80,6 +80,7 @@ part of 'routes.dart'; path: 'custom_tracking_protection', ), TypedGoRoute(name: 'ErrorLogsRoute', path: 'error_logs'), + TypedGoRoute(name: 'SyncSettingsRoute', path: 'sync'), ], ) class SettingsRoute extends GoRouteData with $SettingsRoute { @@ -214,3 +215,10 @@ class CustomTrackingProtectionRoute extends GoRouteData return const CustomTrackingProtectionScreen(); } } + +class SyncSettingsRoute extends GoRouteData with $SyncSettingsRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const SyncSettingsScreen(); + } +} 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 fa6bc491..9c46e3c6 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -39,8 +39,8 @@ 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/browser/domain/entities/sheet.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/keep_tab_dialog.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart'; @@ -55,6 +55,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_ import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/move_to_background.dart'; @@ -303,6 +304,44 @@ class BrowserScreen extends HookConsumerWidget { }, ); + useOnAppLifecycleStateChange((previous, current) { + switch (current) { + case AppLifecycleState.resumed: + if (current != AppLifecycleState.resumed) { + return; + } + + if (!ref.read(syncIsAuthenticatedProvider)) { + return; + } + + unawaited(() async { + try { + final openedTabs = await ref + .read(syncRepositoryProvider.notifier) + .pollIncomingTabsAndOpen(); + + if (openedTabs > 0 && context.mounted) { + ui_helper.showOpenedTabsFromAnotherDeviceMessage( + context, + openedTabs, + ); + } + } catch (e, s) { + logger.e( + 'Failed polling incoming sync tabs on resume', + error: e, + stackTrace: s, + ); + } + }()); + case AppLifecycleState.detached: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + } + }); + final pointerMoveEventsController = useStreamController(); // Watch sheet state for rendering in Stack @@ -317,8 +356,7 @@ class BrowserScreen extends HookConsumerWidget { // Toolbar is visible when: sheet is shown OR (not fullscreen AND controller says visible) // The controller handles loading-start show internally via ref.listen on isLoading - final effectiveAppBarVisible = - toolbarState == ToolbarVisibility.visible; + final effectiveAppBarVisible = toolbarState == ToolbarVisibility.visible; final topToolbarVisible = sheetDisplayed || (!tabInFullScreen && effectiveAppBarVisible); final bottomToolbarVisible = @@ -1020,6 +1058,16 @@ class _ViewTabsSheet extends HookConsumerWidget { final tabsViewMode = ref.watch(tabsViewModeControllerProvider); final tabsReorderable = ref.watch(tabsReorderableControllerProvider); + final isSyncedScope = ref.watch( + effectiveTabsTrayScopeProvider.select( + (scope) => scope == TabsTrayScope.synced, + ), + ); + + final effectiveTabsViewMode = isSyncedScope + ? TabsViewMode.list + : tabsViewMode; + final draggableScrollableController = useDraggableScrollableController( keys: [tabsReorderable], ); @@ -1037,7 +1085,7 @@ class _ViewTabsSheet extends HookConsumerWidget { topRight: Radius.circular(28), ), clipBehavior: Clip.antiAlias, - child: switch (tabsViewMode) { + child: switch (effectiveTabsViewMode) { TabsViewMode.list => ViewTabListWidget( scrollController: scrollController, showNewTabFab: true, diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart b/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart index 350302f8..bfea791b 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart @@ -26,6 +26,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/scroll_visibility.dart'; @@ -37,6 +38,16 @@ class TabViewScreen extends HookConsumerWidget { final tabsViewMode = ref.watch(tabsViewModeControllerProvider); final tabsReorderable = ref.watch(tabsReorderableControllerProvider); + final isSyncedScope = ref.watch( + effectiveTabsTrayScopeProvider.select( + (scope) => scope == TabsTrayScope.synced, + ), + ); + + final effectiveTabsViewMode = isSyncedScope + ? TabsViewMode.list + : tabsViewMode; + final scrollController = useScrollController(keys: [tabsReorderable]); // Track FAB visibility based on scroll direction @@ -45,7 +56,7 @@ class TabViewScreen extends HookConsumerWidget { return Dialog.fullscreen( child: Scaffold( body: SafeArea( - child: switch (tabsViewMode) { + child: switch (effectiveTabsViewMode) { TabsViewMode.list => ViewTabListWidget( key: ValueKey(tabsReorderable), scrollController: scrollController, @@ -73,31 +84,35 @@ class TabViewScreen extends HookConsumerWidget { ), }, ), - floatingActionButton: AnimatedSlide( - duration: const Duration(milliseconds: 200), - offset: isFabVisible.value ? Offset.zero : const Offset(0, 2), - curve: Curves.easeInOut, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: isFabVisible.value ? 1.0 : 0.0, - child: FloatingActionButton( - onPressed: () async { - final settings = ref.read(generalSettingsWithDefaultsProvider); + floatingActionButton: isSyncedScope + ? null + : AnimatedSlide( + duration: const Duration(milliseconds: 200), + offset: isFabVisible.value ? Offset.zero : const Offset(0, 2), + curve: Curves.easeInOut, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: isFabVisible.value ? 1.0 : 0.0, + child: FloatingActionButton( + onPressed: () async { + final settings = ref.read( + generalSettingsWithDefaultsProvider, + ); - await SearchRoute( - tabType: - ref.read(selectedTabTypeProvider) ?? - settings.defaultCreateTabType, - ).push(context); + await SearchRoute( + tabType: + ref.read(selectedTabTypeProvider) ?? + settings.defaultCreateTabType, + ).push(context); - if (context.mounted) { - const BrowserRoute().go(context); - } - }, - child: const Icon(Icons.add), - ), - ), - ), + if (context.mounted) { + const BrowserRoute().go(context); + } + }, + child: const Icon(Icons.add), + ), + ), + ), ), ); } 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 f04682af..ca393331 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 @@ -670,6 +670,7 @@ class ShareMenuButton extends HookConsumerWidget { OpenInAppMenuItemButton(selectedTabId: selectedTabId), ShareScreenshotMenuItemButton(selectedTabId: selectedTabId), ShareMenuItemButton(selectedTabId: selectedTabId), + SendTabToDeviceMenuItemButton(selectedTabId: selectedTabId), ShowQrCodeMenuItemButton(selectedTabId: selectedTabId), ], builder: (context, controller, child) { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart index d457b147..5066b511 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart @@ -18,6 +18,8 @@ * along with this program. If not, see . */ +import 'dart:async'; + import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -31,12 +33,15 @@ import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart'; +import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart'; import 'package:weblibre/features/user/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/icons/tor_icons.dart'; import 'package:weblibre/utils/exit_app.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; /// Navigation drawer for the browser screen. /// Contains all navigation destinations and settings. @@ -52,7 +57,7 @@ class BrowserNavigationDrawer extends HookConsumerWidget { children: [ // Profile Header _ProfileHeader(), - + _SyncTile(), const Divider(), // Section 1: Tools & Configuration @@ -327,3 +332,76 @@ class _ExtensionsSection extends HookConsumerWidget { ); } } + +/// Sync tile widget shown in navigation drawer when sync is active. +/// Displays sync status and allows manual sync trigger. +class _SyncTile extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final isAuthenticated = ref.watch(syncIsAuthenticatedProvider); + + // Only show if sync is active + if (!isAuthenticated) { + return const SizedBox.shrink(); + } + + final syncInfo = ref.watch( + syncRepositoryProvider.select((value) => value.value?.account), + ); + + final syncStarted = ref.watch( + syncEventProvider.select( + (value) => value.isLoading || value.value?.$1 == SyncEvent.started, + ), + ); + final isSyncing = syncStarted || syncInfo?.syncing == true; + + final controller = useAnimationController( + duration: const Duration(seconds: 2), + ); + + useEffect(() { + if (isSyncing) { + unawaited(controller.repeat()); + } else { + controller.stop(); + controller.reset(); + } + return null; + }, [isSyncing]); + + return ListTile( + leading: RotationTransition( + turns: Tween(begin: 0, end: -1).animate(controller), + child: const Icon(Icons.sync), + ), + title: const Text('Sync Now'), + onTap: () async { + await ref.read(syncRepositoryProvider.notifier).syncNow(); + + final openedTabs = await ref + .read(syncRepositoryProvider.notifier) + .pollIncomingTabsAndOpen(); + + if (context.mounted) { + if (openedTabs > 0) { + ui_helper.showOpenedTabsFromAnotherDeviceMessage( + context, + openedTabs, + ); + } else { + ui_helper.showInfoMessage( + context, + 'Synchronization complete', + duration: const Duration(seconds: 2), + ); + } + } + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart index 1ac7a31e..e92b860e 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart @@ -26,13 +26,16 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:skeletonizer/skeletonizer.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/features/browser/presentation/dialogs/content_selection_dialog.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class ShareMenuItemButton extends HookConsumerWidget { const ShareMenuItemButton({super.key, required this.selectedTabId}); @@ -287,3 +290,97 @@ class CopyAddressMenuItemButton extends HookConsumerWidget { ); } } + +class SendTabToDeviceMenuItemButton extends HookConsumerWidget { + final String? selectedTabId; + + const SendTabToDeviceMenuItemButton({super.key, required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (selectedTabId == null) { + return const SizedBox.shrink(); + } + + final isAuthenticated = ref.watch(syncIsAuthenticatedProvider); + final devices = ref.watch(syncDevicesProvider); + + if (!isAuthenticated) { + return const SizedBox.shrink(); + } + + return Skeletonizer( + enabled: devices.isLoading && devices.value == null, + child: SubmenuButton( + leadingIcon: const Icon(Icons.send_outlined), + menuChildren: devices.when( + data: (deviceList) { + final targets = deviceList + .where((device) => !device.isCurrentDevice && device.canSendTab) + .toList(growable: false); + + if (targets.isEmpty) { + return const [MenuItemButton(child: Text('No target devices'))]; + } + + return targets + .map((device) { + return MenuItemButton( + closeOnActivate: false, + leadingIcon: const Icon(Icons.devices_other), + child: Text(device.displayName), + onPressed: () async { + final tabState = ref.read( + tabStateProvider(selectedTabId), + ); + if (tabState == null) { + return; + } + + final title = tabState.title.isNotEmpty + ? tabState.title + : tabState.url.toString(); + + final success = await ref + .read(syncRepositoryProvider.notifier) + .sendTabToDevice( + deviceId: device.deviceId, + title: title, + url: tabState.url.toString(), + ); + + if (context.mounted) { + if (success) { + ui_helper.showInfoMessage( + context, + 'Sent tab to ${device.displayName}', + ); + } else { + ui_helper.showErrorMessage( + context, + 'Failed to send tab', + ); + } + + MenuController.maybeOf(context)?.close(); + } + }, + ); + }) + .toList(growable: false); + }, + loading: () => const [ + MenuItemButton( + leadingIcon: Icon(Icons.devices_other), + child: Text('Loading devices...'), + ), + ], + error: (_, _) => const [ + MenuItemButton(child: Text('Failed to load devices')), + ], + ), + child: const Text('Send To Device'), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index b2587694..083123a3 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -475,6 +475,7 @@ class TabMenu extends HookConsumerWidget { OpenInAppMenuItemButton(selectedTabId: selectedTabId), ShareScreenshotMenuItemButton(selectedTabId: selectedTabId), ShareMenuItemButton(selectedTabId: selectedTabId), + SendTabToDeviceMenuItemButton(selectedTabId: selectedTabId), ShowQrCodeMenuItemButton(selectedTabId: selectedTabId), ], leadingIcon: const Icon(Icons.share), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart index 5ca974d8..662c877f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart @@ -44,6 +44,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; class _TabDraggable extends HookConsumerWidget { @@ -149,8 +150,58 @@ class _TabListView extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // final screenWidth = MediaQuery.of(context).size.width; + final scope = ref.watch(effectiveTabsTrayScopeProvider); + return switch (scope) { + TabsTrayScope.synced => _buildSyncedTabsView(context, ref), + _ => _buildLocalTabsView(context, ref), + }; + } + + Widget _buildSyncedTabsView(BuildContext context, WidgetRef ref) { + final syncedTabs = ref.watch(syncedTabsForSelectedDeviceProvider); + + return syncedTabs.when( + skipLoadingOnReload: true, + data: (tabs) { + if (tabs.isEmpty) { + return const Center(child: Text('No synced tabs available')); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: ListView.builder( + controller: scrollController, + itemCount: tabs.length, + itemBuilder: (context, index) { + final tab = tabs[index].tab; + final uri = Uri.tryParse(tab.url); + + if (uri == null) { + return const SizedBox.shrink(); + } + + return SyncedListTabPreview( + title: tab.title.isNotEmpty ? tab.title : tab.url, + url: uri, + deviceName: tabs[index].deviceName, + onTap: () async { + await OpenSharedContentRoute( + sharedUrl: uri.toString(), + ).push(context); + }, + ); + }, + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => + Center(child: Text('Failed to load synced tabs: $error')), + ); + } + + Widget _buildLocalTabsView(BuildContext context, WidgetRef ref) { final containerId = ref.watch(selectedContainerProvider); final filteredTabEntities = ref.watch( @@ -417,6 +468,12 @@ class ViewTabListWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isSyncedScope = ref.watch( + effectiveTabsTrayScopeProvider.select( + (scope) => scope == TabsTrayScope.synced, + ), + ); + final isFabVisible = useState(true); final lastSheetSize = useRef(0.0); final isInitialized = useRef(false); @@ -516,7 +573,7 @@ class ViewTabListWidget extends HookConsumerWidget { onClose: onClose, ), ), - if (showNewTabFab) + if (showNewTabFab && !isSyncedScope) AnimatedSlide( duration: const Duration(milliseconds: 200), offset: isFabVisible.value ? Offset.zero : const Offset(0, 2), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index a8fb159a..36ffe269 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -36,6 +36,7 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/safe_raw_image.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class GridTabItemContainer extends StatelessWidget { @@ -378,6 +379,51 @@ class ListTabPreview extends HookConsumerWidget { } } +class SyncedListTabPreview extends StatelessWidget { + const SyncedListTabPreview({ + super.key, + required this.title, + required this.url, + required this.deviceName, + required this.onTap, + }); + + final String title; + final Uri url; + final String deviceName; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: onTap, + contentPadding: const EdgeInsets.only(left: 8, right: 6), + leading: UrlIcon([url], iconSize: 20), + title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.devices, size: 14), + const SizedBox(width: 4), + Expanded( + child: Text( + deviceName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + UriBreadcrumb(uri: url), + ], + ), + trailing: const Icon(Icons.open_in_new), + ); + } +} + class SingleGridTabPreview extends HookConsumerWidget { final String tabId; final String? activeTabId; diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart index d540d12a..b6aeeb9d 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart @@ -43,6 +43,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -50,6 +51,151 @@ import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/speech_to_text_button.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; +/// Widget for tab filters (container chips with synced option) +class _TabFilters extends ConsumerWidget { + final TabsViewMode tabsViewMode; + + const _TabFilters({required this.tabsViewMode}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSyncedScope = ref.watch( + effectiveTabsTrayScopeProvider.select( + (scope) => scope == TabsTrayScope.synced, + ), + ); + + final selectedContainer = ref.watch( + selectedContainerDataProvider.select((value) => value.value), + ); + + final isAuthenticated = ref.watch(syncIsAuthenticatedProvider); + final syncedTabCountAsync = ref.watch(syncedTabsTotalCountProvider); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ContainerChips( + showGroupSuggestions: switch (tabsViewMode) { + TabsViewMode.list || TabsViewMode.grid => true, + TabsViewMode.tree => false, + }, + enableDragAndDrop: switch (tabsViewMode) { + TabsViewMode.list || TabsViewMode.grid => true, + TabsViewMode.tree => false, + }, + showSyncedChip: isAuthenticated && tabsViewMode != TabsViewMode.tree, + syncedChipSelected: isSyncedScope, + syncedTabCount: syncedTabCountAsync.when( + data: (count) => count, + loading: () => 0, + error: (_, _) => 0, + ), + onSyncedChipSelected: () { + ref.read(tabsTrayScopeControllerProvider.notifier).showSynced(); + }, + selectedContainer: selectedContainer, + onSelected: (container) async { + ref.read(tabsTrayScopeControllerProvider.notifier).showLocal(); + + if (container != null) { + final result = await ref + .read(selectedContainerProvider.notifier) + .setContainerId(container.id); + + if (context.mounted && + result == SetContainerResult.successHasProxy) { + final shouldStartProxy = await ref + .read(startProxyControllerProvider.notifier) + .shouldPromptProxyStart(); + + if (!context.mounted || !shouldStartProxy) return; + + final dialogResult = await showDialog( + context: context, + builder: (context) { + return const TorDialog(); + }, + ); + + if (dialogResult == true) { + await ref + .read(startProxyControllerProvider.notifier) + .startProxy(); + } + } + } else { + ref.read(selectedContainerProvider.notifier).clearContainer(); + } + }, + onDeleted: (container) { + ref.read(tabsTrayScopeControllerProvider.notifier).showLocal(); + ref.read(selectedContainerProvider.notifier).clearContainer(); + }, + onLongPress: (container) async { + await ContainerEditRoute( + containerData: jsonEncode(container.toJson()), + ).push(context); + }, + ), + if (isSyncedScope) ...[ + const SizedBox(height: 8), + _SyncedDeviceSelector(), + ], + ], + ); + } +} + +/// Widget for synced device selector +class _SyncedDeviceSelector extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final remoteDevicesAsync = ref.watch(syncRemoteTabsProvider); + final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider); + + return remoteDevicesAsync.when( + skipLoadingOnReload: true, + data: (devices) { + if (devices.isEmpty) { + return const SizedBox.shrink(); + } + + final effectiveSelectedDeviceId = + selectedDeviceId != null && + devices.any((device) => device.deviceId == selectedDeviceId) + ? selectedDeviceId + : devices.first.deviceId; + + return SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + children: devices + .map((device) { + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: ChoiceChip( + label: Text(device.deviceName), + selected: effectiveSelectedDeviceId == device.deviceId, + onSelected: (_) { + ref + .read(selectedSyncedTabsDeviceIdProvider.notifier) + .selectDevice(device.deviceId); + }, + ), + ); + }) + .toList(growable: false), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + } +} + class TabViewHeader extends HookConsumerWidget { static const headerSize = 124.0; @@ -82,6 +228,13 @@ class TabViewHeader extends HookConsumerWidget { ), ); + final selectedContainerId = ref.watch(selectedContainerProvider); + final isSyncedScope = ref.watch( + effectiveTabsTrayScopeProvider.select( + (scope) => scope == TabsTrayScope.synced, + ), + ); + useOnListenableChange(searchTextController, () async { if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) { await ref @@ -153,13 +306,15 @@ class TabViewHeader extends HookConsumerWidget { .toList(), child: IconButton( tooltip: 'Change view mode', - onPressed: () { - if (viewModeMenuController.isOpen) { - viewModeMenuController.close(); - } else { - viewModeMenuController.open(); - } - }, + onPressed: isSyncedScope + ? null + : () { + if (viewModeMenuController.isOpen) { + viewModeMenuController.close(); + } else { + viewModeMenuController.open(); + } + }, icon: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -278,122 +433,133 @@ class TabViewHeader extends HookConsumerWidget { menuChildren: [ MenuItemButton( leadingIcon: const Icon(MdiIcons.closeCircle), + onPressed: isSyncedScope + ? null + : () async { + final result = await showCloseAllTabsDialog( + context, + ); + + if (result == true) { + final count = await ref + .read( + tabDataRepositoryProvider.notifier, + ) + .closeContainerTabs( + selectedContainerId, + ); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read( + tabRepositoryProvider.notifier, + ) + .undoClose, + count: count.length, + ); + } + } + }, child: const Text('Close All Tabs'), - onPressed: () async { - final result = await showCloseAllTabsDialog( - context, - ); - - if (result == true) { - final container = ref.read( - selectedContainerProvider, - ); - - final count = await ref - .read(tabDataRepositoryProvider.notifier) - .closeContainerTabs(container); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref - .read(tabRepositoryProvider.notifier) - .undoClose, - count: count.length, - ); - } - } - }, ), MenuItemButton( leadingIcon: const Icon(MdiIcons.incognitoCircleOff), + onPressed: isSyncedScope + ? null + : () async { + final result = + await showCloseAllPrivateTabsDialog( + context, + ); + + if (result == true) { + final count = await ref + .read( + tabDataRepositoryProvider.notifier, + ) + .closeContainerTabs( + selectedContainerId, + includeRegular: false, + ); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read( + tabRepositoryProvider.notifier, + ) + .undoClose, + count: count.length, + ); + } + } + }, child: const Text('Close Private Tabs'), - onPressed: () async { - final result = await showCloseAllPrivateTabsDialog( - context, - ); - - if (result == true) { - final container = ref.read( - selectedContainerProvider, - ); - - final count = await ref - .read(tabDataRepositoryProvider.notifier) - .closeContainerTabs( - container, - includeRegular: false, - ); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref - .read(tabRepositoryProvider.notifier) - .undoClose, - count: count.length, - ); - } - } - }, ), const Divider(), MenuItemButton( leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline), - child: const Text('Bookmark all'), - onPressed: () async { - final choice = await showBookmarkAllDialog(context); - if (choice == null || !context.mounted) return; - - final containerId = ref.read( - selectedContainerProvider, - ); - - final tabData = await ref - .read(tabDataRepositoryProvider.notifier) - .getContainerTabsData(containerId); - - if (choice == BookmarkAllChoice.fast) { - if (!context.mounted) return; - final folderGuid = await showSelectFolderDialog( - context, - ); - if (folderGuid == null) return; - - final repo = ref.read( - bookmarksRepositoryProvider.notifier, - ); - for (final tab in tabData) { - if (tab.url != null) { - await repo.addBookmark( - parentGuid: folderGuid, - url: tab.url!, - title: tab.title ?? tab.url.toString(), + onPressed: isSyncedScope + ? null + : () async { + final choice = await showBookmarkAllDialog( + context, ); - } - } + if (choice == null || !context.mounted) { + return; + } - if (context.mounted) { - ui_helper.showInfoMessage( - context, - '${tabData.length} bookmark(s) added', - ); - } - } else { - for (final tab in tabData) { - if (context.mounted) { - await BookmarkEntryAddRoute( - bookmarkInfo: jsonEncode( - BookmarkInfo( - title: tab.title, - url: tab.url.toString(), - ).encode(), - ), - ).push(context); - } - } - } - }, + final tabData = await ref + .read(tabDataRepositoryProvider.notifier) + .getContainerTabsData( + selectedContainerId, + ); + + if (choice == BookmarkAllChoice.fast) { + if (!context.mounted) return; + final folderGuid = + await showSelectFolderDialog(context); + if (folderGuid == null) return; + + final repo = ref.read( + bookmarksRepositoryProvider.notifier, + ); + for (final tab in tabData) { + if (tab.url != null) { + await repo.addBookmark( + parentGuid: folderGuid, + url: tab.url!, + title: + tab.title ?? tab.url.toString(), + ); + } + } + + if (context.mounted) { + ui_helper.showInfoMessage( + context, + '${tabData.length} bookmark(s) added', + ); + } + } else { + for (final tab in tabData) { + if (context.mounted) { + await BookmarkEntryAddRoute( + bookmarkInfo: jsonEncode( + BookmarkInfo( + title: tab.title, + url: tab.url.toString(), + ).encode(), + ), + ).push(context); + } + } + } + }, + child: const Text('Bookmark all'), ), Consumer( builder: (context, ref, child) { @@ -582,66 +748,7 @@ class TabViewHeader extends HookConsumerWidget { ), Consumer( builder: (context, ref, child) { - final selectedContainer = ref.watch( - selectedContainerDataProvider.select( - (value) => value.value, - ), - ); - - return ContainerChips( - showGroupSuggestions: switch (tabsViewMode) { - TabsViewMode.list || TabsViewMode.grid => true, - TabsViewMode.tree => false, - }, - enableDragAndDrop: switch (tabsViewMode) { - TabsViewMode.list || TabsViewMode.grid => true, - TabsViewMode.tree => false, - }, - selectedContainer: selectedContainer, - onSelected: (container) async { - if (container != null) { - final result = await ref - .read(selectedContainerProvider.notifier) - .setContainerId(container.id); - - if (context.mounted && - result == SetContainerResult.successHasProxy) { - final shouldStartProxy = await ref - .read(startProxyControllerProvider.notifier) - .shouldPromptProxyStart(); - - if (!context.mounted || !shouldStartProxy) return; - - final dialogResult = await showDialog( - context: context, - builder: (context) { - return const TorDialog(); - }, - ); - - if (dialogResult == true) { - await ref - .read(startProxyControllerProvider.notifier) - .startProxy(); - } - } - } else { - ref - .read(selectedContainerProvider.notifier) - .clearContainer(); - } - }, - onDeleted: (container) { - ref - .read(selectedContainerProvider.notifier) - .clearContainer(); - }, - onLongPress: (container) async { - await ContainerEditRoute( - containerData: jsonEncode(container.toJson()), - ).push(context); - }, - ); + return _TabFilters(tabsViewMode: tabsViewMode); }, ), const SizedBox(height: 8), 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 6ac21320..15fc3c06 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 @@ -178,7 +178,7 @@ class FeedSearch extends HookConsumerWidget { child: Text( ref .read(formatProvider.notifier) - .fullDateTimeWithTimezone(articleDate), + .fullDateTime(articleDate), style: theme.textTheme.bodySmall?.copyWith( fontStyle: FontStyle.italic, ), 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 0d048aa8..58695e67 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 @@ -186,7 +186,7 @@ class ContainerDao extends DatabaseAccessor ); } - SingleSelectable siteAssignedContainerId(Uri uri) { + Selectable siteAssignedContainerId(Uri uri) { return db.definitionsDrift.siteAssignedContainerId(uri: uri.origin); } 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 7d8992e9..8adacfe7 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container.dart @@ -174,7 +174,8 @@ class ContainerRepository extends _$ContainerRepository { .read(tabDatabaseProvider) .containerDao .siteAssignedContainerId(uri) - .getSingle(); + .get() + .then((value) => value.firstOrNull); } Future> getContainersToClearOnExit() async { 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 355b8134..905edbac 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 @@ -42,7 +42,7 @@ final class ContainerRepositoryProvider } String _$containerRepositoryHash() => - r'62c2b06970db4385ea0d2edc68bcf076c291a9b6'; + r'5e46b6d9d3510aeeb70646ede75d71c2db9efb0f'; abstract class _$ContainerRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index 0285dbfb..623a6cdd 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -39,12 +39,12 @@ import 'package:weblibre/presentation/widgets/selectable_chips.dart'; class _UnassignedContainerChip extends ConsumerWidget { final int? Function()? containerBadgeCount; - final ContainerData? selectedContainer; + final bool selected; final void Function(ContainerDataWithCount?)? onSelected; const _UnassignedContainerChip({ required this.containerBadgeCount, - required this.selectedContainer, + required this.selected, required this.onSelected, }); @@ -65,7 +65,7 @@ class _UnassignedContainerChip extends ConsumerWidget { label: (tabCount > 0) ? Text(tabCount.toString()) : const SizedBox.shrink(), - selected: selectedContainer == null, + selected: selected, showCheckmark: false, onSelected: (value) { if (value) { @@ -76,6 +76,34 @@ class _UnassignedContainerChip extends ConsumerWidget { } } +class _SyncedTabsChip extends ConsumerWidget { + final bool selected; + final int count; + final VoidCallback onSelected; + + const _SyncedTabsChip({ + required this.selected, + required this.count, + required this.onSelected, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return FilterChip( + avatar: const Icon(Icons.devices_other), + labelPadding: count > 0 ? null : const EdgeInsets.only(right: 2.0), + label: count > 0 ? Text(count.toString()) : const SizedBox.shrink(), + selected: selected, + showCheckmark: false, + onSelected: (value) { + if (value) { + onSelected(); + } + }, + ); + } +} + class _ContainerSuggestionsChip extends ConsumerWidget { const _ContainerSuggestionsChip(); @@ -135,6 +163,11 @@ class ContainerChips extends HookConsumerWidget { final bool showUnassignedChip; final bool showGroupSuggestions; final bool enableDragAndDrop; + final bool showSyncedChip; + final bool syncedChipSelected; + final bool? unassignedChipSelected; + final int syncedTabCount; + final VoidCallback? onSyncedChipSelected; final ContainerData? selectedContainer; final bool Function(ContainerDataWithCount)? containerFilter; @@ -157,6 +190,11 @@ class ContainerChips extends HookConsumerWidget { this.showUnassignedChip = true, this.showGroupSuggestions = false, this.enableDragAndDrop = true, + this.showSyncedChip = false, + this.syncedChipSelected = false, + this.unassignedChipSelected, + this.syncedTabCount = 0, + this.onSyncedChipSelected, }); @override @@ -217,6 +255,12 @@ class ContainerChips extends HookConsumerWidget { } : null, prefixListItems: [ + if (showSyncedChip) + _SyncedTabsChip( + selected: syncedChipSelected, + count: syncedTabCount, + onSelected: onSyncedChipSelected ?? () {}, + ), if (showUnassignedChip) enableDragAndDrop ? TabDragContainerTarget( @@ -224,14 +268,20 @@ class ContainerChips extends HookConsumerWidget { child: _UnassignedContainerChip( containerBadgeCount: () => containerBadgeCount?.call(null), - selectedContainer: selectedContainer, + selected: + unassignedChipSelected ?? + (selectedContainer == null && + !syncedChipSelected), onSelected: onSelected, ), ) : _UnassignedContainerChip( containerBadgeCount: () => containerBadgeCount?.call(null), - selectedContainer: selectedContainer, + selected: + unassignedChipSelected ?? + (selectedContainer == null && + !syncedChipSelected), onSelected: onSelected, ), if (showGroupSuggestions) diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart index 10bc3e29..d5563a45 100644 --- a/app/lib/features/settings/presentation/screens/settings.dart +++ b/app/lib/features/settings/presentation/screens/settings.dart @@ -41,6 +41,7 @@ class SettingsScreen extends HookConsumerWidget { _PrivacySecurityTile(), _SearchContentTile(), _TabsBehaviorTile(), + _SyncTile(), _FingerprintingTile(), _AdvancedTile(), ], @@ -176,6 +177,31 @@ class _FingerprintingTile extends StatelessWidget { } } +class _SyncTile extends StatelessWidget { + const _SyncTile(); + + @override + Widget build(BuildContext context) { + return Card( + color: Theme.of(context).highlightColor, + clipBehavior: Clip.antiAlias, + child: ListTile( + title: const Text('Firefox Sync'), + subtitle: const Text('Account, sync now, engine selection'), + contentPadding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + leading: const Icon(Icons.sync), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await SyncSettingsRoute().push(context); + }, + ), + ); + } +} + class _AdvancedTile extends StatelessWidget { const _AdvancedTile(); diff --git a/app/lib/features/sync/domain/entities/sync_repository_state.dart b/app/lib/features/sync/domain/entities/sync_repository_state.dart new file mode 100644 index 00000000..7f86b17c --- /dev/null +++ b/app/lib/features/sync/domain/entities/sync_repository_state.dart @@ -0,0 +1,34 @@ +import 'package:copy_with_extension/copy_with_extension.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; + +part 'sync_repository_state.g.dart'; + +enum SyncEvent { + started, + completed, + error; +} + +@CopyWith() +class SyncRepositoryState with FastEquatable { + final SyncAccountInfo account; + final List remoteTabs; + final List devices; + final String? deviceName; + final SyncEvent? lastSyncEvent; + final String? lastSyncError; + + SyncRepositoryState({ + required this.account, + this.remoteTabs = const [], + this.devices = const [], + this.deviceName, + this.lastSyncEvent, + this.lastSyncError, + }); + + @override + List get hashParameters => + [account, remoteTabs, devices, deviceName, lastSyncEvent, lastSyncError]; +} diff --git a/app/lib/features/sync/domain/entities/sync_repository_state.g.dart b/app/lib/features/sync/domain/entities/sync_repository_state.g.dart new file mode 100644 index 00000000..222a78b2 --- /dev/null +++ b/app/lib/features/sync/domain/entities/sync_repository_state.g.dart @@ -0,0 +1,122 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_repository_state.dart'; + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class _$SyncRepositoryStateCWProxy { + SyncRepositoryState account(SyncAccountInfo account); + + SyncRepositoryState remoteTabs(List remoteTabs); + + SyncRepositoryState devices(List devices); + + SyncRepositoryState deviceName(String? deviceName); + + SyncRepositoryState lastSyncEvent(SyncEvent? lastSyncEvent); + + SyncRepositoryState lastSyncError(String? lastSyncError); + + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `SyncRepositoryState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// SyncRepositoryState(...).copyWith(id: 12, name: "My name") + /// ``` + SyncRepositoryState call({ + SyncAccountInfo account, + List remoteTabs, + List devices, + String? deviceName, + SyncEvent? lastSyncEvent, + String? lastSyncError, + }); +} + +/// Callable proxy for `copyWith` functionality. +/// Use as `instanceOfSyncRepositoryState.copyWith(...)` or call `instanceOfSyncRepositoryState.copyWith.fieldName(value)` for a single field. +class _$SyncRepositoryStateCWProxyImpl implements _$SyncRepositoryStateCWProxy { + const _$SyncRepositoryStateCWProxyImpl(this._value); + + final SyncRepositoryState _value; + + @override + SyncRepositoryState account(SyncAccountInfo account) => + call(account: account); + + @override + SyncRepositoryState remoteTabs(List remoteTabs) => + call(remoteTabs: remoteTabs); + + @override + SyncRepositoryState devices(List devices) => + call(devices: devices); + + @override + SyncRepositoryState deviceName(String? deviceName) => + call(deviceName: deviceName); + + @override + SyncRepositoryState lastSyncEvent(SyncEvent? lastSyncEvent) => + call(lastSyncEvent: lastSyncEvent); + + @override + SyncRepositoryState lastSyncError(String? lastSyncError) => + call(lastSyncError: lastSyncError); + + @override + /// Creates a new instance with the provided field values. + /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `SyncRepositoryState(...).copyWith.fieldName(value)`. + /// + /// Example: + /// ```dart + /// SyncRepositoryState(...).copyWith(id: 12, name: "My name") + /// ``` + SyncRepositoryState call({ + Object? account = const $CopyWithPlaceholder(), + Object? remoteTabs = const $CopyWithPlaceholder(), + Object? devices = const $CopyWithPlaceholder(), + Object? deviceName = const $CopyWithPlaceholder(), + Object? lastSyncEvent = const $CopyWithPlaceholder(), + Object? lastSyncError = const $CopyWithPlaceholder(), + }) { + return SyncRepositoryState( + account: account == const $CopyWithPlaceholder() || account == null + ? _value.account + // ignore: cast_nullable_to_non_nullable + : account as SyncAccountInfo, + remoteTabs: + remoteTabs == const $CopyWithPlaceholder() || remoteTabs == null + ? _value.remoteTabs + // ignore: cast_nullable_to_non_nullable + : remoteTabs as List, + devices: devices == const $CopyWithPlaceholder() || devices == null + ? _value.devices + // ignore: cast_nullable_to_non_nullable + : devices as List, + deviceName: deviceName == const $CopyWithPlaceholder() + ? _value.deviceName + // ignore: cast_nullable_to_non_nullable + : deviceName as String?, + lastSyncEvent: lastSyncEvent == const $CopyWithPlaceholder() + ? _value.lastSyncEvent + // ignore: cast_nullable_to_non_nullable + : lastSyncEvent as SyncEvent?, + lastSyncError: lastSyncError == const $CopyWithPlaceholder() + ? _value.lastSyncError + // ignore: cast_nullable_to_non_nullable + : lastSyncError as String?, + ); + } +} + +extension $SyncRepositoryStateCopyWith on SyncRepositoryState { + /// Returns a callable class used to build a new instance with modified fields. + /// Example: `instanceOfSyncRepositoryState.copyWith(...)` or `instanceOfSyncRepositoryState.copyWith.fieldName(...)`. + // ignore: library_private_types_in_public_api + _$SyncRepositoryStateCWProxy get copyWith => + _$SyncRepositoryStateCWProxyImpl(this); +} diff --git a/app/lib/features/sync/domain/entities/synced_tab_item.dart b/app/lib/features/sync/domain/entities/synced_tab_item.dart new file mode 100644 index 00000000..1878fb04 --- /dev/null +++ b/app/lib/features/sync/domain/entities/synced_tab_item.dart @@ -0,0 +1,17 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; + +class SyncedTabItem with FastEquatable { + final String deviceId; + final String deviceName; + final SyncRemoteTab tab; + + SyncedTabItem({ + required this.deviceId, + required this.deviceName, + required this.tab, + }); + + @override + List get hashParameters => [deviceId, deviceName, tab]; +} diff --git a/app/lib/features/sync/domain/repositories/sync.dart b/app/lib/features/sync/domain/repositories/sync.dart new file mode 100644 index 00000000..fcf74cf5 --- /dev/null +++ b/app/lib/features/sync/domain/repositories/sync.dart @@ -0,0 +1,411 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:drift/drift.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/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; +import 'package:weblibre/features/sync/domain/entities/synced_tab_item.dart'; + +part 'sync.g.dart'; + +@Riverpod(keepAlive: true) +bool syncIsAuthenticated(Ref ref) { + return ref.watch( + syncRepositoryProvider.select( + (value) => value.value?.account.authenticated == true, + ), + ); +} + +enum TabsTrayScope { local, synced } + +@Riverpod(keepAlive: true) +class TabsTrayScopeController extends _$TabsTrayScopeController { + @override + TabsTrayScope build() => TabsTrayScope.local; + + void showLocal() { + state = TabsTrayScope.local; + } + + void showSynced() { + state = TabsTrayScope.synced; + } +} + +@Riverpod(keepAlive: true) +TabsTrayScope effectiveTabsTrayScope(Ref ref) { + final scope = ref.watch(tabsTrayScopeControllerProvider); + final isAuthenticated = ref.watch(syncIsAuthenticatedProvider); + + if (!isAuthenticated && scope == TabsTrayScope.synced) { + return TabsTrayScope.local; + } + + return scope; +} + +@Riverpod(keepAlive: true) +class SelectedSyncedTabsDeviceId extends _$SelectedSyncedTabsDeviceId { + @override + String? build() => null; + + // ignore: use_setters_to_change_properties + void selectDevice(String? value) { + state = value; + } +} + +@riverpod +Future syncedTabsTotalCount(Ref ref) { + return ref.watch( + syncRemoteTabsProvider.selectAsync( + (devices) => + devices.fold(0, (count, device) => count + device.tabs.length), + ), + ); +} + +@riverpod +Future effectiveSyncedTabsDeviceId(Ref ref) async { + final devices = await ref.watch( + syncRemoteTabsProvider.selectAsync((tabs) => tabs), + ); + final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider); + + if (devices.isEmpty) { + return null; + } + + if (selectedDeviceId != null && + devices.any((device) => device.deviceId == selectedDeviceId)) { + return selectedDeviceId; + } + + return devices.first.deviceId; +} + +@riverpod +Future> syncedTabsForSelectedDevice(Ref ref) async { + final devices = await ref.watch( + syncRemoteTabsProvider.selectAsync((tabs) => tabs), + ); + + final selectedDeviceId = ref.watch(selectedSyncedTabsDeviceIdProvider); + + final effectiveSelectedDeviceId = + selectedDeviceId != null && + devices.any((device) => device.deviceId == selectedDeviceId) + ? selectedDeviceId + : devices.firstOrNull?.deviceId; + + if (effectiveSelectedDeviceId == null) { + return const []; + } + + final device = devices.firstWhereOrNull( + (item) => item.deviceId == effectiveSelectedDeviceId, + ); + + if (device == null) { + return const []; + } + + final tabs = device.tabs + .map( + (tab) => SyncedTabItem( + deviceId: device.deviceId, + deviceName: device.deviceName, + tab: tab, + ), + ) + .toList(growable: false); + + tabs.sort((a, b) => b.tab.lastUsed.compareTo(a.tab.lastUsed)); + return tabs; +} + +@Riverpod(keepAlive: true) +class SyncRepository extends _$SyncRepository { + final _service = GeckoSyncService(); + + StreamSubscription? _authStateSub; + StreamSubscription? _syncStartedSub; + StreamSubscription? _syncCompletedSub; + StreamSubscription? _syncErrorSub; + + Future _awaitInitialized() => future; + + void _update(SyncRepositoryState Function(SyncRepositoryState) updater) { + final current = state.value; + if (current != null) { + state = AsyncData(updater(current)); + } + } + + Future _refreshAccount() async { + await _awaitInitialized(); + + final account = await _service.getAccountInfo(); + _update((s) => s.copyWith(account: account)); + } + + Future _refreshTabs() async { + await _awaitInitialized(); + + try { + final tabs = await _service.getSyncedTabs(); + _update((s) => s.copyWith(remoteTabs: tabs)); + } on Exception catch (e) { + logger.e('Failed to refresh synced tabs', error: e); + _update( + (s) => s.copyWith( + lastSyncEvent: SyncEvent.error, + lastSyncError: e.toString(), + ), + ); + } + } + + Future _refreshDevices() async { + await _awaitInitialized(); + + try { + final devices = await _service.getDevices(); + _update((s) => s.copyWith(devices: devices)); + } on Exception catch (e) { + logger.e('Failed to refresh devices', error: e); + _update( + (s) => s.copyWith( + lastSyncEvent: SyncEvent.error, + lastSyncError: e.toString(), + ), + ); + } + } + + Future _refreshDeviceName() async { + await _awaitInitialized(); + + final deviceName = await _service.getDeviceName(); + _update((s) => s.copyWith(deviceName: deviceName)); + } + + Future refresh() async { + await _refreshAccount(); + return state.value!.account; + } + + Future signIn() async { + await _service.beginAuthentication(); + } + + Future signInWithPairing(String pairingUrl) async { + await _service.beginPairingAuthentication(pairingUrl); + } + + Future signOut() async { + await _service.logout(); + } + + Future syncNow() async { + await _service.syncNow(); + await Future.wait([_refreshAccount(), _refreshTabs(), _refreshDevices()]); + } + + Future setEngineEnabled(SyncEngineValue engine, bool enabled) async { + await _service.setEngineEnabled(engine, enabled); + await Future.wait([_refreshAccount(), _refreshTabs(), _refreshDevices()]); + } + + Future sendTabToDevice({ + required String deviceId, + required String title, + required String url, + }) { + return _service.sendTabToDevice(deviceId, title, url); + } + + Future setDeviceName(String newName) async { + final result = await _service.setDeviceName(newName); + + if (result) { + await Future.wait([_refreshDevices(), _refreshDeviceName()]); + } + + return result; + } + + Future refreshDevices() async { + await _service.refreshDevices(); + await _refreshDevices(); + } + + Future pollIncomingTabsAndOpen() async { + await _service.pollDeviceCommands(); + final incomingTabs = await _service.drainIncomingTabs(); + + for (final tab in incomingTabs) { + await _openUrlInAssignedContainer(tab.url); + } + + await _refreshTabs(); + return incomingTabs.length; + } + + Future openSyncedTab(SyncRemoteTab tab) async { + await _openUrlInAssignedContainer(tab.url); + } + + Future _openUrlInAssignedContainer(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) { + return; + } + + final containerRepository = ref.read(containerRepositoryProvider.notifier); + + final containerId = await containerRepository.siteAssignedContainerId(uri); + + final assignedContainer = containerId == null + ? null + : await containerRepository.getContainerData(containerId); + + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: uri, + selectTab: true, + private: false, + container: Value(assignedContainer), + ); + } + + @override + Future build() async { + final syncStateService = ref.read(geckoSyncStateServiceProvider); + + _syncStartedSub = syncStateService.syncStartedEvents.listen((_) { + _update( + (s) => s.copyWith( + lastSyncEvent: SyncEvent.started, + // ignore: avoid_redundant_argument_values + lastSyncError: null, + ), + ); + }); + + _syncCompletedSub = syncStateService.syncCompletedEvents.listen((_) { + _update( + (s) => s.copyWith( + lastSyncEvent: SyncEvent.completed, + // ignore: avoid_redundant_argument_values + lastSyncError: null, + ), + ); + unawaited(Future.wait([_refreshTabs(), _refreshDevices()])); + }); + + _syncErrorSub = syncStateService.syncErrorEvents.listen((error) { + logger.e('Sync failed', error: error ?? 'Unknown synchronization error'); + _update( + (s) => s.copyWith(lastSyncEvent: SyncEvent.error, lastSyncError: error), + ); + unawaited(Future.wait([_refreshTabs(), _refreshDevices()])); + }); + + _authStateSub = syncStateService.authStateEvents.listen((info) { + final previous = state.value; + + if (previous == null) { + state = AsyncData(SyncRepositoryState(account: info)); + return; + } + + final unauthenticated = !info.authenticated; + state = AsyncData( + unauthenticated + ? SyncRepositoryState(account: info) + : previous.copyWith(account: info), + ); + + final authStateChanged = + previous.account.authenticated != info.authenticated || + previous.account.needsReauth != info.needsReauth; + + if (authStateChanged && info.authenticated) { + unawaited( + Future.wait([ + _refreshAccount(), + _refreshTabs(), + _refreshDevices(), + _refreshDeviceName(), + ]), + ); + } + }); + + ref.onDispose(() { + unawaited(_authStateSub?.cancel()); + unawaited(_syncStartedSub?.cancel()); + unawaited(_syncCompletedSub?.cancel()); + unawaited(_syncErrorSub?.cancel()); + }); + + final (account, tabs, devices, deviceName) = await ( + _service.getAccountInfo(), + _service.getSyncedTabs(), + _service.getDevices(), + _service.getDeviceName(), + ).wait; + + return SyncRepositoryState( + account: account, + remoteTabs: tabs, + devices: devices, + deviceName: deviceName, + ); + } +} + +@Riverpod(keepAlive: true) +GeckoSyncStateService geckoSyncStateService(Ref ref) { + final service = GeckoSyncStateService.setUp(); + ref.onDispose(() => service.dispose()); + return service; +} + +@riverpod +Future syncDeviceName(Ref ref) { + return ref.watch( + syncRepositoryProvider.selectAsync((value) => value.deviceName), + ); +} + +@riverpod +Future> syncRemoteTabs(Ref ref) { + return ref.watch( + syncRepositoryProvider.selectAsync((value) => value.remoteTabs), + ); +} + +@riverpod +Future> syncDevices(Ref ref) { + return ref.watch( + syncRepositoryProvider.selectAsync((value) => value.devices), + ); +} + +@riverpod +Future<(SyncEvent?, String?)> syncEvent(Ref ref) { + return ref.watch( + syncRepositoryProvider.selectAsync( + (s) => (s.lastSyncEvent, s.lastSyncError), + ), + ); +} diff --git a/app/lib/features/sync/domain/repositories/sync.g.dart b/app/lib/features/sync/domain/repositories/sync.g.dart new file mode 100644 index 00000000..bb663e4f --- /dev/null +++ b/app/lib/features/sync/domain/repositories/sync.g.dart @@ -0,0 +1,560 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(syncIsAuthenticated) +final syncIsAuthenticatedProvider = SyncIsAuthenticatedProvider._(); + +final class SyncIsAuthenticatedProvider + extends $FunctionalProvider + with $Provider { + SyncIsAuthenticatedProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncIsAuthenticatedProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncIsAuthenticatedHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return syncIsAuthenticated(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$syncIsAuthenticatedHash() => + r'40e108a5c1d4d885fd31edde04146bf4131dd51c'; + +@ProviderFor(TabsTrayScopeController) +final tabsTrayScopeControllerProvider = TabsTrayScopeControllerProvider._(); + +final class TabsTrayScopeControllerProvider + extends $NotifierProvider { + TabsTrayScopeControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabsTrayScopeControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabsTrayScopeControllerHash(); + + @$internal + @override + TabsTrayScopeController create() => TabsTrayScopeController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TabsTrayScope value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$tabsTrayScopeControllerHash() => + r'e9415c1f57e1a78804ec4eee122c9db9ee416066'; + +abstract class _$TabsTrayScopeController extends $Notifier { + TabsTrayScope build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + TabsTrayScope, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +@ProviderFor(effectiveTabsTrayScope) +final effectiveTabsTrayScopeProvider = EffectiveTabsTrayScopeProvider._(); + +final class EffectiveTabsTrayScopeProvider + extends $FunctionalProvider + with $Provider { + EffectiveTabsTrayScopeProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'effectiveTabsTrayScopeProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$effectiveTabsTrayScopeHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + TabsTrayScope create(Ref ref) { + return effectiveTabsTrayScope(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TabsTrayScope value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$effectiveTabsTrayScopeHash() => + r'ac295d7413a7014882835d3533bf31cd3c031a1d'; + +@ProviderFor(SelectedSyncedTabsDeviceId) +final selectedSyncedTabsDeviceIdProvider = + SelectedSyncedTabsDeviceIdProvider._(); + +final class SelectedSyncedTabsDeviceIdProvider + extends $NotifierProvider { + SelectedSyncedTabsDeviceIdProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'selectedSyncedTabsDeviceIdProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$selectedSyncedTabsDeviceIdHash(); + + @$internal + @override + SelectedSyncedTabsDeviceId create() => SelectedSyncedTabsDeviceId(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$selectedSyncedTabsDeviceIdHash() => + r'61f86d2f17d6c2f04e1fb76dae900c4695aa6af0'; + +abstract class _$SelectedSyncedTabsDeviceId extends $Notifier { + String? build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + String?, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +@ProviderFor(syncedTabsTotalCount) +final syncedTabsTotalCountProvider = SyncedTabsTotalCountProvider._(); + +final class SyncedTabsTotalCountProvider + extends $FunctionalProvider, int, FutureOr> + with $FutureModifier, $FutureProvider { + SyncedTabsTotalCountProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncedTabsTotalCountProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncedTabsTotalCountHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return syncedTabsTotalCount(ref); + } +} + +String _$syncedTabsTotalCountHash() => + r'507b14aac187c434cf02925d895151c1a25159ea'; + +@ProviderFor(effectiveSyncedTabsDeviceId) +final effectiveSyncedTabsDeviceIdProvider = + EffectiveSyncedTabsDeviceIdProvider._(); + +final class EffectiveSyncedTabsDeviceIdProvider + extends $FunctionalProvider, String?, FutureOr> + with $FutureModifier, $FutureProvider { + EffectiveSyncedTabsDeviceIdProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'effectiveSyncedTabsDeviceIdProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$effectiveSyncedTabsDeviceIdHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return effectiveSyncedTabsDeviceId(ref); + } +} + +String _$effectiveSyncedTabsDeviceIdHash() => + r'26ed7e56191a8019cab5c89dc8d5622e1a709e20'; + +@ProviderFor(syncedTabsForSelectedDevice) +final syncedTabsForSelectedDeviceProvider = + SyncedTabsForSelectedDeviceProvider._(); + +final class SyncedTabsForSelectedDeviceProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + SyncedTabsForSelectedDeviceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncedTabsForSelectedDeviceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncedTabsForSelectedDeviceHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return syncedTabsForSelectedDevice(ref); + } +} + +String _$syncedTabsForSelectedDeviceHash() => + r'a85c4e972ab55b872f1de64cd27ba18d7dcc023a'; + +@ProviderFor(SyncRepository) +final syncRepositoryProvider = SyncRepositoryProvider._(); + +final class SyncRepositoryProvider + extends $AsyncNotifierProvider { + SyncRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncRepositoryHash(); + + @$internal + @override + SyncRepository create() => SyncRepository(); +} + +String _$syncRepositoryHash() => r'5312aed1abf60ce3e04afa7d2f60b38367c09312'; + +abstract class _$SyncRepository extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, SyncRepositoryState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, SyncRepositoryState>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + +@ProviderFor(geckoSyncStateService) +final geckoSyncStateServiceProvider = GeckoSyncStateServiceProvider._(); + +final class GeckoSyncStateServiceProvider + extends + $FunctionalProvider< + GeckoSyncStateService, + GeckoSyncStateService, + GeckoSyncStateService + > + with $Provider { + GeckoSyncStateServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'geckoSyncStateServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$geckoSyncStateServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + GeckoSyncStateService create(Ref ref) { + return geckoSyncStateService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GeckoSyncStateService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$geckoSyncStateServiceHash() => + r'1e96db4a6229a3d2a31862cc9cf88c463405819b'; + +@ProviderFor(syncDeviceName) +final syncDeviceNameProvider = SyncDeviceNameProvider._(); + +final class SyncDeviceNameProvider + extends $FunctionalProvider, String?, FutureOr> + with $FutureModifier, $FutureProvider { + SyncDeviceNameProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncDeviceNameProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncDeviceNameHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return syncDeviceName(ref); + } +} + +String _$syncDeviceNameHash() => r'3d7e531cba481c4233a895b1d80e915d646fc6b6'; + +@ProviderFor(syncRemoteTabs) +final syncRemoteTabsProvider = SyncRemoteTabsProvider._(); + +final class SyncRemoteTabsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + SyncRemoteTabsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncRemoteTabsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncRemoteTabsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return syncRemoteTabs(ref); + } +} + +String _$syncRemoteTabsHash() => r'5040a010e578f2fe395302e7d34a91df1854f8a9'; + +@ProviderFor(syncDevices) +final syncDevicesProvider = SyncDevicesProvider._(); + +final class SyncDevicesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + SyncDevicesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncDevicesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncDevicesHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return syncDevices(ref); + } +} + +String _$syncDevicesHash() => r'6249b4891f95fc6f8e2f70f1555aff0e6356ccad'; + +@ProviderFor(syncEvent) +final syncEventProvider = SyncEventProvider._(); + +final class SyncEventProvider + extends + $FunctionalProvider< + AsyncValue<(SyncEvent?, String?)>, + (SyncEvent?, String?), + FutureOr<(SyncEvent?, String?)> + > + with + $FutureModifier<(SyncEvent?, String?)>, + $FutureProvider<(SyncEvent?, String?)> { + SyncEventProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'syncEventProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$syncEventHash(); + + @$internal + @override + $FutureProviderElement<(SyncEvent?, String?)> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr<(SyncEvent?, String?)> create(Ref ref) { + return syncEvent(ref); + } +} + +String _$syncEventHash() => r'c84d251502578779f66e17ee79e353ffa3b23e1a'; diff --git a/app/lib/features/sync/presentation/screens/sync_settings.dart b/app/lib/features/sync/presentation/screens/sync_settings.dart new file mode 100644 index 00000000..45201b2a --- /dev/null +++ b/app/lib/features/sync/presentation/screens/sync_settings.dart @@ -0,0 +1,463 @@ +import 'dart:async'; + +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; +import 'package:weblibre/core/providers/format.dart'; +import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart'; +import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart'; +import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; +import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; +import 'package:weblibre/features/user/data/models/general_settings.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +class SyncSettingsScreen extends HookConsumerWidget { + const SyncSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final syncInfo = ref.watch( + syncRepositoryProvider.select((value) => value.value?.account), + ); + + final generalSettings = ref.watch(generalSettingsWithDefaultsProvider); + + final syncStarted = ref.watch( + syncEventProvider.select( + (value) => value.isLoading || value.value?.$1 == SyncEvent.started, + ), + ); + final isSyncing = syncStarted || syncInfo?.syncing == true; + + final syncText = useMemoized(() { + if (isSyncing) { + return 'Synchronization in progress'; + } + + final timestamp = syncInfo?.lastSyncedAt; + if (timestamp == null || timestamp <= 0) { + return 'Never synced'; + } + + final date = DateTime.fromMillisecondsSinceEpoch(timestamp); + final formattedDate = ref + .read(formatProvider.notifier) + .fullDateTime(date.toLocal()); + + return 'Last synced: $formattedDate'; + }, [syncInfo, isSyncing]); + + final syncController = useAnimationController( + duration: const Duration(seconds: 2), + ); + + useEffect(() { + if (isSyncing) { + unawaited(syncController.repeat()); + } else { + syncController.stop(); + syncController.reset(); + } + return null; + }, [isSyncing]); + + return Scaffold( + appBar: AppBar(title: const Text('Firefox Sync')), + body: SafeArea( + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + padding: const EdgeInsets.symmetric(horizontal: 12.0), + children: [ + const SettingSection(name: 'Account'), + ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: Text(syncInfo?.email ?? 'Not signed in'), + subtitle: Text( + syncInfo?.needsReauth == true + ? 'Authentication expired. Sign in again to continue syncing.' + : syncInfo?.authenticated == true + ? (syncInfo?.displayName ?? 'Signed in') + : 'Sign in to synchronize tabs, bookmarks, and history', + ), + trailing: syncInfo?.authenticated == true + ? IconButton( + icon: const Icon(Icons.logout), + tooltip: 'Sign Out', + onPressed: isSyncing + ? null + : () async { + final confirmed = + await _showSignOutConfirmation(context); + if (confirmed == true) { + await ref + .read(syncRepositoryProvider.notifier) + .signOut(); + } + }, + ) + : const Icon(Icons.login), + onTap: (syncInfo?.authenticated == true || isSyncing) + ? null + : () async { + await ref + .read(syncRepositoryProvider.notifier) + .signIn(); + }, + ), + if (syncInfo?.authenticated != true && !isSyncing) + ListTile( + leading: const Icon(Icons.qr_code_scanner), + title: const Text('Scan QR Code to pair'), + subtitle: const Text( + 'Scan a QR code from firefox.com/pair on desktop', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final barcode = await showDialog( + context: context, + builder: (_) => const QrScannerDialog(), + ); + + final code = barcode?.code; + if (code == null || code.isEmpty) return; + + final uri = Uri.tryParse(code); + if (uri == null || !uri.hasScheme) { + if (context.mounted) { + ui_helper.showErrorMessage( + context, + 'Invalid QR code: not a valid URL', + ); + } + + return; + } + + await ref + .read(syncRepositoryProvider.notifier) + .signInWithPairing(code); + }, + ), + if (syncInfo?.authenticated == true) + ListTile( + leading: const Icon(Icons.devices), + title: const Text('Device Name'), + subtitle: ref + .watch(syncDeviceNameProvider) + .when( + data: (name) => Text(name ?? 'Unknown'), + loading: () => const Text('Loading...'), + error: (_, _) => const Text('Unknown'), + ), + trailing: const Icon(Icons.edit_outlined), + onTap: isSyncing + ? null + : () async { + final currentName = ref + .read(syncRepositoryProvider) + .value + ?.deviceName; + + if (currentName != null && context.mounted) { + await _showDeviceNameDialog( + context, + currentName: currentName, + onSave: (newName) { + return ref + .read(syncRepositoryProvider.notifier) + .setDeviceName(newName); + }, + ); + } + }, + ), + const SettingSection(name: 'Synchronization'), + ListTile( + leading: RotationTransition( + turns: Tween( + begin: 0, + end: -1, + ).animate(syncController), + child: const Icon(Icons.sync), + ), + title: const Text('Sync Now'), + subtitle: Text(syncText), + trailing: const Icon(Icons.chevron_right), + onTap: isSyncing + ? null + : () async { + await ref + .read(syncRepositoryProvider.notifier) + .syncNow(); + }, + ), + SwitchListTile.adaptive( + title: const Text('Sync History'), + value: _engineEnabled(syncInfo, SyncEngineValue.history), + onChanged: (syncInfo == null || isSyncing) + ? null + : (value) async { + await ref + .read(syncRepositoryProvider.notifier) + .setEngineEnabled(SyncEngineValue.history, value); + }, + ), + SwitchListTile.adaptive( + title: const Text('Sync Bookmarks'), + value: _engineEnabled(syncInfo, SyncEngineValue.bookmarks), + onChanged: (syncInfo == null || isSyncing) + ? null + : (value) async { + await ref + .read(syncRepositoryProvider.notifier) + .setEngineEnabled( + SyncEngineValue.bookmarks, + value, + ); + }, + ), + SwitchListTile.adaptive( + title: const Text('Sync Open Tabs'), + value: _engineEnabled(syncInfo, SyncEngineValue.tabs), + onChanged: (syncInfo == null || isSyncing) + ? null + : (value) async { + await ref + .read(syncRepositoryProvider.notifier) + .setEngineEnabled(SyncEngineValue.tabs, value); + }, + ), + const SettingSection(name: 'Server Overrides'), + ListTile( + title: const Text('FxA Server Override'), + subtitle: Text( + generalSettings.syncServerOverride.isEmpty + ? 'Default Mozilla server' + : generalSettings.syncServerOverride, + ), + trailing: const Icon(Icons.edit_outlined), + onTap: isSyncing + ? null + : () => _showTextSettingDialog( + context, + title: 'FxA Server Override', + initialValue: generalSettings.syncServerOverride, + hint: 'https://accounts.firefox.com', + onSave: (value) { + return ref + .read( + saveGeneralSettingsControllerProvider + .notifier, + ) + .save( + (current) => current.copyWith + .syncServerOverride(value.trim()), + ); + }, + ), + ), + ListTile( + title: const Text('Sync Token Server Override'), + subtitle: Text( + generalSettings.syncTokenServerOverride.isEmpty + ? 'Automatic from FxA server' + : generalSettings.syncTokenServerOverride, + ), + trailing: const Icon(Icons.edit_outlined), + onTap: isSyncing + ? null + : () => _showTextSettingDialog( + context, + title: 'Sync Token Server Override', + initialValue: generalSettings.syncTokenServerOverride, + hint: + 'https://token.services.mozilla.com/1.0/sync/1.5', + onSave: (value) { + return ref + .read( + saveGeneralSettingsControllerProvider + .notifier, + ) + .save( + (current) => current.copyWith + .syncTokenServerOverride(value.trim()), + ); + }, + ), + ), + const ListTile( + dense: true, + title: Text( + 'Restart the app after changing server overrides.', + style: TextStyle(fontSize: 12), + ), + ), + ], + ); + }, + ), + ), + ); + } + + static bool _engineEnabled(SyncAccountInfo? info, SyncEngineValue engine) { + final engines = info?.engines; + if (engines == null) { + return true; + } + + for (final status in engines) { + if (status.engine == engine) { + return status.enabled; + } + } + + return true; + } + + static Future _showSignOutConfirmation(BuildContext context) { + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Sign out?'), + content: const Text( + 'Are you sure you want to sign out of Firefox Sync?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Sign Out'), + ), + ], + ); + }, + ); + } + + static Future _showDeviceNameDialog( + BuildContext context, { + required String currentName, + required Future Function(String name) onSave, + }) async { + final controller = TextEditingController(text: currentName); + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Device Name'), + content: TextField( + controller: controller, + decoration: const InputDecoration(hintText: 'Enter device name'), + autofocus: true, + inputFormatters: [LengthLimitingTextInputFormatter(128)], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final newName = controller.text.trim(); + if (newName.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Device name cannot be empty'), + ), + ); + } + return; + } + + final success = await onSave(newName).catchError((_) => false); + + if (!success) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to update device name'), + ), + ); + } + return; + } + + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + } + + static Future _showTextSettingDialog( + BuildContext context, { + required String title, + required String initialValue, + required String hint, + required Future Function(String value) onSave, + }) async { + final controller = TextEditingController(text: initialValue); + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + decoration: InputDecoration(hintText: hint), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final value = controller.text.trim(); + if (value.isNotEmpty) { + final uri = Uri.tryParse(value); + if (uri == null || uri.scheme != 'https') { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Must be a valid HTTPS URL'), + ), + ); + } + return; + } + } + await onSave(controller.text); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + } +} diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart index 8b5f29f4..8830c53b 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -89,6 +89,8 @@ class GeneralSettings with FastEquatable { final bool tabListShowFavicons; final bool quickTabSwitcherShowTitles; final bool drawerGestureEnabled; + final String syncServerOverride; + final String syncTokenServerOverride; GeneralSettings({ required this.themeMode, @@ -120,6 +122,8 @@ class GeneralSettings with FastEquatable { required this.tabListShowFavicons, required this.quickTabSwitcherShowTitles, required this.drawerGestureEnabled, + required this.syncServerOverride, + required this.syncTokenServerOverride, }); GeneralSettings.withDefaults({ @@ -152,6 +156,8 @@ class GeneralSettings with FastEquatable { bool? tabListShowFavicons, bool? quickTabSwitcherShowTitles, bool? drawerGestureEnabled, + String? syncServerOverride, + String? syncTokenServerOverride, }) : themeMode = themeMode ?? ThemeMode.dark, enableReadability = enableReadability ?? true, enforceReadability = enforceReadability ?? false, @@ -184,7 +190,9 @@ class GeneralSettings with FastEquatable { allowClipboardAccess = allowClipboardAccess ?? true, tabListShowFavicons = tabListShowFavicons ?? false, quickTabSwitcherShowTitles = quickTabSwitcherShowTitles ?? true, - drawerGestureEnabled = drawerGestureEnabled ?? false; + drawerGestureEnabled = drawerGestureEnabled ?? false, + syncServerOverride = syncServerOverride ?? '', + syncTokenServerOverride = syncTokenServerOverride ?? ''; factory GeneralSettings.fromJson(Map json) => _$GeneralSettingsFromJson(json); @@ -222,5 +230,7 @@ class GeneralSettings with FastEquatable { tabListShowFavicons, quickTabSwitcherShowTitles, drawerGestureEnabled, + syncServerOverride, + syncTokenServerOverride, ]; } diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart index bb4a1f85..232ed371 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -77,6 +77,10 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings drawerGestureEnabled(bool drawerGestureEnabled); + GeneralSettings syncServerOverride(String syncServerOverride); + + GeneralSettings syncTokenServerOverride(String syncTokenServerOverride); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. /// @@ -114,6 +118,8 @@ abstract class _$GeneralSettingsCWProxy { bool tabListShowFavicons, bool quickTabSwitcherShowTitles, bool drawerGestureEnabled, + String syncServerOverride, + String syncTokenServerOverride, }); } @@ -245,6 +251,14 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings drawerGestureEnabled(bool drawerGestureEnabled) => call(drawerGestureEnabled: drawerGestureEnabled); + @override + GeneralSettings syncServerOverride(String syncServerOverride) => + call(syncServerOverride: syncServerOverride); + + @override + GeneralSettings syncTokenServerOverride(String syncTokenServerOverride) => + call(syncTokenServerOverride: syncTokenServerOverride); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. @@ -283,6 +297,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? tabListShowFavicons = const $CopyWithPlaceholder(), Object? quickTabSwitcherShowTitles = const $CopyWithPlaceholder(), Object? drawerGestureEnabled = const $CopyWithPlaceholder(), + Object? syncServerOverride = const $CopyWithPlaceholder(), + Object? syncTokenServerOverride = const $CopyWithPlaceholder(), }) { return GeneralSettings( themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null @@ -455,6 +471,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.drawerGestureEnabled // ignore: cast_nullable_to_non_nullable : drawerGestureEnabled as bool, + syncServerOverride: + syncServerOverride == const $CopyWithPlaceholder() || + syncServerOverride == null + ? _value.syncServerOverride + // ignore: cast_nullable_to_non_nullable + : syncServerOverride as String, + syncTokenServerOverride: + syncTokenServerOverride == const $CopyWithPlaceholder() || + syncTokenServerOverride == null + ? _value.syncTokenServerOverride + // ignore: cast_nullable_to_non_nullable + : syncTokenServerOverride as String, ); } } @@ -534,6 +562,8 @@ GeneralSettings _$GeneralSettingsFromJson( tabListShowFavicons: json['tabListShowFavicons'] as bool?, quickTabSwitcherShowTitles: json['quickTabSwitcherShowTitles'] as bool?, drawerGestureEnabled: json['drawerGestureEnabled'] as bool?, + syncServerOverride: json['syncServerOverride'] as String?, + syncTokenServerOverride: json['syncTokenServerOverride'] as String?, ); Map _$GeneralSettingsToJson( @@ -577,6 +607,8 @@ Map _$GeneralSettingsToJson( 'tabListShowFavicons': instance.tabListShowFavicons, 'quickTabSwitcherShowTitles': instance.quickTabSwitcherShowTitles, 'drawerGestureEnabled': instance.drawerGestureEnabled, + 'syncServerOverride': instance.syncServerOverride, + 'syncTokenServerOverride': instance.syncTokenServerOverride, }; const _$ThemeModeEnumMap = { diff --git a/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart b/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart index 438755ad..f03f23be 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart @@ -60,9 +60,7 @@ class ProfileBackupListScreen extends HookConsumerWidget { key: ValueKey(file.path), title: Text(profileName), subtitle: Text( - ref - .read(formatProvider.notifier) - .fullDateTimeWithTimezone(dateTime), + ref.read(formatProvider.notifier).fullDateTime(dateTime), ), onTap: () async { await RestoreProfileRoute( diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart index b8b7228b..c3804469 100644 --- a/app/lib/features/user/domain/repositories/general_settings.dart +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -155,6 +155,14 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.bool, db.typeMapping, ), + 'syncServerOverride': settings['syncServerOverride']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), + 'syncTokenServerOverride': settings['syncTokenServerOverride']?.readAs( + DriftSqlType.string, + db.typeMapping, + ), }); } diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart index 22778d68..0b47d2f1 100644 --- a/app/lib/features/user/domain/repositories/general_settings.g.dart +++ b/app/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'33730ccb09471c1a7c187bd19760110f524e055b'; + r'ed5bca200b840e0802980b85a0c0f682d7b24a8c'; abstract class _$GeneralSettingsRepository extends $StreamNotifier { diff --git a/app/lib/features/web_feed/presentation/screens/feed_article.dart b/app/lib/features/web_feed/presentation/screens/feed_article.dart index d6379a6d..4eae946a 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_article.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_article.dart @@ -147,13 +147,13 @@ class FeedArticleScreen extends HookConsumerWidget { children: [ const Divider(), Text( - 'Published: ${hasArticleCreated ? ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.created!) : 'N/A'}', + 'Published: ${hasArticleCreated ? ref.read(formatProvider.notifier).fullDateTime(article.created!) : 'N/A'}', style: Theme.of(context).textTheme.bodySmall ?.copyWith(fontStyle: FontStyle.italic), ), if (hasArticleUpdated) Text( - 'Updated: ${ref.read(formatProvider.notifier).fullDateTimeWithTimezone(article.updated!)}', + 'Updated: ${ref.read(formatProvider.notifier).fullDateTime(article.updated!)}', style: Theme.of(context).textTheme.bodySmall ?.copyWith(fontStyle: FontStyle.italic), ), diff --git a/app/lib/main.dart b/app/lib/main.dart index 8530d6bc..4e0633a6 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -17,6 +17,7 @@ * 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:developer'; import 'package:background_fetch/background_fetch.dart'; @@ -101,6 +102,9 @@ class _MainWidget extends HookConsumerWidget { final engineSettings = await ref .read(engineSettingsRepositoryProvider.notifier) .fetchSettings(); + final generalSettings = await ref + .read(generalSettingsRepositoryProvider.notifier) + .fetchSettings(); try { await GeckoBrowserService().initialize( @@ -108,6 +112,8 @@ class _MainWidget extends HookConsumerWidget { kDebugMode ? LogLevel.debug : LogLevel.warn, engineSettings.contentBlocking, engineSettings.addonCollection, + generalSettings.syncServerOverride, + generalSettings.syncTokenServerOverride, ); } on PlatformException catch (e, s) { logger.e( diff --git a/app/lib/presentation/main_app.dart b/app/lib/presentation/main_app.dart index fbe5c9f1..70172544 100644 --- a/app/lib/presentation/main_app.dart +++ b/app/lib/presentation/main_app.dart @@ -21,7 +21,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/providers/router.dart'; import 'package:weblibre/domain/services/app_initialization.dart'; +import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class MainApp extends HookConsumerWidget { final ThemeData? theme; @@ -72,6 +75,9 @@ class MainApp extends HookConsumerWidget { darkTheme: darkTheme, themeMode: themeMode, routerConfig: router.value, + builder: (context, child) { + return _SyncEventListener(child: child ?? const SizedBox.shrink()); + }, ); }, onFailure: (errorMessage) { @@ -99,3 +105,41 @@ class MainApp extends HookConsumerWidget { ); } } + +class _SyncEventListener extends ConsumerWidget { + final Widget child; + + const _SyncEventListener({required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(syncEventProvider, (previous, next) { + if (next.isLoading || !next.hasValue) return; + + final event = next.value; + if (event == null) return; + + final (syncEvent, syncError) = event; + + switch (syncEvent) { + // case SyncEvent.completed: + // ui_helper.showInfoMessage( + // context, + // 'Synchronization complete', + // duration: const Duration(seconds: 2), + // ); + case SyncEvent.error: + ui_helper.showErrorMessage( + context, + syncError ?? 'Synchronization failed', + ); + case SyncEvent.started: + case SyncEvent.completed: + case null: + break; + } + }); + + return child; + } +} diff --git a/app/lib/utils/ui_helper.dart b/app/lib/utils/ui_helper.dart index b900da5a..57737f2b 100644 --- a/app/lib/utils/ui_helper.dart +++ b/app/lib/utils/ui_helper.dart @@ -30,8 +30,8 @@ SnackBar _createFloatingSnackBar({ required Widget content, Color? backgroundColor, SnackBarAction? action, - Duration duration = const Duration(seconds: 4), - bool persist = false, + required Duration duration, + required bool persist, }) { return SnackBar( content: content, @@ -43,34 +43,69 @@ SnackBar _createFloatingSnackBar({ ); } -void showErrorMessage(BuildContext context, String message) { +void showErrorMessage( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 4), + bool persist = false, +}) { final snackBar = _createFloatingSnackBar( content: Text( message, style: TextStyle(color: Theme.of(context).colorScheme.error), ), backgroundColor: Theme.of(context).colorScheme.onError, + duration: duration, + persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } -void showInfoMessage(BuildContext context, String message) { - final snackBar = _createFloatingSnackBar(content: Text(message)); +void showInfoMessage( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 4), + bool persist = false, +}) { + final snackBar = _createFloatingSnackBar( + content: Text(message), + duration: duration, + persist: persist, + ); ScaffoldMessenger.of(context).showSnackBar(snackBar); } +void showOpenedTabsFromAnotherDeviceMessage( + BuildContext context, + int openedTabs, { + Duration duration = const Duration(seconds: 4), + bool persist = false, +}) { + if (openedTabs <= 0) { + return; + } + + final message = openedTabs == 1 + ? 'Opened 1 tab received from another device' + : 'Opened $openedTabs tabs received from another device'; + + showInfoMessage(context, message, duration: duration, persist: persist); +} + void showTabBackButtonMessage( BuildContext context, int tabCount, - Duration duration, -) { + Duration duration, { + bool persist = false, +}) { final snackbar = _createFloatingSnackBar( content: (tabCount > 1) ? const Text('Navigate BACK again to close current tab') : const Text('Navigate BACK again to exit app'), duration: duration, + persist: persist, ); ScaffoldMessenger.of(context) @@ -83,6 +118,7 @@ void showTabOpenedMessage( String? tabName, void Function()? onShow, Duration duration = const Duration(seconds: 3), + bool persist = false, }) { final message = switch (tabName.whenNotEmpty) { String() => "New tab '$tabName' opened in background", @@ -95,6 +131,7 @@ void showTabOpenedMessage( (onPressed) => SnackBarAction(label: 'Show', onPressed: onPressed), ), duration: duration, + persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); @@ -104,6 +141,7 @@ Future showSuggestNewTabMessage( BuildContext context, { required void Function(String? searchText) onAdd, Duration duration = const Duration(seconds: 3), + bool persist = false, }) async { final clipboardUrl = await tryGetUriFromClipboard(); @@ -117,6 +155,7 @@ Future showSuggestNewTabMessage( }, ), duration: duration, + persist: persist, ); if (context.mounted) { @@ -130,6 +169,7 @@ void showTabSwitchMessage( String? tabName, void Function()? onSwitch, Duration duration = const Duration(seconds: 3), + bool persist = false, }) { ScaffoldMessenger.of(context).clearSnackBars(); @@ -144,6 +184,7 @@ void showTabSwitchMessage( (onPressed) => SnackBarAction(label: 'Switch', onPressed: onPressed), ), duration: duration, + persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); @@ -179,6 +220,7 @@ void showTabUndoClose( VoidCallback onUndo, { int count = 1, Duration duration = const Duration(seconds: 3), + bool persist = false, }) { ScaffoldMessenger.of(context).clearSnackBars(); @@ -188,6 +230,7 @@ void showTabUndoClose( : const Text('Tab closed'), action: SnackBarAction(label: 'Undo', onPressed: onUndo), duration: duration, + persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); @@ -197,6 +240,7 @@ void showDismissOverrideMessage( BuildContext context, VoidCallback onDismiss, { Duration duration = const Duration(seconds: 4), + bool persist = false, }) { ScaffoldMessenger.of(context).clearSnackBars(); @@ -204,6 +248,7 @@ void showDismissOverrideMessage( content: const Text('Hiding disabled by site'), action: SnackBarAction(label: 'Dismiss', onPressed: onDismiss), duration: duration, + persist: persist, ); ScaffoldMessenger.of(context).showSnackBar(snackBar); diff --git a/packages/flutter_mozilla_components/android/build.gradle b/packages/flutter_mozilla_components/android/build.gradle index 8d76aa71..a7f9b438 100644 --- a/packages/flutter_mozilla_components/android/build.gradle +++ b/packages/flutter_mozilla_components/android/build.gradle @@ -82,6 +82,8 @@ android { configurations.configureEach { exclude group: "org.mozilla.telemetry", module: "glean-native" + // Exclude standalone tink to avoid duplicate classes with tink-android + exclude group: "com.google.crypto.tink", module: "tink" } // Select the Glean from GeckoView. @@ -114,6 +116,8 @@ dependencies { implementation "org.mozilla.components:browser-thumbnails:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-addons:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-app-links:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-accounts:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-accounts-push:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-awesomebar:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-customtabs:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-downloads:$mozillaComponentsVersion" @@ -122,6 +126,7 @@ dependencies { implementation "org.mozilla.components:feature-prompts:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-session:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-readerview:$mozillaComponentsVersion" + implementation "org.mozilla.components:feature-syncedtabs:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-privatemode:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-sitepermissions:$mozillaComponentsVersion" implementation "org.mozilla.components:feature-webcompat:$mozillaComponentsVersion" @@ -131,6 +136,8 @@ dependencies { implementation "org.mozilla.components:feature-intent:$mozillaComponentsVersion" implementation "org.mozilla.components:ui-widgets:$mozillaComponentsVersion" implementation "org.mozilla.components:lib-publicsuffixlist:$mozillaComponentsVersion" + implementation "org.mozilla.components:service-firefox-accounts:$mozillaComponentsVersion" + implementation "org.mozilla.components:support-appservices:$mozillaComponentsVersion" implementation 'androidx.coordinatorlayout:coordinatorlayout:1.3.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0' diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfile.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfile.kt new file mode 100644 index 00000000..6e146511 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ActiveProfile.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024-2025 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 . + */ +package eu.weblibre.flutter_mozilla_components + +import android.content.Context +import java.io.File + +object ActiveProfile { + @Volatile + var prefix: String? = null + + /** SharedPreference names used by mozilla-components FxA/sync that need profile isolation */ + val FXA_SHARED_PREFERENCE_NAMES = setOf( + "fxaAppState", // FxA account state (SharedPrefAccountStorage) + "fxaStatePrefAC", // Sentinel flag for SecureAbove22 account state presence + "fxaStateAC_kp_pre_m", // SecureAbove22 encrypted account state (API < 23 fallback) + "fxaStateAC_kp_post_m", // SecureAbove22 encrypted account state (API >= 23) + "fxa_abnormalities", // Tracks FxA account abnormalities + "mozac_feature_accounts_push", // Push subscription scope + verification state + "SyncAuthInfoCache", // Cached sync auth tokens + "FxaDeviceSettingsCache", // Cached device settings (ID, name, type) + "syncEngines", // Per-engine enabled/disabled state + "syncPrefs", // Last-synced timestamp + persisted sync state + ) + + /** + * Resolve the active profile prefix from disk. + * Called in Application.onCreate() to handle cold-start WorkManager scenarios. + */ + fun resolveFromDisk(context: Context) { + val profileFile = File(context.filesDir, PwaConstants.CURRENT_PROFILE_FILE) + if (!profileFile.exists()) return + val uuid = profileFile.readText().trim().ifEmpty { return } + val relativePath = "${PwaConstants.PROFILES_DIR_NAME}/${PwaConstants.PROFILE_DIR_PREFIX}$uuid" + prefix = File(relativePath).name + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt index c7dc3209..7047e92c 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/BaseBrowserFragment.kt @@ -37,6 +37,8 @@ import io.flutter.Log import mozilla.components.browser.state.state.WebExtensionState import mozilla.components.browser.thumbnails.BrowserThumbnails import mozilla.components.concept.engine.EngineView +import mozilla.components.feature.accounts.FxaCapability +import mozilla.components.feature.accounts.FxaWebChannelFeature import mozilla.components.feature.app.links.AppLinksFeature import mozilla.components.feature.downloads.DownloadsFeature import mozilla.components.feature.downloads.manager.FetchDownloadManager @@ -85,6 +87,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit ViewBoundFeatureWrapper() private val webAuthnFeature = ViewBoundFeatureWrapper() + private val fxaWebChannelFeature = ViewBoundFeatureWrapper() private var pictureInPictureFeature: PictureInPictureFeature? = null @@ -450,6 +453,19 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit view = view ) + fxaWebChannelFeature.set( + feature = FxaWebChannelFeature( + customTabSessionId = sessionId, + runtime = components.core.engine, + store = components.core.store, + accountManager = components.backgroundServices.accountManager, + serverConfig = components.backgroundServices.serverConfig, + fxaCapabilities = setOf(FxaCapability.CHOOSE_WHAT_TO_SYNC), + ), + owner = this, + view = view, + ) + readerViewFeature.set( feature = ReaderViewIntegration( profileContext, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt index 234f7f90..06f83b73 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/Components.kt @@ -9,6 +9,7 @@ package eu.weblibre.flutter_mozilla_components import android.content.Context import androidx.core.app.NotificationManagerCompat import eu.weblibre.flutter_mozilla_components.components.Core +import eu.weblibre.flutter_mozilla_components.components.BackgroundServices import eu.weblibre.flutter_mozilla_components.components.Events import eu.weblibre.flutter_mozilla_components.components.Features import eu.weblibre.flutter_mozilla_components.components.Search @@ -19,6 +20,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController import mozilla.components.concept.engine.EngineView @@ -38,14 +40,38 @@ class Components(val profileApplicationContext: ProfileContext, val logLevel: Log.Priority, val contentBlocking: ContentBlocking, val addonCollection: AddonCollection?, + val fxaServerOverride: String?, + val syncTokenServerOverride: String?, val addonEvents: GeckoAddonEvents, private val tabContentEvents: GeckoTabContentEvents, - private val extensionEvents: BrowserExtensionEvents + private val extensionEvents: BrowserExtensionEvents, + private val syncStateEvents: GeckoSyncStateEvents?, ) { val core by lazy { Core(profileApplicationContext, this, flutterEvents, extensionEvents) } + val backgroundServices by lazy { + BackgroundServices( + context = profileApplicationContext, + browserStore = lazy { core.store }, + historyStorage = core.lazyHistoryStorage, + bookmarkStorage = core.lazyBookmarksStorage, + remoteTabsStorage = core.lazyRemoteTabsStorage, + fxaServerOverride = fxaServerOverride, + syncTokenServerOverride = syncTokenServerOverride, + syncStateEvents = syncStateEvents, + ) + } val events by lazy { Events(flutterEvents) } val useCases by lazy { UseCases(profileApplicationContext, core.engine, core.store, core.webAppShortcutManager) } - val services by lazy { Services(profileApplicationContext, core.store, useCases.tabsUseCases) } + val services by lazy { + Services( + profileApplicationContext, + core.store, + useCases.tabsUseCases, + backgroundServices.accountManager, + core.engine, + backgroundServices.serverConfig, + ) + } val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) } val search by lazy { Search(profileApplicationContext, core, useCases) } @@ -68,4 +94,4 @@ class Components(val profileApplicationContext: ProfileContext, val dateTimeProvider: DateTimeProvider by lazy { DefaultDateTimeProvider() } val downloadEstimator: DownloadEstimator by lazy { DownloadEstimator(dateTimeProvider = dateTimeProvider) } -} \ No newline at end of file +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 51aded03..9690921c 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -15,6 +15,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping @@ -26,6 +27,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider import mozilla.components.browser.state.action.CustomTabListAction import mozilla.components.browser.state.selector.findCustomTab @@ -114,9 +116,12 @@ object GlobalComponents { addonEvents: GeckoAddonEvents, tabContentEvents: GeckoTabContentEvents, extensionEvents: BrowserExtensionEvents, + syncStateEvents: GeckoSyncStateEvents?, logLevel: Log.Priority, contentBlocking: ContentBlocking, addonCollection: AddonCollection?, + fxaServerOverride: String?, + syncTokenServerOverride: String?, mode: ComponentsMode = ComponentsMode.FULL, ) { Logger.debug("Creating new components") @@ -137,18 +142,30 @@ object GlobalComponents { logLevel, contentBlocking, addonCollection, + fxaServerOverride, + syncTokenServerOverride, addonEvents, tabContentEvents, extensionEvents, + syncStateEvents, ) _components = newComponents currentMode = mode + previousComponents?.let { + runCatching { + it.backgroundServices.accountManager.close() + } + } + //newComponents.crashReporter.install(applicationContext) //Facts.registerProcessor(LogFactProcessor()) - //RustHttpConfig.setClient(lazy { newComponents.core.client }) + val megazordNetworkSetup = MegazordSetup.setupMegazordNetwork( + context = newComponents.profileApplicationContext, + client = lazy { newComponents.core.client }, + ) if (mode == ComponentsMode.FULL) { newComponents.core.engine.warmUp() @@ -167,6 +184,12 @@ object GlobalComponents { } if (mode == ComponentsMode.FULL) { + if (!megazordNetworkSetup.isCompleted) { + runBlocking { + megazordNetworkSetup.await() + } + } + val restoreJob = restoreBrowserState(newComponents) if (previousCustomTabs.isNotEmpty()) { restoreJob.invokeOnCompletion { @@ -215,6 +238,12 @@ object GlobalComponents { GlobalScope.launch(Dispatchers.IO) { newComponents.core.fileUploadsDirCleaner.cleanUploadsDirectory() } + + // Eagerly initialize account manager so sync starts + newComponents.backgroundServices.accountManager + + // Start FxA web channel feature for OAuth redirect handling + newComponents.services.fxaWebChannelFeature.start() } else { restorePreviousCustomTabs() } @@ -256,9 +285,12 @@ object GlobalComponents { addonEvents = GeckoAddonEvents(messenger), tabContentEvents = GeckoTabContentEvents(messenger), extensionEvents = BrowserExtensionEvents(messenger), + syncStateEvents = null, logLevel = logLevel, contentBlocking = contentBlocking, addonCollection = null, + fxaServerOverride = null, + syncTokenServerOverride = null, mode = ComponentsMode.EXTERNAL, ) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/MegazordSetup.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/MegazordSetup.kt new file mode 100644 index 00000000..55ae2597 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/MegazordSetup.kt @@ -0,0 +1,32 @@ +package eu.weblibre.flutter_mozilla_components + +import android.content.Context +import android.content.pm.ApplicationInfo +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import mozilla.components.concept.fetch.Client +import mozilla.components.support.AppServicesInitializer +import mozilla.components.support.AppServicesInitializer.Config as AppServicesConfig +import mozilla.components.support.rusthttp.RustHttpConfig + +object MegazordSetup { + fun setupEarlyMainProcess() { + AppServicesInitializer.init(AppServicesConfig(null)) + } + + @DelicateCoroutinesApi + fun setupMegazordNetwork(context: Context, client: Lazy): Deferred = + GlobalScope.async(Dispatchers.IO) { + val isDebuggable = + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + + if (isDebuggable) { + RustHttpConfig.allowEmulatorLoopback() + } + + RustHttpConfig.setClient(client) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ProfileContext.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ProfileContext.kt index f250970d..4adfdb5d 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ProfileContext.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/ProfileContext.kt @@ -43,6 +43,7 @@ class ProfileContext(private val base: Context, val relativePath: String) : } init { + ActiveProfile.prefix = profilePrefix customFilesDir.mkdirs() customNoBackupFilesDir.mkdirs() customObbDir.mkdirs() diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/AuthCustomTabActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/AuthCustomTabActivity.kt new file mode 100644 index 00000000..2c42dc6f --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/AuthCustomTabActivity.kt @@ -0,0 +1,30 @@ +package eu.weblibre.flutter_mozilla_components.activities + +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.OAuthAccount +import eu.weblibre.flutter_mozilla_components.GlobalComponents + +class AuthCustomTabActivity : ExternalAppBrowserActivity() { + private val accountStateObserver = object : AccountObserver { + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { + finish() + } + } + + override fun onResume() { + super.onResume() + GlobalComponents.components + ?.backgroundServices + ?.accountManager + ?.register(accountStateObserver, this, true) + } + + override fun onDestroy() { + GlobalComponents.components + ?.backgroundServices + ?.accountManager + ?.unregister(accountStateObserver) + super.onDestroy() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/AuthIntentReceiverActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/AuthIntentReceiverActivity.kt new file mode 100644 index 00000000..0085bc72 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/AuthIntentReceiverActivity.kt @@ -0,0 +1,54 @@ +package eu.weblibre.flutter_mozilla_components.activities + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Log +import mozilla.components.feature.customtabs.CustomTabIntentProcessor +import mozilla.components.feature.intent.ext.getSessionId +import eu.weblibre.flutter_mozilla_components.GlobalComponents + +class AuthIntentReceiverActivity : Activity() { + companion object { + private const val TAG = "AuthIntentReceiver" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val sourceIntent = intent?.let { Intent(it) } ?: Intent() + + if (GlobalComponents.components == null && !GlobalComponents.ensureExternalComponents(applicationContext)) { + finish() + return + } + + val components = GlobalComponents.components + if (components == null) { + finish() + return + } + + val processed = CustomTabIntentProcessor( + components.useCases.customTabsUseCases.add, + resources, + isPrivate = false, + ).process(sourceIntent) + + if (processed) { + val sessionId = sourceIntent.getSessionId() ?: components.core.store.state.customTabs.lastOrNull()?.id + if (sessionId != null) { + val authIntent = ExternalAppBrowserActivity + .createIntent(this, sessionId) + .setClassName(this, AuthCustomTabActivity::class.java.name) + startActivity(authIntent) + } else { + Log.w(TAG, "Auth intent processed but no custom tab session id found") + } + } else { + Log.w(TAG, "Auth custom tab intent was not processed") + } + + finish() + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt index 8f4ea18f..ad16057e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/activities/ExternalAppBrowserActivity.kt @@ -34,7 +34,7 @@ import mozilla.components.support.base.log.logger.Logger * * Uses an empty taskAffinity so Custom Tabs appear as a separate task from the main app. */ -class ExternalAppBrowserActivity : AppCompatActivity() { +open class ExternalAppBrowserActivity : AppCompatActivity() { companion object { private const val TAG = "ExternalAppBrowserActivity" diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index 310bb1d4..36d33c87 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -47,6 +47,8 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabsApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportApi @@ -155,7 +157,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { profileFolder: String, logLevel: LogLevel, contentBlocking: ContentBlocking, - addonCollection: AddonCollection? + addonCollection: AddonCollection?, + fxaServerOverride: String?, + syncTokenServerOverride: String?, ) { synchronized(this) { if (!isGeckoInitialized) { @@ -170,7 +174,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { Log.addSink(PriorityAwareLogSink(level, geckoLogging)) - setupGeckoEngine(profileFolder, level, contentBlocking, addonCollection) + setupGeckoEngine( + profileFolder, + level, + contentBlocking, + addonCollection, + fxaServerOverride, + syncTokenServerOverride, + ) isGeckoInitialized = true } } @@ -190,7 +201,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { profileFolder: String, logLevel: Log.Priority, contentBlocking: ContentBlocking, - addonCollection: AddonCollection? + addonCollection: AddonCollection?, + fxaServerOverride: String?, + syncTokenServerOverride: String?, ) { val profileApplicationContext = ProfileContext(_flutterPluginBinding.applicationContext, profileFolder) @@ -220,6 +233,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GeckoSuggestionApiImpl(suggestionEvents) ) + val syncStateEvents = GeckoSyncStateEvents(_flutterPluginBinding.binaryMessenger) + GlobalComponents.setUp( profileApplicationContext, _flutterEvents, @@ -228,9 +243,12 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { addonEvents, tabContentEvents, extensionEvents, + syncStateEvents, logLevel, contentBlocking, - addonCollection + addonCollection, + fxaServerOverride, + syncTokenServerOverride, ) val engineSettingsApiImpl = GeckoEngineSettingsApiImpl() @@ -275,6 +293,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GeckoPublicSuffixListApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPublicSuffixListApiImpl(profileApplicationContext)) GeckoTrackingProtectionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTrackingProtectionApiImpl()) GeckoAppLinksApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAppLinksApiImpl(profileApplicationContext)) + GeckoSyncApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSyncApiImpl()) // PWA API for web app installation and management GeckoPwaApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPwaApiImpl(profileApplicationContext)) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSyncApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSyncApiImpl.kt new file mode 100644 index 00000000..6c28d355 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoSyncApiImpl.kt @@ -0,0 +1,346 @@ +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.GlobalComponents +import eu.weblibre.flutter_mozilla_components.components.WebLibreFxAEntryPoint +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncApi +import eu.weblibre.flutter_mozilla_components.pigeons.SyncAccountInfo +import eu.weblibre.flutter_mozilla_components.pigeons.SyncDevice +import eu.weblibre.flutter_mozilla_components.pigeons.SyncDeviceTabs +import eu.weblibre.flutter_mozilla_components.pigeons.SyncEngineStatus +import eu.weblibre.flutter_mozilla_components.pigeons.SyncEngineValue +import eu.weblibre.flutter_mozilla_components.pigeons.SyncIncomingTab +import eu.weblibre.flutter_mozilla_components.pigeons.SyncRemoteTab +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import mozilla.components.concept.sync.DeviceCapability +import mozilla.components.concept.sync.DeviceCommandOutgoing +import mozilla.components.concept.sync.TabData +import mozilla.components.service.fxa.SyncEngine +import mozilla.components.service.fxa.manager.SCOPE_PROFILE +import mozilla.components.service.fxa.manager.SCOPE_SYNC +import mozilla.components.service.fxa.manager.SyncEnginesStorage +import mozilla.components.service.fxa.sync.SyncReason +import mozilla.components.service.fxa.sync.getLastSynced + +class GeckoSyncApiImpl : GeckoSyncApi { + companion object { + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + } + + private val components by lazy { + requireNotNull(GlobalComponents.components) { "Components not initialized" } + } + + override fun getAccountInfo(callback: (Result) -> Unit) { + coroutineScope.launch { + try { + val accountManager = components.backgroundServices.accountManager + val account = accountManager.authenticatedAccount() + val needsReauth = accountManager.accountNeedsReauth() + val profile = account?.getProfile() + val engineStorage = SyncEnginesStorage(components.profileApplicationContext) + val engineStatus = engineStorage.getStatus() + + callback( + Result.success( + SyncAccountInfo( + authenticated = account != null && !needsReauth, + syncing = accountManager.isSyncActive(), + needsReauth = needsReauth, + email = profile?.email, + displayName = profile?.displayName, + lastSyncedAt = getLastSynced(components.profileApplicationContext) + .takeIf { it > 0L }, + engines = listOf( + SyncEngineStatus( + engine = SyncEngineValue.HISTORY, + enabled = engineStatus[SyncEngine.History] ?: true, + ), + SyncEngineStatus( + engine = SyncEngineValue.BOOKMARKS, + enabled = engineStatus[SyncEngine.Bookmarks] ?: true, + ), + SyncEngineStatus( + engine = SyncEngineValue.TABS, + enabled = engineStatus[SyncEngine.Tabs] ?: true, + ), + ), + ), + ), + ) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } + + override fun beginAuthentication(callback: (Result) -> Unit) { + coroutineScope.launch { + runCatching { + components.backgroundServices.awaitStarted() + components.services.accountsAuthFeature.beginAuthentication( + context = components.profileApplicationContext, + entrypoint = WebLibreFxAEntryPoint.Settings, + scopes = setOf(SCOPE_PROFILE, SCOPE_SYNC), + ) + }.fold( + onSuccess = { callback(Result.success(Unit)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun beginPairingAuthentication( + pairingUrl: String, + callback: (Result) -> Unit, + ) { + coroutineScope.launch { + runCatching { + components.backgroundServices.awaitStarted() + components.services.accountsAuthFeature.beginPairingAuthentication( + context = components.profileApplicationContext, + pairingUrl = pairingUrl, + entrypoint = WebLibreFxAEntryPoint.Settings, + scopes = setOf(SCOPE_PROFILE, SCOPE_SYNC), + ) + }.fold( + onSuccess = { callback(Result.success(Unit)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun logout(callback: (Result) -> Unit) { + coroutineScope.launch { + runCatching { + components.backgroundServices.accountManager.logout() + }.fold( + onSuccess = { callback(Result.success(Unit)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun syncNow(callback: (Result) -> Unit) { + coroutineScope.launch { + runCatching { + components.backgroundServices.accountManager.syncNow(SyncReason.User) + }.fold( + onSuccess = { callback(Result.success(Unit)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun setEngineEnabled( + engine: SyncEngineValue, + enabled: Boolean, + callback: (Result) -> Unit, + ) { + coroutineScope.launch { + runCatching { + val storage = SyncEnginesStorage(components.profileApplicationContext) + val mapped = when (engine) { + SyncEngineValue.HISTORY -> SyncEngine.History + SyncEngineValue.BOOKMARKS -> SyncEngine.Bookmarks + SyncEngineValue.TABS -> SyncEngine.Tabs + } + + storage.setStatus(mapped, enabled) + components.backgroundServices.accountManager.syncNow(SyncReason.EngineChange) + }.fold( + onSuccess = { callback(Result.success(Unit)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun getSyncedTabs(callback: (Result>) -> Unit) { + coroutineScope.launch { + runCatching { + val deviceNames = loadDeviceDisplayNames() + components.core.remoteTabsStorage.getAll().entries.mapNotNull { (client, tabs) -> + val deviceName = deviceNames[client.id] ?: return@mapNotNull null + SyncDeviceTabs( + deviceId = client.id, + deviceName = deviceName, + tabs = tabs.map { tab -> + val active = tab.active() + SyncRemoteTab( + title = active.title, + url = active.url, + iconUrl = active.iconUrl, + lastUsed = tab.lastUsed, + inactive = tab.inactive, + ) + }.sortedByDescending { it.lastUsed }, + ) + }.sortedBy { it.deviceName.lowercase() } + }.fold( + onSuccess = { callback(Result.success(it)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun getDevices(callback: (Result>) -> Unit) { + coroutineScope.launch { + runCatching { + components.backgroundServices.awaitStarted() + val account = components.backgroundServices.accountManager.authenticatedAccount() + ?: return@runCatching emptyList() + + val constellation = account.deviceConstellation() + constellation.refreshDevices() + val state = constellation.state() + val currentDevice = state?.currentDevice + val otherDevices = state?.otherDevices.orEmpty() + + (listOfNotNull(currentDevice) + otherDevices).map { device -> + SyncDevice( + deviceId = device.id, + displayName = device.displayName, + isCurrentDevice = device.isCurrentDevice, + canSendTab = device.capabilities.contains(DeviceCapability.SEND_TAB), + ) + }.sortedBy { it.displayName.lowercase() } + }.fold( + onSuccess = { callback(Result.success(it)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun sendTabToDevice( + deviceId: String, + title: String, + url: String, + callback: (Result) -> Unit, + ) { + coroutineScope.launch { + runCatching { + components.backgroundServices.awaitStarted() + val account = components.backgroundServices.accountManager.authenticatedAccount() + ?: return@runCatching false + + val constellation = account.deviceConstellation() + constellation.refreshDevices() + val state = constellation.state() + val target = state?.otherDevices?.firstOrNull { + it.id == deviceId && it.capabilities.contains(DeviceCapability.SEND_TAB) + } ?: return@runCatching false + + constellation.sendCommandToDevice( + target.id, + DeviceCommandOutgoing.SendTab( + title = title, + url = url, + ), + ) + }.fold( + onSuccess = { callback(Result.success(it)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun refreshDevices(callback: (Result) -> Unit) { + coroutineScope.launch { + runCatching { + components.backgroundServices.awaitStarted() + val account = components.backgroundServices.accountManager.authenticatedAccount() + ?: return@runCatching + + account.deviceConstellation().refreshDevices() + }.fold( + onSuccess = { callback(Result.success(Unit)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun pollDeviceCommands(callback: (Result) -> Unit) { + coroutineScope.launch { + runCatching { + components.backgroundServices.awaitStarted() + val account = components.backgroundServices.accountManager.authenticatedAccount() + ?: return@runCatching + + account.deviceConstellation().pollForCommands() + }.fold( + onSuccess = { callback(Result.success(Unit)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun drainIncomingTabs(callback: (Result>) -> Unit) { + coroutineScope.launch { + runCatching { + components.backgroundServices.drainIncomingTabs().map { + SyncIncomingTab( + title = it.title, + url = it.url, + fromDeviceId = it.fromDeviceId, + fromDeviceName = it.fromDeviceName, + ) + } + }.fold( + onSuccess = { callback(Result.success(it)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun getDeviceName(callback: (Result) -> Unit) { + coroutineScope.launch { + runCatching { + components.backgroundServices.awaitStarted() + val account = components.backgroundServices.accountManager.authenticatedAccount() + ?: return@runCatching null + + val constellation = account.deviceConstellation() + constellation.refreshDevices() + constellation.state()?.currentDevice?.displayName + }.fold( + onSuccess = { callback(Result.success(it)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + override fun setDeviceName(newName: String, callback: (Result) -> Unit) { + coroutineScope.launch { + runCatching { + val trimmed = newName.trim() + if (trimmed.isEmpty()) { + return@runCatching false + } + + components.backgroundServices.awaitStarted() + val account = components.backgroundServices.accountManager.authenticatedAccount() + ?: return@runCatching false + + account.deviceConstellation() + .setDeviceName(trimmed, components.profileApplicationContext) + }.fold( + onSuccess = { callback(Result.success(it)) }, + onFailure = { callback(Result.failure(it)) }, + ) + } + } + + private suspend fun loadDeviceDisplayNames(): Map { + components.backgroundServices.awaitStarted() + val account = components.backgroundServices.accountManager.authenticatedAccount() + ?: return emptyMap() + + val constellation = account.deviceConstellation() + constellation.refreshDevices() + val state = constellation.state() ?: return emptyMap() + val devices = listOfNotNull(state.currentDevice) + state.otherDevices + return devices.associate { it.id to it.displayName } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/BackgroundServices.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/BackgroundServices.kt new file mode 100644 index 00000000..315da816 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/BackgroundServices.kt @@ -0,0 +1,315 @@ +package eu.weblibre.flutter_mozilla_components.components + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Build +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.Device +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.Profile +import mozilla.components.concept.sync.TabData +import mozilla.components.browser.storage.sync.PlacesBookmarksStorage +import mozilla.components.browser.storage.sync.PlacesHistoryStorage +import mozilla.components.browser.storage.sync.RemoteTabsStorage +import mozilla.components.concept.sync.DeviceConfig +import mozilla.components.concept.sync.DeviceCapability +import mozilla.components.concept.sync.DeviceType +import mozilla.components.feature.accounts.push.SendTabFeature +import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage +import mozilla.components.service.fxa.PeriodicSyncConfig +import mozilla.components.service.fxa.ServerConfig +import mozilla.components.service.fxa.SyncConfig +import mozilla.components.service.fxa.SyncEngine +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.manager.SCOPE_SESSION +import mozilla.components.service.fxa.manager.SCOPE_SYNC +import mozilla.components.service.fxa.manager.SyncEnginesStorage +import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider +import mozilla.components.service.fxa.sync.SyncReason +import mozilla.components.service.fxa.sync.SyncStatusObserver +import mozilla.components.service.fxa.sync.getLastSynced +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents +import eu.weblibre.flutter_mozilla_components.pigeons.SyncAccountInfo +import eu.weblibre.flutter_mozilla_components.pigeons.SyncEngineStatus +import eu.weblibre.flutter_mozilla_components.pigeons.SyncEngineValue +import eu.weblibre.flutter_mozilla_components.sync.SyncedTabsIntegration +import mozilla.components.browser.state.store.BrowserStore +import androidx.lifecycle.ProcessLifecycleOwner +import org.mozilla.gecko.util.ThreadUtils.runOnUiThread +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.TimeUnit + +class BackgroundServices( + private val context: Context, + private val browserStore: Lazy, + private val historyStorage: Lazy, + private val bookmarkStorage: Lazy, + private val remoteTabsStorage: Lazy, + private val fxaServerOverride: String?, + private val syncTokenServerOverride: String?, + private val syncStateEvents: GeckoSyncStateEvents?, +) { + companion object { + private const val MIN_STARTUP_SYNC_INTERVAL_MS = 15 * 60 * 1000L + private val MAX_ACTIVE_TIME_MS = TimeUnit.DAYS.toMillis(14L) + } + + data class IncomingTab( + val title: String, + val url: String, + val fromDeviceId: String?, + val fromDeviceName: String?, + ) + + private val incomingTabsLock = Any() + private val incomingTabsQueue = ArrayDeque() + private val startedSignal = CompletableDeferred() + private val authStateScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val authStateLock = Any() + private var lastAuthState: SyncAccountInfo? = null + private val isDebuggable = + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + + private val supportedEngines = setOf( + SyncEngine.History, + SyncEngine.Bookmarks, + SyncEngine.Tabs, + ) + + private val syncConfig = SyncConfig( + supportedEngines, + periodicSyncConfig = PeriodicSyncConfig(periodMinutes = 240), + ) + + val syncedTabsStorage by lazy { + SyncedTabsStorage( + accountManager, + browserStore.value, + remoteTabsStorage.value, + MAX_ACTIVE_TIME_MS, + ) + } + + val serverConfig: ServerConfig = FxaServer.config( + context = context, + serverOverride = fxaServerOverride, + tokenServerOverride = syncTokenServerOverride, + ) + + private val deviceConfig = DeviceConfig( + name = "WebLibre ${Build.MANUFACTURER} ${Build.MODEL}", + type = DeviceType.MOBILE, + capabilities = setOf(DeviceCapability.SEND_TAB, DeviceCapability.CLOSE_TABS), + secureStateAtRest = true, + ) + + init { + GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage) + GlobalSyncableStoreProvider.configureStore(SyncEngine.Bookmarks to bookmarkStorage) + GlobalSyncableStoreProvider.configureStore(SyncEngine.Tabs to remoteTabsStorage) + } + + private val sequenceCounter = AtomicLong(0) + private val syncedTabsIntegrationLaunched = AtomicBoolean(false) + + private fun dispatchAuthState(account: OAuthAccount?, needsReauth: Boolean = false) { + val events = syncStateEvents ?: return + authStateScope.launch { + val profile = account?.getProfile() + val engineStorage = SyncEnginesStorage(context) + val engineStatus = engineStorage.getStatus() + + val info = SyncAccountInfo( + authenticated = account != null && !needsReauth, + syncing = accountManager.isSyncActive(), + needsReauth = needsReauth, + email = profile?.email, + displayName = profile?.displayName, + lastSyncedAt = getLastSynced(context).takeIf { it > 0L }, + engines = listOf( + SyncEngineStatus( + engine = SyncEngineValue.HISTORY, + enabled = engineStatus[SyncEngine.History] ?: true, + ), + SyncEngineStatus( + engine = SyncEngineValue.BOOKMARKS, + enabled = engineStatus[SyncEngine.Bookmarks] ?: true, + ), + SyncEngineStatus( + engine = SyncEngineValue.TABS, + enabled = engineStatus[SyncEngine.Tabs] ?: true, + ), + ), + ) + + val shouldEmit = synchronized(authStateLock) { + if (lastAuthState == info) { + false + } else { + lastAuthState = info + true + } + } + + if (!shouldEmit) { + return@launch + } + + runOnUiThread { + events.onAuthStateChanged(sequenceCounter.incrementAndGet(), info) { _ -> } + } + } + } + + val accountManager: FxaAccountManager by lazy { + FxaAccountManager( + context = context, + serverConfig = serverConfig, + deviceConfig = deviceConfig, + syncConfig = syncConfig, + applicationScopes = setOf(SCOPE_SYNC, SCOPE_SESSION), + crashReporter = null, + ).also { accountManager -> + SendTabFeature(accountManager) { device: Device?, tabs: List -> + synchronized(incomingTabsLock) { + tabs.forEach { tab -> + incomingTabsQueue.addLast( + IncomingTab( + title = tab.title, + url = tab.url, + fromDeviceId = device?.id, + fromDeviceName = device?.displayName, + ), + ) + } + } + } + + accountManager.register(object : AccountObserver { + override fun onReady(authenticatedAccount: OAuthAccount?) { + if (!startedSignal.isCompleted) { + startedSignal.complete(Unit) + } + } + + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { + dispatchAuthState(account) + } + + override fun onAuthenticationProblems() { + dispatchAuthState(accountManager.authenticatedAccount(), needsReauth = true) + } + + override fun onLoggedOut() { + dispatchAuthState(null) + } + + override fun onProfileUpdated(profile: Profile) { + dispatchAuthState(accountManager.authenticatedAccount()) + } + + override fun onFlowError(error: mozilla.components.concept.sync.AuthFlowError) { + dispatchAuthState(accountManager.authenticatedAccount(), needsReauth = true) + } + }) + + accountManager.registerForSyncEvents(object : SyncStatusObserver { + override fun onStarted() { + val events = syncStateEvents ?: return + dispatchAuthState(accountManager.authenticatedAccount()) + runOnUiThread { + events.onSyncStarted(sequenceCounter.incrementAndGet()) { _ -> } + } + } + + override fun onIdle() { + val events = syncStateEvents ?: return + dispatchAuthState(accountManager.authenticatedAccount()) + runOnUiThread { + events.onSyncCompleted(sequenceCounter.incrementAndGet()) { _ -> } + } + } + + override fun onError(error: Exception?) { + val events = syncStateEvents ?: return + dispatchAuthState(accountManager.authenticatedAccount()) + runOnUiThread { + events.onSyncError( + sequenceCounter.incrementAndGet(), + error?.message, + ) { _ -> } + } + } + }, owner = ProcessLifecycleOwner.get(), autoPause = false) + + MainScope().launch { + runCatching { + accountManager.start() + }.fold( + onSuccess = {}, + onFailure = { error -> + val isDuplicateInitialize = error.message + ?.contains("Initialize already sent", ignoreCase = true) + ?: false + + if (isDuplicateInitialize) { + return@fold + } + + if (!startedSignal.isCompleted) { + startedSignal.completeExceptionally(error) + } + throw error + }, + ) + if (accountManager.authenticatedAccount() != null && shouldSyncOnStartup()) { + accountManager.syncNow(SyncReason.Startup) + } + } + } + } + + suspend fun awaitStarted() { + accountManager + launchSyncedTabsIntegrationIfNeeded() + withTimeout(10_000) { + startedSignal.await() + } + } + + private fun launchSyncedTabsIntegrationIfNeeded() { + if (syncedTabsIntegrationLaunched.compareAndSet(false, true)) { + SyncedTabsIntegration(accountManager, syncedTabsStorage).launch() + } + } + + private fun shouldSyncOnStartup(): Boolean { + val lastSynced = getLastSynced(context) + if (lastSynced <= 0L) { + return true + } + + return (System.currentTimeMillis() - lastSynced) >= MIN_STARTUP_SYNC_INTERVAL_MS + } + + fun drainIncomingTabs(): List { + synchronized(incomingTabsLock) { + if (incomingTabsQueue.isEmpty()) { + return emptyList() + } + + val values = incomingTabsQueue.toList() + incomingTabsQueue.clear() + return values + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt index 6d17e3a5..f2dc4b87 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt @@ -33,6 +33,7 @@ import mozilla.components.browser.state.engine.middleware.SessionPrioritizationM import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.storage.sync.PlacesBookmarksStorage import mozilla.components.browser.storage.sync.PlacesHistoryStorage +import mozilla.components.browser.storage.sync.RemoteTabsStorage import mozilla.components.browser.thumbnails.ThumbnailsMiddleware import mozilla.components.browser.thumbnails.storage.ThumbnailStorage import mozilla.components.concept.engine.DefaultSettings @@ -62,8 +63,11 @@ import mozilla.components.feature.session.middleware.LastAccessMiddleware import mozilla.components.feature.session.middleware.undo.UndoMiddleware import mozilla.components.feature.sitepermissions.OnDiskSitePermissionsStorage import mozilla.components.feature.webnotifications.WebNotificationFeature +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.concept.base.crash.CrashReporting import mozilla.components.support.base.worker.Frequency import org.mozilla.geckoview.GeckoRuntime +import kotlinx.coroutines.Job import java.util.concurrent.TimeUnit private const val AMO_COLLECTION_MAX_CACHE_AGE = 24 * 60L @@ -74,6 +78,12 @@ class Core( private val flutterEvents: GeckoStateEvents, private val extensionEvents: BrowserExtensionEvents ) { + private val noOpCrashReporter = object : CrashReporting { + override fun submitCaughtException(throwable: Throwable): Job = Job() + + override fun recordCrashBreadcrumb(breadcrumb: Breadcrumb) = Unit + } + val prefs by lazy { PreferenceManager.getDefaultSharedPreferences(context) } @@ -251,12 +261,14 @@ class Core( */ val lazyHistoryStorage = lazy { PlacesHistoryStorage(context) } val lazyBookmarksStorage = lazy { PlacesBookmarksStorage(context) } + val lazyRemoteTabsStorage = lazy { RemoteTabsStorage(context, noOpCrashReporter) } /** * A convenience accessor to the [PlacesHistoryStorage]. */ val historyStorage by lazy { lazyHistoryStorage.value } val bookmarksStorage by lazy { lazyBookmarksStorage.value } + val remoteTabsStorage by lazy { lazyRemoteTabsStorage.value } val permissionStorage by lazy { PermissionStorage(geckoSitePermissionsStorage) } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/FxaServer.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/FxaServer.kt new file mode 100644 index 00000000..c318fe89 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/FxaServer.kt @@ -0,0 +1,30 @@ +package eu.weblibre.flutter_mozilla_components.components + +import android.content.Context +import mozilla.appservices.fxaclient.FxaServer as AppServicesFxaServer +import mozilla.components.service.fxa.ServerConfig + +object FxaServer { + private const val CLIENT_ID = "a2270f727f45f648" + const val REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel" + + fun config( + context: Context, + serverOverride: String?, + tokenServerOverride: String? + ): ServerConfig { + val effectiveServerOverride = serverOverride?.trim().orEmpty() + val effectiveTokenOverride = tokenServerOverride?.trim().takeUnless { it.isNullOrEmpty() } + + return if (effectiveServerOverride.isEmpty()) { + ServerConfig(AppServicesFxaServer.Release, CLIENT_ID, REDIRECT_URL, effectiveTokenOverride) + } else { + ServerConfig( + AppServicesFxaServer.Custom(effectiveServerOverride), + CLIENT_ID, + REDIRECT_URL, + effectiveTokenOverride, + ) + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt index b4efdbb7..2780713f 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Services.kt @@ -5,13 +5,26 @@ package eu.weblibre.flutter_mozilla_components.components import android.content.Context +import android.content.Intent +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.net.toUri import androidx.preference.PreferenceManager import eu.weblibre.flutter_mozilla_components.GlobalComponents import eu.weblibre.flutter_mozilla_components.R +import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.Engine +import mozilla.components.feature.accounts.FirefoxAccountsAuthFeature +import mozilla.components.feature.accounts.FxaCapability +import mozilla.components.feature.accounts.FxaWebChannelFeature import mozilla.components.feature.app.links.AppLinksInterceptor import mozilla.components.feature.tabs.TabsUseCases +import mozilla.components.service.fxa.ServerConfig +import mozilla.components.service.fxa.manager.FxaAccountManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Component group which encapsulates foreground-friendly services. @@ -20,9 +33,39 @@ class Services( private val context: Context, private val store: BrowserStore, private val tabsUseCases: TabsUseCases, + accountManager: FxaAccountManager, + private val engine: Engine, + private val serverConfig: ServerConfig, ) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val accountsAuthFeature by lazy { + FirefoxAccountsAuthFeature(accountManager, FxaServer.REDIRECT_URL) { _, authUrl -> + CoroutineScope(Dispatchers.Main).launch { + val intent = CustomTabsIntent.Builder() + .setInstantAppsEnabled(false) + .build() + .intent + .setData(authUrl.toUri()) + .setClassName(context, AuthIntentReceiverActivity::class.java.name) + .setPackage(context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + } + } + + val fxaWebChannelFeature by lazy { + FxaWebChannelFeature( + customTabSessionId = null, + runtime = engine, + store = store, + accountManager = accountManager, + serverConfig = serverConfig, + fxaCapabilities = setOf(FxaCapability.CHOOSE_WHAT_TO_SYNC), + ) + } + val appLinksInterceptor by lazy { AppLinksInterceptor( context = context, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/WebLibreFxAEntryPoint.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/WebLibreFxAEntryPoint.kt new file mode 100644 index 00000000..a70f283f --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/WebLibreFxAEntryPoint.kt @@ -0,0 +1,7 @@ +package eu.weblibre.flutter_mozilla_components.components + +import mozilla.components.concept.sync.FxAEntryPoint + +enum class WebLibreFxAEntryPoint(override val entryName: String) : FxAEntryPoint { + Settings("settings"), +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt index 9815b60c..848aea8c 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/interceptor/AppRequestInterceptor.kt @@ -42,6 +42,19 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor { return null } + components.services.accountsAuthFeature.interceptor.onLoadRequest( + engineSession, + uri, + lastUri, + hasUserGesture, + isSameDomain, + isRedirect, + isDirectNavigation, + isSubframeRequest, + )?.let { + return it + } + return components.services.appLinksInterceptor.onLoadRequest( engineSession, uri, @@ -64,4 +77,4 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor { } override fun interceptsAppInitiatedRequests() = true -} \ No newline at end of file +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index 536e5caa..922251c5 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -450,6 +450,18 @@ enum class LogLevel(val raw: Int) { } } +enum class SyncEngineValue(val raw: Int) { + HISTORY(0), + BOOKMARKS(1), + TABS(2); + + companion object { + fun ofRaw(raw: Int): SyncEngineValue? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** Type of ML model operation */ enum class MlProgressType(val raw: Int) { DOWNLOADING(0), @@ -2499,6 +2511,231 @@ data class AddonCollection ( override fun hashCode(): Int = toList().hashCode() } +/** Generated class from Pigeon that represents data sent in messages. */ +data class SyncEngineStatus ( + val engine: SyncEngineValue, + val enabled: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): SyncEngineStatus { + val engine = pigeonVar_list[0] as SyncEngineValue + val enabled = pigeonVar_list[1] as Boolean + return SyncEngineStatus(engine, enabled) + } + } + fun toList(): List { + return listOf( + engine, + enabled, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SyncEngineStatus) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SyncAccountInfo ( + val authenticated: Boolean, + val syncing: Boolean, + val needsReauth: Boolean, + val email: String? = null, + val displayName: String? = null, + val lastSyncedAt: Long? = null, + val engines: List +) + { + companion object { + fun fromList(pigeonVar_list: List): SyncAccountInfo { + val authenticated = pigeonVar_list[0] as Boolean + val syncing = pigeonVar_list[1] as Boolean + val needsReauth = pigeonVar_list[2] as Boolean + val email = pigeonVar_list[3] as String? + val displayName = pigeonVar_list[4] as String? + val lastSyncedAt = pigeonVar_list[5] as Long? + val engines = pigeonVar_list[6] as List + return SyncAccountInfo(authenticated, syncing, needsReauth, email, displayName, lastSyncedAt, engines) + } + } + fun toList(): List { + return listOf( + authenticated, + syncing, + needsReauth, + email, + displayName, + lastSyncedAt, + engines, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SyncAccountInfo) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SyncDevice ( + val deviceId: String, + val displayName: String, + val isCurrentDevice: Boolean, + val canSendTab: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): SyncDevice { + val deviceId = pigeonVar_list[0] as String + val displayName = pigeonVar_list[1] as String + val isCurrentDevice = pigeonVar_list[2] as Boolean + val canSendTab = pigeonVar_list[3] as Boolean + return SyncDevice(deviceId, displayName, isCurrentDevice, canSendTab) + } + } + fun toList(): List { + return listOf( + deviceId, + displayName, + isCurrentDevice, + canSendTab, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SyncDevice) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SyncIncomingTab ( + val title: String, + val url: String, + val fromDeviceId: String? = null, + val fromDeviceName: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): SyncIncomingTab { + val title = pigeonVar_list[0] as String + val url = pigeonVar_list[1] as String + val fromDeviceId = pigeonVar_list[2] as String? + val fromDeviceName = pigeonVar_list[3] as String? + return SyncIncomingTab(title, url, fromDeviceId, fromDeviceName) + } + } + fun toList(): List { + return listOf( + title, + url, + fromDeviceId, + fromDeviceName, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SyncIncomingTab) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SyncRemoteTab ( + val title: String, + val url: String, + val iconUrl: String? = null, + val lastUsed: Long, + val inactive: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): SyncRemoteTab { + val title = pigeonVar_list[0] as String + val url = pigeonVar_list[1] as String + val iconUrl = pigeonVar_list[2] as String? + val lastUsed = pigeonVar_list[3] as Long + val inactive = pigeonVar_list[4] as Boolean + return SyncRemoteTab(title, url, iconUrl, lastUsed, inactive) + } + } + fun toList(): List { + return listOf( + title, + url, + iconUrl, + lastUsed, + inactive, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SyncRemoteTab) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SyncDeviceTabs ( + val deviceId: String, + val deviceName: String, + val tabs: List +) + { + companion object { + fun fromList(pigeonVar_list: List): SyncDeviceTabs { + val deviceId = pigeonVar_list[0] as String + val deviceName = pigeonVar_list[1] as String + val tabs = pigeonVar_list[2] as List + return SyncDeviceTabs(deviceId, deviceName, tabs) + } + } + fun toList(): List { + return listOf( + deviceId, + deviceName, + tabs, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is SyncDeviceTabs) { + return false + } + if (this === other) { + return true + } + return GeckoPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + /** Generated class from Pigeon that represents data sent in messages. */ data class GeckoPref ( val name: String, @@ -3370,345 +3607,380 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { } 151.toByte() -> { return (readValue(buffer) as Long?)?.let { - MlProgressType.ofRaw(it.toInt()) + SyncEngineValue.ofRaw(it.toInt()) } } 152.toByte() -> { return (readValue(buffer) as Long?)?.let { - MlProgressStatus.ofRaw(it.toInt()) + MlProgressType.ofRaw(it.toInt()) } } 153.toByte() -> { return (readValue(buffer) as Long?)?.let { - ClearDataType.ofRaw(it.toInt()) + MlProgressStatus.ofRaw(it.toInt()) } } 154.toByte() -> { return (readValue(buffer) as Long?)?.let { - GeckoFetchMethod.ofRaw(it.toInt()) + ClearDataType.ofRaw(it.toInt()) } } 155.toByte() -> { return (readValue(buffer) as Long?)?.let { - GeckoFetchRedircet.ofRaw(it.toInt()) + GeckoFetchMethod.ofRaw(it.toInt()) } } 156.toByte() -> { return (readValue(buffer) as Long?)?.let { - GeckoFetchCookiePolicy.ofRaw(it.toInt()) + GeckoFetchRedircet.ofRaw(it.toInt()) } } 157.toByte() -> { return (readValue(buffer) as Long?)?.let { - BookmarkNodeType.ofRaw(it.toInt()) + GeckoFetchCookiePolicy.ofRaw(it.toInt()) } } 158.toByte() -> { return (readValue(buffer) as Long?)?.let { - SitePermissionStatus.ofRaw(it.toInt()) + BookmarkNodeType.ofRaw(it.toInt()) } } 159.toByte() -> { return (readValue(buffer) as Long?)?.let { - AutoplayStatus.ofRaw(it.toInt()) + SitePermissionStatus.ofRaw(it.toInt()) } } 160.toByte() -> { - return (readValue(buffer) as? List)?.let { - TranslationOptions.fromList(it) + return (readValue(buffer) as Long?)?.let { + AutoplayStatus.ofRaw(it.toInt()) } } 161.toByte() -> { return (readValue(buffer) as? List)?.let { - ReaderState.fromList(it) + TranslationOptions.fromList(it) } } 162.toByte() -> { return (readValue(buffer) as? List)?.let { - AddTabParams.fromList(it) + ReaderState.fromList(it) } } 163.toByte() -> { return (readValue(buffer) as? List)?.let { - LastMediaAccessState.fromList(it) + AddTabParams.fromList(it) } } 164.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryMetadataKey.fromList(it) + LastMediaAccessState.fromList(it) } } 165.toByte() -> { return (readValue(buffer) as? List)?.let { - PackageCategoryValue.fromList(it) + HistoryMetadataKey.fromList(it) } } 166.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalPackage.fromList(it) + PackageCategoryValue.fromList(it) } } 167.toByte() -> { return (readValue(buffer) as? List)?.let { - LoadUrlFlagsValue.fromList(it) + ExternalPackage.fromList(it) } } 168.toByte() -> { return (readValue(buffer) as? List)?.let { - SourceValue.fromList(it) + LoadUrlFlagsValue.fromList(it) } } 169.toByte() -> { return (readValue(buffer) as? List)?.let { - TabState.fromList(it) + SourceValue.fromList(it) } } 170.toByte() -> { return (readValue(buffer) as? List)?.let { - RecoverableTab.fromList(it) + TabState.fromList(it) } } 171.toByte() -> { return (readValue(buffer) as? List)?.let { - RecoverableBrowserState.fromList(it) + RecoverableTab.fromList(it) } } 172.toByte() -> { return (readValue(buffer) as? List)?.let { - IconRequest.fromList(it) + RecoverableBrowserState.fromList(it) } } 173.toByte() -> { return (readValue(buffer) as? List)?.let { - ResourceSize.fromList(it) + IconRequest.fromList(it) } } 174.toByte() -> { return (readValue(buffer) as? List)?.let { - Resource.fromList(it) + ResourceSize.fromList(it) } } 175.toByte() -> { return (readValue(buffer) as? List)?.let { - IconResult.fromList(it) + Resource.fromList(it) } } 176.toByte() -> { return (readValue(buffer) as? List)?.let { - CookiePartitionKey.fromList(it) + IconResult.fromList(it) } } 177.toByte() -> { return (readValue(buffer) as? List)?.let { - Cookie.fromList(it) + CookiePartitionKey.fromList(it) } } 178.toByte() -> { return (readValue(buffer) as? List)?.let { - VisitInfo.fromList(it) + Cookie.fromList(it) } } 179.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryItem.fromList(it) + VisitInfo.fromList(it) } } 180.toByte() -> { return (readValue(buffer) as? List)?.let { - HistoryState.fromList(it) + HistoryItem.fromList(it) } } 181.toByte() -> { return (readValue(buffer) as? List)?.let { - ReaderableState.fromList(it) + HistoryState.fromList(it) } } 182.toByte() -> { return (readValue(buffer) as? List)?.let { - SecurityInfoState.fromList(it) + ReaderableState.fromList(it) } } 183.toByte() -> { return (readValue(buffer) as? List)?.let { - TabContentState.fromList(it) + SecurityInfoState.fromList(it) } } 184.toByte() -> { return (readValue(buffer) as? List)?.let { - FindResultState.fromList(it) + TabContentState.fromList(it) } } 185.toByte() -> { return (readValue(buffer) as? List)?.let { - CustomSelectionAction.fromList(it) + FindResultState.fromList(it) } } 186.toByte() -> { return (readValue(buffer) as? List)?.let { - WebExtensionData.fromList(it) + CustomSelectionAction.fromList(it) } } 187.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoSuggestion.fromList(it) + WebExtensionData.fromList(it) } } 188.toByte() -> { return (readValue(buffer) as? List)?.let { - TabContent.fromList(it) + GeckoSuggestion.fromList(it) } } 189.toByte() -> { return (readValue(buffer) as? List)?.let { - ContentBlocking.fromList(it) + TabContent.fromList(it) } } 190.toByte() -> { return (readValue(buffer) as? List)?.let { - DohSettings.fromList(it) + ContentBlocking.fromList(it) } } 191.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoEngineSettings.fromList(it) + DohSettings.fromList(it) } } 192.toByte() -> { return (readValue(buffer) as? List)?.let { - AutocompleteResult.fromList(it) + GeckoEngineSettings.fromList(it) } } 193.toByte() -> { return (readValue(buffer) as? List)?.let { - UnknownHitResult.fromList(it) + AutocompleteResult.fromList(it) } } 194.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageHitResult.fromList(it) + UnknownHitResult.fromList(it) } } 195.toByte() -> { return (readValue(buffer) as? List)?.let { - VideoHitResult.fromList(it) + ImageHitResult.fromList(it) } } 196.toByte() -> { return (readValue(buffer) as? List)?.let { - AudioHitResult.fromList(it) + VideoHitResult.fromList(it) } } 197.toByte() -> { return (readValue(buffer) as? List)?.let { - ImageSrcHitResult.fromList(it) + AudioHitResult.fromList(it) } } 198.toByte() -> { return (readValue(buffer) as? List)?.let { - PhoneHitResult.fromList(it) + ImageSrcHitResult.fromList(it) } } 199.toByte() -> { return (readValue(buffer) as? List)?.let { - EmailHitResult.fromList(it) + PhoneHitResult.fromList(it) } } 200.toByte() -> { return (readValue(buffer) as? List)?.let { - GeoHitResult.fromList(it) + EmailHitResult.fromList(it) } } 201.toByte() -> { return (readValue(buffer) as? List)?.let { - DownloadState.fromList(it) + GeoHitResult.fromList(it) } } 202.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareInternetResourceState.fromList(it) + DownloadState.fromList(it) } } 203.toByte() -> { return (readValue(buffer) as? List)?.let { - AddonCollection.fromList(it) + ShareInternetResourceState.fromList(it) } } 204.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoPref.fromList(it) + AddonCollection.fromList(it) } } 205.toByte() -> { return (readValue(buffer) as? List)?.let { - MlProgressData.fromList(it) + SyncEngineStatus.fromList(it) } } 206.toByte() -> { return (readValue(buffer) as? List)?.let { - ContainerSiteAssignment.fromList(it) + SyncAccountInfo.fromList(it) } } 207.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoHeader.fromList(it) + SyncDevice.fromList(it) } } 208.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchRequest.fromList(it) + SyncIncomingTab.fromList(it) } } 209.toByte() -> { return (readValue(buffer) as? List)?.let { - GeckoFetchResponse.fromList(it) + SyncRemoteTab.fromList(it) } } 210.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkNode.fromList(it) + SyncDeviceTabs.fromList(it) } } 211.toByte() -> { return (readValue(buffer) as? List)?.let { - BookmarkInfo.fromList(it) + GeckoPref.fromList(it) } } 212.toByte() -> { return (readValue(buffer) as? List)?.let { - SitePermissions.fromList(it) + MlProgressData.fromList(it) } } 213.toByte() -> { return (readValue(buffer) as? List)?.let { - TrackingProtectionException.fromList(it) + ContainerSiteAssignment.fromList(it) } } 214.toByte() -> { return (readValue(buffer) as? List)?.let { - PwaIcon.fromList(it) + GeckoHeader.fromList(it) } } 215.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetFiles.fromList(it) + GeckoFetchRequest.fromList(it) } } 216.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTargetParams.fromList(it) + GeckoFetchResponse.fromList(it) } } 217.toByte() -> { return (readValue(buffer) as? List)?.let { - ShareTarget.fromList(it) + BookmarkNode.fromList(it) } } 218.toByte() -> { return (readValue(buffer) as? List)?.let { - ExternalApplicationResource.fromList(it) + BookmarkInfo.fromList(it) } } 219.toByte() -> { + return (readValue(buffer) as? List)?.let { + SitePermissions.fromList(it) + } + } + 220.toByte() -> { + return (readValue(buffer) as? List)?.let { + TrackingProtectionException.fromList(it) + } + } + 221.toByte() -> { + return (readValue(buffer) as? List)?.let { + PwaIcon.fromList(it) + } + } + 222.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTargetFiles.fromList(it) + } + } + 223.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTargetParams.fromList(it) + } + } + 224.toByte() -> { + return (readValue(buffer) as? List)?.let { + ShareTarget.fromList(it) + } + } + 225.toByte() -> { + return (readValue(buffer) as? List)?.let { + ExternalApplicationResource.fromList(it) + } + } + 226.toByte() -> { return (readValue(buffer) as? List)?.let { PwaManifest.fromList(it) } @@ -3806,282 +4078,310 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { stream.write(150) writeValue(stream, value.raw.toLong()) } - is MlProgressType -> { + is SyncEngineValue -> { stream.write(151) writeValue(stream, value.raw.toLong()) } - is MlProgressStatus -> { + is MlProgressType -> { stream.write(152) writeValue(stream, value.raw.toLong()) } - is ClearDataType -> { + is MlProgressStatus -> { stream.write(153) writeValue(stream, value.raw.toLong()) } - is GeckoFetchMethod -> { + is ClearDataType -> { stream.write(154) writeValue(stream, value.raw.toLong()) } - is GeckoFetchRedircet -> { + is GeckoFetchMethod -> { stream.write(155) writeValue(stream, value.raw.toLong()) } - is GeckoFetchCookiePolicy -> { + is GeckoFetchRedircet -> { stream.write(156) writeValue(stream, value.raw.toLong()) } - is BookmarkNodeType -> { + is GeckoFetchCookiePolicy -> { stream.write(157) writeValue(stream, value.raw.toLong()) } - is SitePermissionStatus -> { + is BookmarkNodeType -> { stream.write(158) writeValue(stream, value.raw.toLong()) } - is AutoplayStatus -> { + is SitePermissionStatus -> { stream.write(159) writeValue(stream, value.raw.toLong()) } - is TranslationOptions -> { + is AutoplayStatus -> { stream.write(160) - writeValue(stream, value.toList()) + writeValue(stream, value.raw.toLong()) } - is ReaderState -> { + is TranslationOptions -> { stream.write(161) writeValue(stream, value.toList()) } - is AddTabParams -> { + is ReaderState -> { stream.write(162) writeValue(stream, value.toList()) } - is LastMediaAccessState -> { + is AddTabParams -> { stream.write(163) writeValue(stream, value.toList()) } - is HistoryMetadataKey -> { + is LastMediaAccessState -> { stream.write(164) writeValue(stream, value.toList()) } - is PackageCategoryValue -> { + is HistoryMetadataKey -> { stream.write(165) writeValue(stream, value.toList()) } - is ExternalPackage -> { + is PackageCategoryValue -> { stream.write(166) writeValue(stream, value.toList()) } - is LoadUrlFlagsValue -> { + is ExternalPackage -> { stream.write(167) writeValue(stream, value.toList()) } - is SourceValue -> { + is LoadUrlFlagsValue -> { stream.write(168) writeValue(stream, value.toList()) } - is TabState -> { + is SourceValue -> { stream.write(169) writeValue(stream, value.toList()) } - is RecoverableTab -> { + is TabState -> { stream.write(170) writeValue(stream, value.toList()) } - is RecoverableBrowserState -> { + is RecoverableTab -> { stream.write(171) writeValue(stream, value.toList()) } - is IconRequest -> { + is RecoverableBrowserState -> { stream.write(172) writeValue(stream, value.toList()) } - is ResourceSize -> { + is IconRequest -> { stream.write(173) writeValue(stream, value.toList()) } - is Resource -> { + is ResourceSize -> { stream.write(174) writeValue(stream, value.toList()) } - is IconResult -> { + is Resource -> { stream.write(175) writeValue(stream, value.toList()) } - is CookiePartitionKey -> { + is IconResult -> { stream.write(176) writeValue(stream, value.toList()) } - is Cookie -> { + is CookiePartitionKey -> { stream.write(177) writeValue(stream, value.toList()) } - is VisitInfo -> { + is Cookie -> { stream.write(178) writeValue(stream, value.toList()) } - is HistoryItem -> { + is VisitInfo -> { stream.write(179) writeValue(stream, value.toList()) } - is HistoryState -> { + is HistoryItem -> { stream.write(180) writeValue(stream, value.toList()) } - is ReaderableState -> { + is HistoryState -> { stream.write(181) writeValue(stream, value.toList()) } - is SecurityInfoState -> { + is ReaderableState -> { stream.write(182) writeValue(stream, value.toList()) } - is TabContentState -> { + is SecurityInfoState -> { stream.write(183) writeValue(stream, value.toList()) } - is FindResultState -> { + is TabContentState -> { stream.write(184) writeValue(stream, value.toList()) } - is CustomSelectionAction -> { + is FindResultState -> { stream.write(185) writeValue(stream, value.toList()) } - is WebExtensionData -> { + is CustomSelectionAction -> { stream.write(186) writeValue(stream, value.toList()) } - is GeckoSuggestion -> { + is WebExtensionData -> { stream.write(187) writeValue(stream, value.toList()) } - is TabContent -> { + is GeckoSuggestion -> { stream.write(188) writeValue(stream, value.toList()) } - is ContentBlocking -> { + is TabContent -> { stream.write(189) writeValue(stream, value.toList()) } - is DohSettings -> { + is ContentBlocking -> { stream.write(190) writeValue(stream, value.toList()) } - is GeckoEngineSettings -> { + is DohSettings -> { stream.write(191) writeValue(stream, value.toList()) } - is AutocompleteResult -> { + is GeckoEngineSettings -> { stream.write(192) writeValue(stream, value.toList()) } - is UnknownHitResult -> { + is AutocompleteResult -> { stream.write(193) writeValue(stream, value.toList()) } - is ImageHitResult -> { + is UnknownHitResult -> { stream.write(194) writeValue(stream, value.toList()) } - is VideoHitResult -> { + is ImageHitResult -> { stream.write(195) writeValue(stream, value.toList()) } - is AudioHitResult -> { + is VideoHitResult -> { stream.write(196) writeValue(stream, value.toList()) } - is ImageSrcHitResult -> { + is AudioHitResult -> { stream.write(197) writeValue(stream, value.toList()) } - is PhoneHitResult -> { + is ImageSrcHitResult -> { stream.write(198) writeValue(stream, value.toList()) } - is EmailHitResult -> { + is PhoneHitResult -> { stream.write(199) writeValue(stream, value.toList()) } - is GeoHitResult -> { + is EmailHitResult -> { stream.write(200) writeValue(stream, value.toList()) } - is DownloadState -> { + is GeoHitResult -> { stream.write(201) writeValue(stream, value.toList()) } - is ShareInternetResourceState -> { + is DownloadState -> { stream.write(202) writeValue(stream, value.toList()) } - is AddonCollection -> { + is ShareInternetResourceState -> { stream.write(203) writeValue(stream, value.toList()) } - is GeckoPref -> { + is AddonCollection -> { stream.write(204) writeValue(stream, value.toList()) } - is MlProgressData -> { + is SyncEngineStatus -> { stream.write(205) writeValue(stream, value.toList()) } - is ContainerSiteAssignment -> { + is SyncAccountInfo -> { stream.write(206) writeValue(stream, value.toList()) } - is GeckoHeader -> { + is SyncDevice -> { stream.write(207) writeValue(stream, value.toList()) } - is GeckoFetchRequest -> { + is SyncIncomingTab -> { stream.write(208) writeValue(stream, value.toList()) } - is GeckoFetchResponse -> { + is SyncRemoteTab -> { stream.write(209) writeValue(stream, value.toList()) } - is BookmarkNode -> { + is SyncDeviceTabs -> { stream.write(210) writeValue(stream, value.toList()) } - is BookmarkInfo -> { + is GeckoPref -> { stream.write(211) writeValue(stream, value.toList()) } - is SitePermissions -> { + is MlProgressData -> { stream.write(212) writeValue(stream, value.toList()) } - is TrackingProtectionException -> { + is ContainerSiteAssignment -> { stream.write(213) writeValue(stream, value.toList()) } - is PwaIcon -> { + is GeckoHeader -> { stream.write(214) writeValue(stream, value.toList()) } - is ShareTargetFiles -> { + is GeckoFetchRequest -> { stream.write(215) writeValue(stream, value.toList()) } - is ShareTargetParams -> { + is GeckoFetchResponse -> { stream.write(216) writeValue(stream, value.toList()) } - is ShareTarget -> { + is BookmarkNode -> { stream.write(217) writeValue(stream, value.toList()) } - is ExternalApplicationResource -> { + is BookmarkInfo -> { stream.write(218) writeValue(stream, value.toList()) } - is PwaManifest -> { + is SitePermissions -> { stream.write(219) writeValue(stream, value.toList()) } + is TrackingProtectionException -> { + stream.write(220) + writeValue(stream, value.toList()) + } + is PwaIcon -> { + stream.write(221) + writeValue(stream, value.toList()) + } + is ShareTargetFiles -> { + stream.write(222) + writeValue(stream, value.toList()) + } + is ShareTargetParams -> { + stream.write(223) + writeValue(stream, value.toList()) + } + is ShareTarget -> { + stream.write(224) + writeValue(stream, value.toList()) + } + is ExternalApplicationResource -> { + stream.write(225) + writeValue(stream, value.toList()) + } + is PwaManifest -> { + stream.write(226) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -4091,7 +4391,7 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoBrowserApi { fun getGeckoVersion(): String - fun initialize(profileFolder: String, logLevel: LogLevel, contentBlocking: ContentBlocking, addonCollection: AddonCollection?) + fun initialize(profileFolder: String, logLevel: LogLevel, contentBlocking: ContentBlocking, addonCollection: AddonCollection?, fxaServerOverride: String?, syncTokenServerOverride: String?) fun showNativeFragment(): Boolean fun onTrimMemory(level: Long) @@ -4128,8 +4428,10 @@ interface GeckoBrowserApi { val logLevelArg = args[1] as LogLevel val contentBlockingArg = args[2] as ContentBlocking val addonCollectionArg = args[3] as AddonCollection? + val fxaServerOverrideArg = args[4] as String? + val syncTokenServerOverrideArg = args[5] as String? val wrapped: List = try { - api.initialize(profileFolderArg, logLevelArg, contentBlockingArg, addonCollectionArg) + api.initialize(profileFolderArg, logLevelArg, contentBlockingArg, addonCollectionArg, fxaServerOverrideArg, syncTokenServerOverrideArg) listOf(null) } catch (exception: Throwable) { GeckoPigeonUtils.wrapError(exception) @@ -4177,6 +4479,291 @@ interface GeckoBrowserApi { } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoSyncApi { + fun getAccountInfo(callback: (Result) -> Unit) + fun beginAuthentication(callback: (Result) -> Unit) + fun beginPairingAuthentication(pairingUrl: String, callback: (Result) -> Unit) + fun logout(callback: (Result) -> Unit) + fun syncNow(callback: (Result) -> Unit) + fun setEngineEnabled(engine: SyncEngineValue, enabled: Boolean, callback: (Result) -> Unit) + fun getSyncedTabs(callback: (Result>) -> Unit) + fun getDevices(callback: (Result>) -> Unit) + fun sendTabToDevice(deviceId: String, title: String, url: String, callback: (Result) -> Unit) + fun refreshDevices(callback: (Result) -> Unit) + fun pollDeviceCommands(callback: (Result) -> Unit) + fun drainIncomingTabs(callback: (Result>) -> Unit) + fun getDeviceName(callback: (Result) -> Unit) + fun setDeviceName(newName: String, callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoSyncApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoSyncApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoSyncApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.getAccountInfo$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.getAccountInfo{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.beginAuthentication$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.beginAuthentication{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.beginPairingAuthentication$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pairingUrlArg = args[0] as String + api.beginPairingAuthentication(pairingUrlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.logout$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.logout{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.syncNow$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.syncNow{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.setEngineEnabled$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val engineArg = args[0] as SyncEngineValue + val enabledArg = args[1] as Boolean + api.setEngineEnabled(engineArg, enabledArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.getSyncedTabs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.getSyncedTabs{ result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.getDevices$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.getDevices{ result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.sendTabToDevice$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val deviceIdArg = args[0] as String + val titleArg = args[1] as String + val urlArg = args[2] as String + api.sendTabToDevice(deviceIdArg, titleArg, urlArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.refreshDevices$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.refreshDevices{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.pollDeviceCommands$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.pollDeviceCommands{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + reply.reply(GeckoPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.drainIncomingTabs$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.drainIncomingTabs{ result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.getDeviceName$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.getDeviceName{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.setDeviceName$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val newNameArg = args[0] as String + api.setDeviceName(newNameArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoEngineSettingsApi { fun setDefaultSettings(settings: GeckoEngineSettings) fun updateRuntimeSettings(settings: GeckoEngineSettings) @@ -5965,6 +6552,83 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } /** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class GeckoSyncStateEvents(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by GeckoSyncStateEvents. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + } + fun onAuthStateChanged(sequenceArg: Long, accountInfoArg: SyncAccountInfo, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onAuthStateChanged$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, accountInfoArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onSyncStarted(sequenceArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncStarted$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onSyncCompleted(sequenceArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncCompleted$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onSyncError(sequenceArg: Long, errorMessageArg: String?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncError$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(sequenceArg, errorMessageArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ class GeckoLogging(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { companion object { /** The codec used by GeckoLogging. */ diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/sync/SyncedTabsIntegration.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/sync/SyncedTabsIntegration.kt new file mode 100644 index 00000000..89a656b3 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/sync/SyncedTabsIntegration.kt @@ -0,0 +1,35 @@ +package eu.weblibre.flutter_mozilla_components.sync + +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage +import mozilla.components.service.fxa.manager.FxaAccountManager + +/** + * Starts and stops SyncedTabsStorage based on the authentication state. + */ +class SyncedTabsIntegration( + private val accountManager: FxaAccountManager, + private val syncedTabsStorage: SyncedTabsStorage, +) { + fun launch() { + accountManager.register(SyncedTabsAccountObserver(syncedTabsStorage)) + + if (accountManager.authenticatedAccount() != null) { + syncedTabsStorage.start() + } + } +} + +internal class SyncedTabsAccountObserver( + private val syncedTabsStorage: SyncedTabsStorage, +) : AccountObserver { + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { + syncedTabsStorage.start() + } + + override fun onLoggedOut() { + syncedTabsStorage.stop() + } +} diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index 38e95b5c..e7c930e7 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -29,6 +29,7 @@ export 'src/domain/services/gecko_readerable.dart'; export 'src/domain/services/gecko_selection_action.dart'; export 'src/domain/services/gecko_session.dart'; export 'src/domain/services/gecko_suggestions.dart'; +export 'src/domain/services/gecko_sync.dart'; export 'src/domain/services/gecko_tab.dart'; export 'src/domain/services/gecko_tab_content.dart'; export 'src/domain/services/gecko_viewport.dart'; @@ -84,6 +85,13 @@ export 'src/pigeons/gecko.g.dart' SecurityInfoState, SitePermissionStatus, SitePermissions, + SyncAccountInfo, + SyncDevice, + SyncDeviceTabs, + SyncEngineStatus, + SyncEngineValue, + SyncIncomingTab, + SyncRemoteTab, TabContent, TabContentState, TrackingProtectionException, 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 e8232239..1d946682 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 @@ -22,12 +22,16 @@ class GeckoBrowserService { LogLevel logLevel, ContentBlocking contentBlocking, AddonCollection? addonCollection, + String? fxaServerOverride, + String? syncTokenServerOverride, ) { return _api.initialize( profileFolder, logLevel, contentBlocking, addonCollection, + fxaServerOverride, + syncTokenServerOverride, ); } diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_sync.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_sync.dart new file mode 100644 index 00000000..ea74e02a --- /dev/null +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_sync.dart @@ -0,0 +1,120 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_mozilla_components/src/extensions/subject.dart'; +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; +import 'package:rxdart/rxdart.dart'; + +final _apiInstance = GeckoSyncApi(); + +class GeckoSyncService { + final GeckoSyncApi _api; + + GeckoSyncService({GeckoSyncApi? api}) : _api = api ?? _apiInstance; + + Future getAccountInfo() { + return _api.getAccountInfo(); + } + + Future beginAuthentication() { + return _api.beginAuthentication(); + } + + Future beginPairingAuthentication(String pairingUrl) { + return _api.beginPairingAuthentication(pairingUrl); + } + + Future logout() { + return _api.logout(); + } + + Future syncNow() { + return _api.syncNow(); + } + + Future setEngineEnabled(SyncEngineValue engine, bool enabled) { + return _api.setEngineEnabled(engine, enabled); + } + + Future> getSyncedTabs() { + return _api.getSyncedTabs(); + } + + Future> getDevices() { + return _api.getDevices(); + } + + Future sendTabToDevice(String deviceId, String title, String url) { + return _api.sendTabToDevice(deviceId, title, url); + } + + Future refreshDevices() { + return _api.refreshDevices(); + } + + Future pollDeviceCommands() { + return _api.pollDeviceCommands(); + } + + Future> drainIncomingTabs() { + return _api.drainIncomingTabs(); + } + + Future getDeviceName() { + return _api.getDeviceName(); + } + + Future setDeviceName(String newName) { + return _api.setDeviceName(newName); + } +} + +class GeckoSyncStateService extends GeckoSyncStateEvents { + final _authStateSubject = BehaviorSubject(); + final _syncStartedSubject = PublishSubject(); + final _syncCompletedSubject = PublishSubject(); + final _syncErrorSubject = PublishSubject(); + + ValueStream get authStateEvents => _authStateSubject.stream; + Stream get syncStartedEvents => _syncStartedSubject.stream; + Stream get syncCompletedEvents => _syncCompletedSubject.stream; + Stream get syncErrorEvents => _syncErrorSubject.stream; + + @override + void onAuthStateChanged(int sequence, SyncAccountInfo accountInfo) { + _authStateSubject.addWhenMoreRecent(sequence, null, accountInfo); + } + + @override + void onSyncStarted(int sequence) { + _syncStartedSubject.addWhenMoreRecent(sequence, null, null); + } + + @override + void onSyncCompleted(int sequence) { + _syncCompletedSubject.addWhenMoreRecent(sequence, null, null); + } + + @override + void onSyncError(int sequence, String? errorMessage) { + _syncErrorSubject.addWhenMoreRecent(sequence, null, errorMessage); + } + + GeckoSyncStateService.setUp({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + GeckoSyncStateEvents.setUp( + this, + binaryMessenger: binaryMessenger, + messageChannelSuffix: messageChannelSuffix, + ); + } + + Future dispose() async { + await Future.wait([ + _authStateSubject.close(), + _syncStartedSubject.close(), + _syncCompletedSubject.close(), + _syncErrorSubject.close(), + ]); + } +} 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 c4dc1540..7fb9bdf8 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -254,6 +254,12 @@ enum LogLevel { error, } +enum SyncEngineValue { + history, + bookmarks, + tabs, +} + /// Type of ML model operation enum MlProgressType { downloading, @@ -3060,6 +3066,347 @@ class AddonCollection { ; } +class SyncEngineStatus { + SyncEngineStatus({ + required this.engine, + required this.enabled, + }); + + SyncEngineValue engine; + + bool enabled; + + List _toList() { + return [ + engine, + enabled, + ]; + } + + Object encode() { + return _toList(); } + + static SyncEngineStatus decode(Object result) { + result as List; + return SyncEngineStatus( + engine: result[0]! as SyncEngineValue, + enabled: result[1]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SyncEngineStatus || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class SyncAccountInfo { + SyncAccountInfo({ + required this.authenticated, + required this.syncing, + required this.needsReauth, + this.email, + this.displayName, + this.lastSyncedAt, + required this.engines, + }); + + bool authenticated; + + bool syncing; + + bool needsReauth; + + String? email; + + String? displayName; + + int? lastSyncedAt; + + List engines; + + List _toList() { + return [ + authenticated, + syncing, + needsReauth, + email, + displayName, + lastSyncedAt, + engines, + ]; + } + + Object encode() { + return _toList(); } + + static SyncAccountInfo decode(Object result) { + result as List; + return SyncAccountInfo( + authenticated: result[0]! as bool, + syncing: result[1]! as bool, + needsReauth: result[2]! as bool, + email: result[3] as String?, + displayName: result[4] as String?, + lastSyncedAt: result[5] as int?, + engines: (result[6] as List?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SyncAccountInfo || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class SyncDevice { + SyncDevice({ + required this.deviceId, + required this.displayName, + required this.isCurrentDevice, + required this.canSendTab, + }); + + String deviceId; + + String displayName; + + bool isCurrentDevice; + + bool canSendTab; + + List _toList() { + return [ + deviceId, + displayName, + isCurrentDevice, + canSendTab, + ]; + } + + Object encode() { + return _toList(); } + + static SyncDevice decode(Object result) { + result as List; + return SyncDevice( + deviceId: result[0]! as String, + displayName: result[1]! as String, + isCurrentDevice: result[2]! as bool, + canSendTab: result[3]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SyncDevice || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class SyncIncomingTab { + SyncIncomingTab({ + required this.title, + required this.url, + this.fromDeviceId, + this.fromDeviceName, + }); + + String title; + + String url; + + String? fromDeviceId; + + String? fromDeviceName; + + List _toList() { + return [ + title, + url, + fromDeviceId, + fromDeviceName, + ]; + } + + Object encode() { + return _toList(); } + + static SyncIncomingTab decode(Object result) { + result as List; + return SyncIncomingTab( + title: result[0]! as String, + url: result[1]! as String, + fromDeviceId: result[2] as String?, + fromDeviceName: result[3] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SyncIncomingTab || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class SyncRemoteTab { + SyncRemoteTab({ + required this.title, + required this.url, + this.iconUrl, + required this.lastUsed, + required this.inactive, + }); + + String title; + + String url; + + String? iconUrl; + + int lastUsed; + + bool inactive; + + List _toList() { + return [ + title, + url, + iconUrl, + lastUsed, + inactive, + ]; + } + + Object encode() { + return _toList(); } + + static SyncRemoteTab decode(Object result) { + result as List; + return SyncRemoteTab( + title: result[0]! as String, + url: result[1]! as String, + iconUrl: result[2] as String?, + lastUsed: result[3]! as int, + inactive: result[4]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SyncRemoteTab || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class SyncDeviceTabs { + SyncDeviceTabs({ + required this.deviceId, + required this.deviceName, + required this.tabs, + }); + + String deviceId; + + String deviceName; + + List tabs; + + List _toList() { + return [ + deviceId, + deviceName, + tabs, + ]; + } + + Object encode() { + return _toList(); } + + static SyncDeviceTabs decode(Object result) { + result as List; + return SyncDeviceTabs( + deviceId: result[0]! as String, + deviceName: result[1]! as String, + tabs: (result[2] as List?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SyncDeviceTabs || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + class GeckoPref { GeckoPref({ required this.name, @@ -4233,213 +4580,234 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is LogLevel) { buffer.putUint8(150); writeValue(buffer, value.index); - } else if (value is MlProgressType) { + } else if (value is SyncEngineValue) { buffer.putUint8(151); writeValue(buffer, value.index); - } else if (value is MlProgressStatus) { + } else if (value is MlProgressType) { buffer.putUint8(152); writeValue(buffer, value.index); - } else if (value is ClearDataType) { + } else if (value is MlProgressStatus) { buffer.putUint8(153); writeValue(buffer, value.index); - } else if (value is GeckoFetchMethod) { + } else if (value is ClearDataType) { buffer.putUint8(154); writeValue(buffer, value.index); - } else if (value is GeckoFetchRedircet) { + } else if (value is GeckoFetchMethod) { buffer.putUint8(155); writeValue(buffer, value.index); - } else if (value is GeckoFetchCookiePolicy) { + } else if (value is GeckoFetchRedircet) { buffer.putUint8(156); writeValue(buffer, value.index); - } else if (value is BookmarkNodeType) { + } else if (value is GeckoFetchCookiePolicy) { buffer.putUint8(157); writeValue(buffer, value.index); - } else if (value is SitePermissionStatus) { + } else if (value is BookmarkNodeType) { buffer.putUint8(158); writeValue(buffer, value.index); - } else if (value is AutoplayStatus) { + } else if (value is SitePermissionStatus) { buffer.putUint8(159); writeValue(buffer, value.index); - } else if (value is TranslationOptions) { + } else if (value is AutoplayStatus) { buffer.putUint8(160); - writeValue(buffer, value.encode()); - } else if (value is ReaderState) { + writeValue(buffer, value.index); + } else if (value is TranslationOptions) { buffer.putUint8(161); writeValue(buffer, value.encode()); - } else if (value is AddTabParams) { + } else if (value is ReaderState) { buffer.putUint8(162); writeValue(buffer, value.encode()); - } else if (value is LastMediaAccessState) { + } else if (value is AddTabParams) { buffer.putUint8(163); writeValue(buffer, value.encode()); - } else if (value is HistoryMetadataKey) { + } else if (value is LastMediaAccessState) { buffer.putUint8(164); writeValue(buffer, value.encode()); - } else if (value is PackageCategoryValue) { + } else if (value is HistoryMetadataKey) { buffer.putUint8(165); writeValue(buffer, value.encode()); - } else if (value is ExternalPackage) { + } else if (value is PackageCategoryValue) { buffer.putUint8(166); writeValue(buffer, value.encode()); - } else if (value is LoadUrlFlagsValue) { + } else if (value is ExternalPackage) { buffer.putUint8(167); writeValue(buffer, value.encode()); - } else if (value is SourceValue) { + } else if (value is LoadUrlFlagsValue) { buffer.putUint8(168); writeValue(buffer, value.encode()); - } else if (value is TabState) { + } else if (value is SourceValue) { buffer.putUint8(169); writeValue(buffer, value.encode()); - } else if (value is RecoverableTab) { + } else if (value is TabState) { buffer.putUint8(170); writeValue(buffer, value.encode()); - } else if (value is RecoverableBrowserState) { + } else if (value is RecoverableTab) { buffer.putUint8(171); writeValue(buffer, value.encode()); - } else if (value is IconRequest) { + } else if (value is RecoverableBrowserState) { buffer.putUint8(172); writeValue(buffer, value.encode()); - } else if (value is ResourceSize) { + } else if (value is IconRequest) { buffer.putUint8(173); writeValue(buffer, value.encode()); - } else if (value is Resource) { + } else if (value is ResourceSize) { buffer.putUint8(174); writeValue(buffer, value.encode()); - } else if (value is IconResult) { + } else if (value is Resource) { buffer.putUint8(175); writeValue(buffer, value.encode()); - } else if (value is CookiePartitionKey) { + } else if (value is IconResult) { buffer.putUint8(176); writeValue(buffer, value.encode()); - } else if (value is Cookie) { + } else if (value is CookiePartitionKey) { buffer.putUint8(177); writeValue(buffer, value.encode()); - } else if (value is VisitInfo) { + } else if (value is Cookie) { buffer.putUint8(178); writeValue(buffer, value.encode()); - } else if (value is HistoryItem) { + } else if (value is VisitInfo) { buffer.putUint8(179); writeValue(buffer, value.encode()); - } else if (value is HistoryState) { + } else if (value is HistoryItem) { buffer.putUint8(180); writeValue(buffer, value.encode()); - } else if (value is ReaderableState) { + } else if (value is HistoryState) { buffer.putUint8(181); writeValue(buffer, value.encode()); - } else if (value is SecurityInfoState) { + } else if (value is ReaderableState) { buffer.putUint8(182); writeValue(buffer, value.encode()); - } else if (value is TabContentState) { + } else if (value is SecurityInfoState) { buffer.putUint8(183); writeValue(buffer, value.encode()); - } else if (value is FindResultState) { + } else if (value is TabContentState) { buffer.putUint8(184); writeValue(buffer, value.encode()); - } else if (value is CustomSelectionAction) { + } else if (value is FindResultState) { buffer.putUint8(185); writeValue(buffer, value.encode()); - } else if (value is WebExtensionData) { + } else if (value is CustomSelectionAction) { buffer.putUint8(186); writeValue(buffer, value.encode()); - } else if (value is GeckoSuggestion) { + } else if (value is WebExtensionData) { buffer.putUint8(187); writeValue(buffer, value.encode()); - } else if (value is TabContent) { + } else if (value is GeckoSuggestion) { buffer.putUint8(188); writeValue(buffer, value.encode()); - } else if (value is ContentBlocking) { + } else if (value is TabContent) { buffer.putUint8(189); writeValue(buffer, value.encode()); - } else if (value is DohSettings) { + } else if (value is ContentBlocking) { buffer.putUint8(190); writeValue(buffer, value.encode()); - } else if (value is GeckoEngineSettings) { + } else if (value is DohSettings) { buffer.putUint8(191); writeValue(buffer, value.encode()); - } else if (value is AutocompleteResult) { + } else if (value is GeckoEngineSettings) { buffer.putUint8(192); writeValue(buffer, value.encode()); - } else if (value is UnknownHitResult) { + } else if (value is AutocompleteResult) { buffer.putUint8(193); writeValue(buffer, value.encode()); - } else if (value is ImageHitResult) { + } else if (value is UnknownHitResult) { buffer.putUint8(194); writeValue(buffer, value.encode()); - } else if (value is VideoHitResult) { + } else if (value is ImageHitResult) { buffer.putUint8(195); writeValue(buffer, value.encode()); - } else if (value is AudioHitResult) { + } else if (value is VideoHitResult) { buffer.putUint8(196); writeValue(buffer, value.encode()); - } else if (value is ImageSrcHitResult) { + } else if (value is AudioHitResult) { buffer.putUint8(197); writeValue(buffer, value.encode()); - } else if (value is PhoneHitResult) { + } else if (value is ImageSrcHitResult) { buffer.putUint8(198); writeValue(buffer, value.encode()); - } else if (value is EmailHitResult) { + } else if (value is PhoneHitResult) { buffer.putUint8(199); writeValue(buffer, value.encode()); - } else if (value is GeoHitResult) { + } else if (value is EmailHitResult) { buffer.putUint8(200); writeValue(buffer, value.encode()); - } else if (value is DownloadState) { + } else if (value is GeoHitResult) { buffer.putUint8(201); writeValue(buffer, value.encode()); - } else if (value is ShareInternetResourceState) { + } else if (value is DownloadState) { buffer.putUint8(202); writeValue(buffer, value.encode()); - } else if (value is AddonCollection) { + } else if (value is ShareInternetResourceState) { buffer.putUint8(203); writeValue(buffer, value.encode()); - } else if (value is GeckoPref) { + } else if (value is AddonCollection) { buffer.putUint8(204); writeValue(buffer, value.encode()); - } else if (value is MlProgressData) { + } else if (value is SyncEngineStatus) { buffer.putUint8(205); writeValue(buffer, value.encode()); - } else if (value is ContainerSiteAssignment) { + } else if (value is SyncAccountInfo) { buffer.putUint8(206); writeValue(buffer, value.encode()); - } else if (value is GeckoHeader) { + } else if (value is SyncDevice) { buffer.putUint8(207); writeValue(buffer, value.encode()); - } else if (value is GeckoFetchRequest) { + } else if (value is SyncIncomingTab) { buffer.putUint8(208); writeValue(buffer, value.encode()); - } else if (value is GeckoFetchResponse) { + } else if (value is SyncRemoteTab) { buffer.putUint8(209); writeValue(buffer, value.encode()); - } else if (value is BookmarkNode) { + } else if (value is SyncDeviceTabs) { buffer.putUint8(210); writeValue(buffer, value.encode()); - } else if (value is BookmarkInfo) { + } else if (value is GeckoPref) { buffer.putUint8(211); writeValue(buffer, value.encode()); - } else if (value is SitePermissions) { + } else if (value is MlProgressData) { buffer.putUint8(212); writeValue(buffer, value.encode()); - } else if (value is TrackingProtectionException) { + } else if (value is ContainerSiteAssignment) { buffer.putUint8(213); writeValue(buffer, value.encode()); - } else if (value is PwaIcon) { + } else if (value is GeckoHeader) { buffer.putUint8(214); writeValue(buffer, value.encode()); - } else if (value is ShareTargetFiles) { + } else if (value is GeckoFetchRequest) { buffer.putUint8(215); writeValue(buffer, value.encode()); - } else if (value is ShareTargetParams) { + } else if (value is GeckoFetchResponse) { buffer.putUint8(216); writeValue(buffer, value.encode()); - } else if (value is ShareTarget) { + } else if (value is BookmarkNode) { buffer.putUint8(217); writeValue(buffer, value.encode()); - } else if (value is ExternalApplicationResource) { + } else if (value is BookmarkInfo) { buffer.putUint8(218); writeValue(buffer, value.encode()); - } else if (value is PwaManifest) { + } else if (value is SitePermissions) { buffer.putUint8(219); writeValue(buffer, value.encode()); + } else if (value is TrackingProtectionException) { + buffer.putUint8(220); + writeValue(buffer, value.encode()); + } else if (value is PwaIcon) { + buffer.putUint8(221); + writeValue(buffer, value.encode()); + } else if (value is ShareTargetFiles) { + buffer.putUint8(222); + writeValue(buffer, value.encode()); + } else if (value is ShareTargetParams) { + buffer.putUint8(223); + writeValue(buffer, value.encode()); + } else if (value is ShareTarget) { + buffer.putUint8(224); + writeValue(buffer, value.encode()); + } else if (value is ExternalApplicationResource) { + buffer.putUint8(225); + writeValue(buffer, value.encode()); + } else if (value is PwaManifest) { + buffer.putUint8(226); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -4516,150 +4884,165 @@ class _PigeonCodec extends StandardMessageCodec { return value == null ? null : LogLevel.values[value]; case 151: final value = readValue(buffer) as int?; - return value == null ? null : MlProgressType.values[value]; + return value == null ? null : SyncEngineValue.values[value]; case 152: final value = readValue(buffer) as int?; - return value == null ? null : MlProgressStatus.values[value]; + return value == null ? null : MlProgressType.values[value]; case 153: final value = readValue(buffer) as int?; - return value == null ? null : ClearDataType.values[value]; + return value == null ? null : MlProgressStatus.values[value]; case 154: final value = readValue(buffer) as int?; - return value == null ? null : GeckoFetchMethod.values[value]; + return value == null ? null : ClearDataType.values[value]; case 155: final value = readValue(buffer) as int?; - return value == null ? null : GeckoFetchRedircet.values[value]; + return value == null ? null : GeckoFetchMethod.values[value]; case 156: final value = readValue(buffer) as int?; - return value == null ? null : GeckoFetchCookiePolicy.values[value]; + return value == null ? null : GeckoFetchRedircet.values[value]; case 157: final value = readValue(buffer) as int?; - return value == null ? null : BookmarkNodeType.values[value]; + return value == null ? null : GeckoFetchCookiePolicy.values[value]; case 158: final value = readValue(buffer) as int?; - return value == null ? null : SitePermissionStatus.values[value]; + return value == null ? null : BookmarkNodeType.values[value]; case 159: final value = readValue(buffer) as int?; - return value == null ? null : AutoplayStatus.values[value]; + return value == null ? null : SitePermissionStatus.values[value]; case 160: - return TranslationOptions.decode(readValue(buffer)!); + final value = readValue(buffer) as int?; + return value == null ? null : AutoplayStatus.values[value]; case 161: - return ReaderState.decode(readValue(buffer)!); + return TranslationOptions.decode(readValue(buffer)!); case 162: - return AddTabParams.decode(readValue(buffer)!); + return ReaderState.decode(readValue(buffer)!); case 163: - return LastMediaAccessState.decode(readValue(buffer)!); + return AddTabParams.decode(readValue(buffer)!); case 164: - return HistoryMetadataKey.decode(readValue(buffer)!); + return LastMediaAccessState.decode(readValue(buffer)!); case 165: - return PackageCategoryValue.decode(readValue(buffer)!); + return HistoryMetadataKey.decode(readValue(buffer)!); case 166: - return ExternalPackage.decode(readValue(buffer)!); + return PackageCategoryValue.decode(readValue(buffer)!); case 167: - return LoadUrlFlagsValue.decode(readValue(buffer)!); + return ExternalPackage.decode(readValue(buffer)!); case 168: - return SourceValue.decode(readValue(buffer)!); + return LoadUrlFlagsValue.decode(readValue(buffer)!); case 169: - return TabState.decode(readValue(buffer)!); + return SourceValue.decode(readValue(buffer)!); case 170: - return RecoverableTab.decode(readValue(buffer)!); + return TabState.decode(readValue(buffer)!); case 171: - return RecoverableBrowserState.decode(readValue(buffer)!); + return RecoverableTab.decode(readValue(buffer)!); case 172: - return IconRequest.decode(readValue(buffer)!); + return RecoverableBrowserState.decode(readValue(buffer)!); case 173: - return ResourceSize.decode(readValue(buffer)!); + return IconRequest.decode(readValue(buffer)!); case 174: - return Resource.decode(readValue(buffer)!); + return ResourceSize.decode(readValue(buffer)!); case 175: - return IconResult.decode(readValue(buffer)!); + return Resource.decode(readValue(buffer)!); case 176: - return CookiePartitionKey.decode(readValue(buffer)!); + return IconResult.decode(readValue(buffer)!); case 177: - return Cookie.decode(readValue(buffer)!); + return CookiePartitionKey.decode(readValue(buffer)!); case 178: - return VisitInfo.decode(readValue(buffer)!); + return Cookie.decode(readValue(buffer)!); case 179: - return HistoryItem.decode(readValue(buffer)!); + return VisitInfo.decode(readValue(buffer)!); case 180: - return HistoryState.decode(readValue(buffer)!); + return HistoryItem.decode(readValue(buffer)!); case 181: - return ReaderableState.decode(readValue(buffer)!); + return HistoryState.decode(readValue(buffer)!); case 182: - return SecurityInfoState.decode(readValue(buffer)!); + return ReaderableState.decode(readValue(buffer)!); case 183: - return TabContentState.decode(readValue(buffer)!); + return SecurityInfoState.decode(readValue(buffer)!); case 184: - return FindResultState.decode(readValue(buffer)!); + return TabContentState.decode(readValue(buffer)!); case 185: - return CustomSelectionAction.decode(readValue(buffer)!); + return FindResultState.decode(readValue(buffer)!); case 186: - return WebExtensionData.decode(readValue(buffer)!); + return CustomSelectionAction.decode(readValue(buffer)!); case 187: - return GeckoSuggestion.decode(readValue(buffer)!); + return WebExtensionData.decode(readValue(buffer)!); case 188: - return TabContent.decode(readValue(buffer)!); + return GeckoSuggestion.decode(readValue(buffer)!); case 189: - return ContentBlocking.decode(readValue(buffer)!); + return TabContent.decode(readValue(buffer)!); case 190: - return DohSettings.decode(readValue(buffer)!); + return ContentBlocking.decode(readValue(buffer)!); case 191: - return GeckoEngineSettings.decode(readValue(buffer)!); + return DohSettings.decode(readValue(buffer)!); case 192: - return AutocompleteResult.decode(readValue(buffer)!); + return GeckoEngineSettings.decode(readValue(buffer)!); case 193: - return UnknownHitResult.decode(readValue(buffer)!); + return AutocompleteResult.decode(readValue(buffer)!); case 194: - return ImageHitResult.decode(readValue(buffer)!); + return UnknownHitResult.decode(readValue(buffer)!); case 195: - return VideoHitResult.decode(readValue(buffer)!); + return ImageHitResult.decode(readValue(buffer)!); case 196: - return AudioHitResult.decode(readValue(buffer)!); + return VideoHitResult.decode(readValue(buffer)!); case 197: - return ImageSrcHitResult.decode(readValue(buffer)!); + return AudioHitResult.decode(readValue(buffer)!); case 198: - return PhoneHitResult.decode(readValue(buffer)!); + return ImageSrcHitResult.decode(readValue(buffer)!); case 199: - return EmailHitResult.decode(readValue(buffer)!); + return PhoneHitResult.decode(readValue(buffer)!); case 200: - return GeoHitResult.decode(readValue(buffer)!); + return EmailHitResult.decode(readValue(buffer)!); case 201: - return DownloadState.decode(readValue(buffer)!); + return GeoHitResult.decode(readValue(buffer)!); case 202: - return ShareInternetResourceState.decode(readValue(buffer)!); + return DownloadState.decode(readValue(buffer)!); case 203: - return AddonCollection.decode(readValue(buffer)!); + return ShareInternetResourceState.decode(readValue(buffer)!); case 204: - return GeckoPref.decode(readValue(buffer)!); + return AddonCollection.decode(readValue(buffer)!); case 205: - return MlProgressData.decode(readValue(buffer)!); + return SyncEngineStatus.decode(readValue(buffer)!); case 206: - return ContainerSiteAssignment.decode(readValue(buffer)!); + return SyncAccountInfo.decode(readValue(buffer)!); case 207: - return GeckoHeader.decode(readValue(buffer)!); + return SyncDevice.decode(readValue(buffer)!); case 208: - return GeckoFetchRequest.decode(readValue(buffer)!); + return SyncIncomingTab.decode(readValue(buffer)!); case 209: - return GeckoFetchResponse.decode(readValue(buffer)!); + return SyncRemoteTab.decode(readValue(buffer)!); case 210: - return BookmarkNode.decode(readValue(buffer)!); + return SyncDeviceTabs.decode(readValue(buffer)!); case 211: - return BookmarkInfo.decode(readValue(buffer)!); + return GeckoPref.decode(readValue(buffer)!); case 212: - return SitePermissions.decode(readValue(buffer)!); + return MlProgressData.decode(readValue(buffer)!); case 213: - return TrackingProtectionException.decode(readValue(buffer)!); + return ContainerSiteAssignment.decode(readValue(buffer)!); case 214: - return PwaIcon.decode(readValue(buffer)!); + return GeckoHeader.decode(readValue(buffer)!); case 215: - return ShareTargetFiles.decode(readValue(buffer)!); + return GeckoFetchRequest.decode(readValue(buffer)!); case 216: - return ShareTargetParams.decode(readValue(buffer)!); + return GeckoFetchResponse.decode(readValue(buffer)!); case 217: - return ShareTarget.decode(readValue(buffer)!); + return BookmarkNode.decode(readValue(buffer)!); case 218: - return ExternalApplicationResource.decode(readValue(buffer)!); + return BookmarkInfo.decode(readValue(buffer)!); case 219: + return SitePermissions.decode(readValue(buffer)!); + case 220: + return TrackingProtectionException.decode(readValue(buffer)!); + case 221: + return PwaIcon.decode(readValue(buffer)!); + case 222: + return ShareTargetFiles.decode(readValue(buffer)!); + case 223: + return ShareTargetParams.decode(readValue(buffer)!); + case 224: + return ShareTarget.decode(readValue(buffer)!); + case 225: + return ExternalApplicationResource.decode(readValue(buffer)!); + case 226: return PwaManifest.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -4707,14 +5090,14 @@ class GeckoBrowserApi { } } - Future initialize(String profileFolder, LogLevel logLevel, ContentBlocking contentBlocking, AddonCollection? addonCollection) async { + Future initialize(String profileFolder, LogLevel logLevel, ContentBlocking contentBlocking, AddonCollection? addonCollection, String? fxaServerOverride, String? syncTokenServerOverride) async { final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.initialize$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([profileFolder, logLevel, contentBlocking, addonCollection]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([profileFolder, logLevel, contentBlocking, addonCollection, fxaServerOverride, syncTokenServerOverride]); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); @@ -4779,6 +5162,358 @@ class GeckoBrowserApi { } } +class GeckoSyncApi { + /// Constructor for [GeckoSyncApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + GeckoSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future getAccountInfo() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.getAccountInfo$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 SyncAccountInfo?)!; + } + } + + Future beginAuthentication() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.beginAuthentication$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 beginPairingAuthentication(String pairingUrl) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.beginPairingAuthentication$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([pairingUrl]); + final 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 logout() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.logout$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 syncNow() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.syncNow$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 setEngineEnabled(SyncEngineValue engine, bool enabled) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.setEngineEnabled$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([engine, enabled]); + final 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> getSyncedTabs() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.getSyncedTabs$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 List?)!.cast(); + } + } + + Future> getDevices() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.getDevices$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 List?)!.cast(); + } + } + + Future sendTabToDevice(String deviceId, String title, String url) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.sendTabToDevice$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([deviceId, title, url]); + final 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?)!; + } + } + + Future refreshDevices() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.refreshDevices$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 pollDeviceCommands() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.pollDeviceCommands$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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> drainIncomingTabs() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.drainIncomingTabs$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 List?)!.cast(); + } + } + + Future getDeviceName() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.getDeviceName$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 (pigeonVar_replyList[0] as String?); + } + } + + Future setDeviceName(String newName) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncApi.setDeviceName$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([newName]); + final 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 GeckoEngineSettingsApi { /// Constructor for [GeckoEngineSettingsApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default @@ -7013,6 +7748,126 @@ abstract class GeckoStateEvents { } } +abstract class GeckoSyncStateEvents { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void onAuthStateChanged(int sequence, SyncAccountInfo accountInfo); + + void onSyncStarted(int sequence); + + void onSyncCompleted(int sequence); + + void onSyncError(int sequence, String? errorMessage); + + static void setUp(GeckoSyncStateEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onAuthStateChanged$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onAuthStateChanged was null.'); + final List args = (message as List?)!; + final int? arg_sequence = (args[0] as int?); + assert(arg_sequence != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onAuthStateChanged was null, expected non-null int.'); + final SyncAccountInfo? arg_accountInfo = (args[1] as SyncAccountInfo?); + assert(arg_accountInfo != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onAuthStateChanged was null, expected non-null SyncAccountInfo.'); + try { + api.onAuthStateChanged(arg_sequence!, arg_accountInfo!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncStarted$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncStarted was null.'); + final List args = (message as List?)!; + final int? arg_sequence = (args[0] as int?); + assert(arg_sequence != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncStarted was null, expected non-null int.'); + try { + api.onSyncStarted(arg_sequence!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncCompleted$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncCompleted was null.'); + final List args = (message as List?)!; + final int? arg_sequence = (args[0] as int?); + assert(arg_sequence != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncCompleted was null, expected non-null int.'); + try { + api.onSyncCompleted(arg_sequence!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncError$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncError was null.'); + final List args = (message as List?)!; + final int? arg_sequence = (args[0] as int?); + assert(arg_sequence != null, + 'Argument for dev.flutter.pigeon.flutter_mozilla_components.GeckoSyncStateEvents.onSyncError was null, expected non-null int.'); + final String? arg_errorMessage = (args[1] as String?); + try { + api.onSyncError(arg_sequence!, arg_errorMessage); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} + abstract class GeckoLogging { static const MessageCodec pigeonChannelCodec = _PigeonCodec(); diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index fe185f54..25fe310a 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -998,11 +998,143 @@ abstract class GeckoBrowserApi { LogLevel logLevel, ContentBlocking contentBlocking, AddonCollection? addonCollection, + String? fxaServerOverride, + String? syncTokenServerOverride, ); bool showNativeFragment(); void onTrimMemory(int level); } +enum SyncEngineValue { history, bookmarks, tabs } + +class SyncEngineStatus { + final SyncEngineValue engine; + final bool enabled; + + SyncEngineStatus({required this.engine, required this.enabled}); +} + +class SyncAccountInfo { + final bool authenticated; + final bool syncing; + final bool needsReauth; + final String? email; + final String? displayName; + final int? lastSyncedAt; + final List engines; + + SyncAccountInfo({ + required this.authenticated, + required this.syncing, + required this.needsReauth, + required this.email, + required this.displayName, + required this.lastSyncedAt, + required this.engines, + }); +} + +class SyncDevice { + final String deviceId; + final String displayName; + final bool isCurrentDevice; + final bool canSendTab; + + SyncDevice({ + required this.deviceId, + required this.displayName, + required this.isCurrentDevice, + required this.canSendTab, + }); +} + +class SyncIncomingTab { + final String title; + final String url; + final String? fromDeviceId; + final String? fromDeviceName; + + SyncIncomingTab({ + required this.title, + required this.url, + required this.fromDeviceId, + required this.fromDeviceName, + }); +} + +class SyncRemoteTab { + final String title; + final String url; + final String? iconUrl; + final int lastUsed; + final bool inactive; + + SyncRemoteTab({ + required this.title, + required this.url, + required this.iconUrl, + required this.lastUsed, + required this.inactive, + }); +} + +class SyncDeviceTabs { + final String deviceId; + final String deviceName; + final List tabs; + + SyncDeviceTabs({ + required this.deviceId, + required this.deviceName, + required this.tabs, + }); +} + +@HostApi() +abstract class GeckoSyncApi { + @async + SyncAccountInfo getAccountInfo(); + + @async + void beginAuthentication(); + + @async + void beginPairingAuthentication(String pairingUrl); + + @async + void logout(); + + @async + void syncNow(); + + @async + void setEngineEnabled(SyncEngineValue engine, bool enabled); + + @async + List getSyncedTabs(); + + @async + List getDevices(); + + @async + bool sendTabToDevice(String deviceId, String title, String url); + + @async + void refreshDevices(); + + @async + void pollDeviceCommands(); + + @async + List drainIncomingTabs(); + + @async + String? getDeviceName(); + + @async + bool setDeviceName(String newName); +} + @HostApi() abstract class GeckoEngineSettingsApi { void setDefaultSettings(GeckoEngineSettings settings); @@ -1427,6 +1559,14 @@ abstract class GeckoStateEvents { void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest); } +@FlutterApi() +abstract class GeckoSyncStateEvents { + void onAuthStateChanged(int sequence, SyncAccountInfo accountInfo); + void onSyncStarted(int sequence); + void onSyncCompleted(int sequence); + void onSyncError(int sequence, String? errorMessage); +} + @FlutterApi() abstract class GeckoLogging { void onLog(LogLevel level, String message);