sync initial

This commit is contained in:
Fabian Freund
2026-02-21 08:29:05 +01:00
parent 03f9dfe9f5
commit 5bdc3c6027
62 changed files with 5724 additions and 519 deletions
@@ -179,6 +179,19 @@
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthCustomTabActivity"
android:exported="false"
android:taskAffinity=""
android:autoRemoveFromRecents="false"
android:theme="@style/ExternalAppBrowserTheme"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|layoutDirection|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<activity
android:name="eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity"
android:exported="false" />
<activity
android:theme="@style/AddonsActivityTheme"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
@@ -20,9 +20,26 @@
package eu.weblibre.gecko
import android.app.Application
import android.content.SharedPreferences
import eu.weblibre.flutter_mozilla_components.ActiveProfile
import eu.weblibre.flutter_mozilla_components.MegazordSetup
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
MegazordSetup.setupEarlyMainProcess()
// Resolve active profile EARLY so cold-start WorkManager workers
// get profile-prefixed SharedPreferences
ActiveProfile.resolveFromDisk(this)
}
override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
val pfx = ActiveProfile.prefix
if (pfx != null && name in ActiveProfile.FXA_SHARED_PREFERENCE_NAMES) {
return super.getSharedPreferences("${pfx}_$name", mode)
}
return super.getSharedPreferences(name, mode)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ part 'format.g.dart';
@Riverpod(keepAlive: true)
class Format extends _$Format {
String fullDateTimeWithTimezone(DateTime date) {
String fullDateTime(DateTime date) {
final pattern = DateFormat('yMMMMd').addPattern('Hm');
return pattern.format(date);
+1 -1
View File
@@ -32,7 +32,7 @@ final class FormatProvider extends $AsyncNotifierProvider<Format, void> {
Format create() => Format();
}
String _$formatHash() => r'fe7fcdd19b512784a36f3838c57701030475e429';
String _$formatHash() => r'f132aabb20bf4df77d771d6c866bc413c00bd9ff';
abstract class _$Format extends $AsyncNotifier<void> {
FutureOr<void> build();
+1
View File
@@ -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';
+26
View File
@@ -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<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $torProxyRoute => GoRouteData.$route(
path: '/tor',
name: 'TorProxyRoute',
@@ -80,6 +80,7 @@ part of 'routes.dart';
path: 'custom_tracking_protection',
),
TypedGoRoute<ErrorLogsRoute>(name: 'ErrorLogsRoute', path: 'error_logs'),
TypedGoRoute<SyncSettingsRoute>(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();
}
}
@@ -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<Offset>();
// 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,
@@ -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),
),
),
),
),
);
}
@@ -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) {
@@ -18,6 +18,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<double>(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();
}
},
);
}
}
@@ -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'),
),
);
}
}
@@ -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),
@@ -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),
@@ -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;
@@ -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<bool>(
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<bool>(
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),
@@ -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,
),
@@ -186,7 +186,7 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
);
}
SingleSelectable<String> siteAssignedContainerId(Uri uri) {
Selectable<String> siteAssignedContainerId(Uri uri) {
return db.definitionsDrift.siteAssignedContainerId(uri: uri.origin);
}
@@ -174,7 +174,8 @@ class ContainerRepository extends _$ContainerRepository {
.read(tabDatabaseProvider)
.containerDao
.siteAssignedContainerId(uri)
.getSingle();
.get()
.then((value) => value.firstOrNull);
}
Future<List<String>> getContainersToClearOnExit() async {
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
}
String _$containerRepositoryHash() =>
r'62c2b06970db4385ea0d2edc68bcf076c291a9b6';
r'5e46b6d9d3510aeeb70646ede75d71c2db9efb0f';
abstract class _$ContainerRepository extends $Notifier<void> {
void build();
@@ -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)
@@ -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();
@@ -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<SyncDeviceTabs> remoteTabs;
final List<SyncDevice> devices;
final String? deviceName;
final SyncEvent? lastSyncEvent;
final String? lastSyncError;
SyncRepositoryState({
required this.account,
this.remoteTabs = const <SyncDeviceTabs>[],
this.devices = const <SyncDevice>[],
this.deviceName,
this.lastSyncEvent,
this.lastSyncError,
});
@override
List<Object?> get hashParameters =>
[account, remoteTabs, devices, deviceName, lastSyncEvent, lastSyncError];
}
@@ -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<SyncDeviceTabs> remoteTabs);
SyncRepositoryState devices(List<SyncDevice> 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<SyncDeviceTabs> remoteTabs,
List<SyncDevice> 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<SyncDeviceTabs> remoteTabs) =>
call(remoteTabs: remoteTabs);
@override
SyncRepositoryState devices(List<SyncDevice> 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<SyncDeviceTabs>,
devices: devices == const $CopyWithPlaceholder() || devices == null
? _value.devices
// ignore: cast_nullable_to_non_nullable
: devices as List<SyncDevice>,
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);
}
@@ -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<Object?> get hashParameters => [deviceId, deviceName, tab];
}
@@ -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<int> syncedTabsTotalCount(Ref ref) {
return ref.watch(
syncRemoteTabsProvider.selectAsync(
(devices) =>
devices.fold<int>(0, (count, device) => count + device.tabs.length),
),
);
}
@riverpod
Future<String?> 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<List<SyncedTabItem>> 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 <SyncedTabItem>[];
}
final device = devices.firstWhereOrNull(
(item) => item.deviceId == effectiveSelectedDeviceId,
);
if (device == null) {
return const <SyncedTabItem>[];
}
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<SyncAccountInfo>? _authStateSub;
StreamSubscription<void>? _syncStartedSub;
StreamSubscription<void>? _syncCompletedSub;
StreamSubscription<String?>? _syncErrorSub;
Future<void> _awaitInitialized() => future;
void _update(SyncRepositoryState Function(SyncRepositoryState) updater) {
final current = state.value;
if (current != null) {
state = AsyncData(updater(current));
}
}
Future<void> _refreshAccount() async {
await _awaitInitialized();
final account = await _service.getAccountInfo();
_update((s) => s.copyWith(account: account));
}
Future<void> _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<void> _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<void> _refreshDeviceName() async {
await _awaitInitialized();
final deviceName = await _service.getDeviceName();
_update((s) => s.copyWith(deviceName: deviceName));
}
Future<SyncAccountInfo> refresh() async {
await _refreshAccount();
return state.value!.account;
}
Future<void> signIn() async {
await _service.beginAuthentication();
}
Future<void> signInWithPairing(String pairingUrl) async {
await _service.beginPairingAuthentication(pairingUrl);
}
Future<void> signOut() async {
await _service.logout();
}
Future<void> syncNow() async {
await _service.syncNow();
await Future.wait([_refreshAccount(), _refreshTabs(), _refreshDevices()]);
}
Future<void> setEngineEnabled(SyncEngineValue engine, bool enabled) async {
await _service.setEngineEnabled(engine, enabled);
await Future.wait([_refreshAccount(), _refreshTabs(), _refreshDevices()]);
}
Future<bool> sendTabToDevice({
required String deviceId,
required String title,
required String url,
}) {
return _service.sendTabToDevice(deviceId, title, url);
}
Future<bool> setDeviceName(String newName) async {
final result = await _service.setDeviceName(newName);
if (result) {
await Future.wait([_refreshDevices(), _refreshDeviceName()]);
}
return result;
}
Future<void> refreshDevices() async {
await _service.refreshDevices();
await _refreshDevices();
}
Future<int> 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<void> openSyncedTab(SyncRemoteTab tab) async {
await _openUrlInAssignedContainer(tab.url);
}
Future<void> _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<SyncRepositoryState> 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<String?> syncDeviceName(Ref ref) {
return ref.watch(
syncRepositoryProvider.selectAsync((value) => value.deviceName),
);
}
@riverpod
Future<List<SyncDeviceTabs>> syncRemoteTabs(Ref ref) {
return ref.watch(
syncRepositoryProvider.selectAsync((value) => value.remoteTabs),
);
}
@riverpod
Future<List<SyncDevice>> 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),
),
);
}
@@ -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<bool, bool, bool>
with $Provider<bool> {
SyncIsAuthenticatedProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncIsAuthenticatedProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncIsAuthenticatedHash();
@$internal
@override
$ProviderElement<bool> $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<bool>(value),
);
}
}
String _$syncIsAuthenticatedHash() =>
r'40e108a5c1d4d885fd31edde04146bf4131dd51c';
@ProviderFor(TabsTrayScopeController)
final tabsTrayScopeControllerProvider = TabsTrayScopeControllerProvider._();
final class TabsTrayScopeControllerProvider
extends $NotifierProvider<TabsTrayScopeController, TabsTrayScope> {
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<TabsTrayScope>(value),
);
}
}
String _$tabsTrayScopeControllerHash() =>
r'e9415c1f57e1a78804ec4eee122c9db9ee416066';
abstract class _$TabsTrayScopeController extends $Notifier<TabsTrayScope> {
TabsTrayScope build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<TabsTrayScope, TabsTrayScope>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<TabsTrayScope, TabsTrayScope>,
TabsTrayScope,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(effectiveTabsTrayScope)
final effectiveTabsTrayScopeProvider = EffectiveTabsTrayScopeProvider._();
final class EffectiveTabsTrayScopeProvider
extends $FunctionalProvider<TabsTrayScope, TabsTrayScope, TabsTrayScope>
with $Provider<TabsTrayScope> {
EffectiveTabsTrayScopeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'effectiveTabsTrayScopeProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$effectiveTabsTrayScopeHash();
@$internal
@override
$ProviderElement<TabsTrayScope> $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<TabsTrayScope>(value),
);
}
}
String _$effectiveTabsTrayScopeHash() =>
r'ac295d7413a7014882835d3533bf31cd3c031a1d';
@ProviderFor(SelectedSyncedTabsDeviceId)
final selectedSyncedTabsDeviceIdProvider =
SelectedSyncedTabsDeviceIdProvider._();
final class SelectedSyncedTabsDeviceIdProvider
extends $NotifierProvider<SelectedSyncedTabsDeviceId, String?> {
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<String?>(value),
);
}
}
String _$selectedSyncedTabsDeviceIdHash() =>
r'61f86d2f17d6c2f04e1fb76dae900c4695aa6af0';
abstract class _$SelectedSyncedTabsDeviceId extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(syncedTabsTotalCount)
final syncedTabsTotalCountProvider = SyncedTabsTotalCountProvider._();
final class SyncedTabsTotalCountProvider
extends $FunctionalProvider<AsyncValue<int>, int, FutureOr<int>>
with $FutureModifier<int>, $FutureProvider<int> {
SyncedTabsTotalCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncedTabsTotalCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncedTabsTotalCountHash();
@$internal
@override
$FutureProviderElement<int> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<int> create(Ref ref) {
return syncedTabsTotalCount(ref);
}
}
String _$syncedTabsTotalCountHash() =>
r'507b14aac187c434cf02925d895151c1a25159ea';
@ProviderFor(effectiveSyncedTabsDeviceId)
final effectiveSyncedTabsDeviceIdProvider =
EffectiveSyncedTabsDeviceIdProvider._();
final class EffectiveSyncedTabsDeviceIdProvider
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
with $FutureModifier<String?>, $FutureProvider<String?> {
EffectiveSyncedTabsDeviceIdProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'effectiveSyncedTabsDeviceIdProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$effectiveSyncedTabsDeviceIdHash();
@$internal
@override
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<String?> create(Ref ref) {
return effectiveSyncedTabsDeviceId(ref);
}
}
String _$effectiveSyncedTabsDeviceIdHash() =>
r'26ed7e56191a8019cab5c89dc8d5622e1a709e20';
@ProviderFor(syncedTabsForSelectedDevice)
final syncedTabsForSelectedDeviceProvider =
SyncedTabsForSelectedDeviceProvider._();
final class SyncedTabsForSelectedDeviceProvider
extends
$FunctionalProvider<
AsyncValue<List<SyncedTabItem>>,
List<SyncedTabItem>,
FutureOr<List<SyncedTabItem>>
>
with
$FutureModifier<List<SyncedTabItem>>,
$FutureProvider<List<SyncedTabItem>> {
SyncedTabsForSelectedDeviceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncedTabsForSelectedDeviceProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncedTabsForSelectedDeviceHash();
@$internal
@override
$FutureProviderElement<List<SyncedTabItem>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SyncedTabItem>> create(Ref ref) {
return syncedTabsForSelectedDevice(ref);
}
}
String _$syncedTabsForSelectedDeviceHash() =>
r'a85c4e972ab55b872f1de64cd27ba18d7dcc023a';
@ProviderFor(SyncRepository)
final syncRepositoryProvider = SyncRepositoryProvider._();
final class SyncRepositoryProvider
extends $AsyncNotifierProvider<SyncRepository, SyncRepositoryState> {
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<SyncRepositoryState> {
FutureOr<SyncRepositoryState> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<SyncRepositoryState>, SyncRepositoryState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SyncRepositoryState>, SyncRepositoryState>,
AsyncValue<SyncRepositoryState>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(geckoSyncStateService)
final geckoSyncStateServiceProvider = GeckoSyncStateServiceProvider._();
final class GeckoSyncStateServiceProvider
extends
$FunctionalProvider<
GeckoSyncStateService,
GeckoSyncStateService,
GeckoSyncStateService
>
with $Provider<GeckoSyncStateService> {
GeckoSyncStateServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'geckoSyncStateServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$geckoSyncStateServiceHash();
@$internal
@override
$ProviderElement<GeckoSyncStateService> $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<GeckoSyncStateService>(value),
);
}
}
String _$geckoSyncStateServiceHash() =>
r'1e96db4a6229a3d2a31862cc9cf88c463405819b';
@ProviderFor(syncDeviceName)
final syncDeviceNameProvider = SyncDeviceNameProvider._();
final class SyncDeviceNameProvider
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
with $FutureModifier<String?>, $FutureProvider<String?> {
SyncDeviceNameProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncDeviceNameProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncDeviceNameHash();
@$internal
@override
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<String?> create(Ref ref) {
return syncDeviceName(ref);
}
}
String _$syncDeviceNameHash() => r'3d7e531cba481c4233a895b1d80e915d646fc6b6';
@ProviderFor(syncRemoteTabs)
final syncRemoteTabsProvider = SyncRemoteTabsProvider._();
final class SyncRemoteTabsProvider
extends
$FunctionalProvider<
AsyncValue<List<SyncDeviceTabs>>,
List<SyncDeviceTabs>,
FutureOr<List<SyncDeviceTabs>>
>
with
$FutureModifier<List<SyncDeviceTabs>>,
$FutureProvider<List<SyncDeviceTabs>> {
SyncRemoteTabsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncRemoteTabsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncRemoteTabsHash();
@$internal
@override
$FutureProviderElement<List<SyncDeviceTabs>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SyncDeviceTabs>> create(Ref ref) {
return syncRemoteTabs(ref);
}
}
String _$syncRemoteTabsHash() => r'5040a010e578f2fe395302e7d34a91df1854f8a9';
@ProviderFor(syncDevices)
final syncDevicesProvider = SyncDevicesProvider._();
final class SyncDevicesProvider
extends
$FunctionalProvider<
AsyncValue<List<SyncDevice>>,
List<SyncDevice>,
FutureOr<List<SyncDevice>>
>
with $FutureModifier<List<SyncDevice>>, $FutureProvider<List<SyncDevice>> {
SyncDevicesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'syncDevicesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$syncDevicesHash();
@$internal
@override
$FutureProviderElement<List<SyncDevice>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SyncDevice>> 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';
@@ -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<Barcode>(
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<double>(
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<bool?> _showSignOutConfirmation(BuildContext context) {
return showDialog<bool>(
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<void> _showDeviceNameDialog(
BuildContext context, {
required String currentName,
required Future<bool> Function(String name) onSave,
}) async {
final controller = TextEditingController(text: currentName);
await showDialog<void>(
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<void> _showTextSettingDialog(
BuildContext context, {
required String title,
required String initialValue,
required String hint,
required Future<void> Function(String value) onSave,
}) async {
final controller = TextEditingController(text: initialValue);
await showDialog<void>(
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'),
),
],
);
},
);
}
}
@@ -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<String, dynamic> json) =>
_$GeneralSettingsFromJson(json);
@@ -222,5 +230,7 @@ class GeneralSettings with FastEquatable {
tabListShowFavicons,
quickTabSwitcherShowTitles,
drawerGestureEnabled,
syncServerOverride,
syncTokenServerOverride,
];
}
@@ -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<String, dynamic> _$GeneralSettingsToJson(
@@ -577,6 +607,8 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'tabListShowFavicons': instance.tabListShowFavicons,
'quickTabSwitcherShowTitles': instance.quickTabSwitcherShowTitles,
'drawerGestureEnabled': instance.drawerGestureEnabled,
'syncServerOverride': instance.syncServerOverride,
'syncTokenServerOverride': instance.syncTokenServerOverride,
};
const _$ThemeModeEnumMap = {
@@ -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(
@@ -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,
),
});
}
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'33730ccb09471c1a7c187bd19760110f524e055b';
r'ed5bca200b840e0802980b85a0c0f682d7b24a8c';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {
@@ -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),
),
+6
View File
@@ -17,6 +17,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart: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(
+44
View File
@@ -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;
}
}
+52 -7
View File
@@ -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<void> 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<void> 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);
@@ -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'
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
}
@@ -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<MediaSessionFullscreenFeature>()
private val webAuthnFeature = ViewBoundFeatureWrapper<WebAuthnFeature>()
private val fxaWebChannelFeature = ViewBoundFeatureWrapper<FxaWebChannelFeature>()
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,
@@ -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) }
}
}
@@ -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,
)
@@ -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<Client>): Deferred<Unit> =
GlobalScope.async(Dispatchers.IO) {
val isDebuggable =
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
if (isDebuggable) {
RustHttpConfig.allowEmulatorLoopback()
}
RustHttpConfig.setClient(client)
}
}
@@ -43,6 +43,7 @@ class ProfileContext(private val base: Context, val relativePath: String) :
}
init {
ActiveProfile.prefix = profilePrefix
customFilesDir.mkdirs()
customNoBackupFilesDir.mkdirs()
customObbDir.mkdirs()
@@ -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()
}
}
@@ -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()
}
}
@@ -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"
@@ -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))
@@ -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<SyncAccountInfo>) -> 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>) -> 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>) -> 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>) -> 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>) -> 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>) -> 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<List<SyncDeviceTabs>>) -> 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<List<SyncDevice>>) -> 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<Boolean>) -> 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>) -> 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>) -> 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<List<SyncIncomingTab>>) -> 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<String?>) -> 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<Boolean>) -> 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<String, String> {
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 }
}
}
@@ -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<BrowserStore>,
private val historyStorage: Lazy<PlacesHistoryStorage>,
private val bookmarkStorage: Lazy<PlacesBookmarksStorage>,
private val remoteTabsStorage: Lazy<RemoteTabsStorage>,
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<IncomingTab>()
private val startedSignal = CompletableDeferred<Unit>()
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<TabData> ->
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<IncomingTab> {
synchronized(incomingTabsLock) {
if (incomingTabsQueue.isEmpty()) {
return emptyList()
}
val values = incomingTabsQueue.toList()
incomingTabsQueue.clear()
return values
}
}
}
@@ -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) }
@@ -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,
)
}
}
}
@@ -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,
@@ -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"),
}
@@ -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
}
}
@@ -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()
}
}
@@ -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,
@@ -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,
);
}
@@ -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<SyncAccountInfo> getAccountInfo() {
return _api.getAccountInfo();
}
Future<void> beginAuthentication() {
return _api.beginAuthentication();
}
Future<void> beginPairingAuthentication(String pairingUrl) {
return _api.beginPairingAuthentication(pairingUrl);
}
Future<void> logout() {
return _api.logout();
}
Future<void> syncNow() {
return _api.syncNow();
}
Future<void> setEngineEnabled(SyncEngineValue engine, bool enabled) {
return _api.setEngineEnabled(engine, enabled);
}
Future<List<SyncDeviceTabs>> getSyncedTabs() {
return _api.getSyncedTabs();
}
Future<List<SyncDevice>> getDevices() {
return _api.getDevices();
}
Future<bool> sendTabToDevice(String deviceId, String title, String url) {
return _api.sendTabToDevice(deviceId, title, url);
}
Future<void> refreshDevices() {
return _api.refreshDevices();
}
Future<void> pollDeviceCommands() {
return _api.pollDeviceCommands();
}
Future<List<SyncIncomingTab>> drainIncomingTabs() {
return _api.drainIncomingTabs();
}
Future<String?> getDeviceName() {
return _api.getDeviceName();
}
Future<bool> setDeviceName(String newName) {
return _api.setDeviceName(newName);
}
}
class GeckoSyncStateService extends GeckoSyncStateEvents {
final _authStateSubject = BehaviorSubject<SyncAccountInfo>();
final _syncStartedSubject = PublishSubject<void>();
final _syncCompletedSubject = PublishSubject<void>();
final _syncErrorSubject = PublishSubject<String?>();
ValueStream<SyncAccountInfo> get authStateEvents => _authStateSubject.stream;
Stream<void> get syncStartedEvents => _syncStartedSubject.stream;
Stream<void> get syncCompletedEvents => _syncCompletedSubject.stream;
Stream<String?> 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<void> dispose() async {
await Future.wait([
_authStateSubject.close(),
_syncStartedSubject.close(),
_syncCompletedSubject.close(),
_syncErrorSubject.close(),
]);
}
}
File diff suppressed because it is too large Load Diff
@@ -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<SyncEngineStatus> 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<SyncRemoteTab> 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<SyncDeviceTabs> getSyncedTabs();
@async
List<SyncDevice> getDevices();
@async
bool sendTabToDevice(String deviceId, String title, String url);
@async
void refreshDevices();
@async
void pollDeviceCommands();
@async
List<SyncIncomingTab> 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);