From dc14a7d71804f529504dd146a02300427237fbc9 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 20 Jul 2025 00:37:51 +0200 Subject: [PATCH] container topic inference --- app/lib/core/routing/routes.browser.dart | 2 +- app/lib/core/routing/routes.g.dart | 2 +- .../features/tabs/domain/providers.dart | 6 + .../features/tabs/domain/providers.g.dart | 122 +++++++++++++ .../domain/providers/selected_container.dart | 3 +- .../providers/selected_container.g.dart | 2 +- .../domain/repositories/container_topic.dart | 70 ++++++++ .../repositories/container_topic.g.dart | 167 ++++++++++++++++++ .../controllers/container_topic.dart | 23 +++ .../controllers/container_topic.g.dart | 30 ++++ .../presentation/screens/container_edit.dart | 41 ++++- .../presentation/widgets/container_chips.dart | 4 +- .../widgets/container_list_tile.dart | 3 +- .../presentation/widgets/container_title.dart | 50 ++++++ app/pubspec.yaml | 1 + .../assets/extensions/ml_engine/api/ml.js | 151 ++++++++++++++++ .../assets/extensions/ml_engine/api/nlp.js | 29 +++ .../assets/extensions/ml_engine/background.js | 38 ++++ .../assets/extensions/ml_engine/manifest.json | 61 +++++++ .../assets/extensions/ml_engine/schema.json | 61 +++++++ .../EngineProvider.kt | 4 +- .../api/GeckoBrowserApiImpl.kt | 2 + .../api/GeckoMlApiImpl.kt | 27 +++ .../feature/MLEngineFeature.kt | 126 +++++++++++++ .../pigeons/Gecko.g.kt | 38 +++- .../lib/flutter_mozilla_components.dart | 1 + .../lib/src/domain/services/gecko_ml.dart | 15 ++ .../lib/src/pigeons/gecko.g.dart | 44 ++++- .../pigeons/gecko.dart | 6 + 29 files changed, 1117 insertions(+), 12 deletions(-) create mode 100644 app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.dart create mode 100644 app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.g.dart create mode 100644 app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart create mode 100644 app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart create mode 100644 app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/ml.js create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/nlp.js create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/background.js create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/manifest.json create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/schema.json create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoMlApiImpl.kt create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/MLEngineFeature.kt create mode 100644 packages/flutter_mozilla_components/lib/src/domain/services/gecko_ml.dart diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index 97f365e5..28b7dff3 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -110,7 +110,7 @@ class ContainerSelectionRoute extends GoRouteData } class ContainerEditRoute extends GoRouteData with _$ContainerEditRoute { - final ContainerData $extra; + final ContainerDataWithCount $extra; ContainerEditRoute(this.$extra); diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index db5ec706..4b687c09 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -517,7 +517,7 @@ mixin _$ContainerCreateRoute on GoRouteData { mixin _$ContainerEditRoute on GoRouteData { static ContainerEditRoute _fromState(GoRouterState state) => - ContainerEditRoute(state.extra as ContainerData); + ContainerEditRoute(state.extra as ContainerDataWithCount); ContainerEditRoute get _self => this as ContainerEditRoute; diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart index 795acfa9..d331a5be 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.dart @@ -64,3 +64,9 @@ Stream> tabDescendants(Ref ref, String tabId) { ); }); } + +@Riverpod() +Stream> containerTabsData(Ref ref, String containerId) { + final db = ref.watch(tabDatabaseProvider); + return db.containerDao.getContainerTabsData(containerId).watch(); +} diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart index d303244d..9e41f6b6 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -442,5 +442,127 @@ class _TabDescendantsProviderElement String get tabId => (origin as TabDescendantsProvider).tabId; } +String _$containerTabsDataHash() => r'1987b2d69f2ba663a7343e93572aad3c31a29be3'; + +/// See also [containerTabsData]. +@ProviderFor(containerTabsData) +const containerTabsDataProvider = ContainerTabsDataFamily(); + +/// See also [containerTabsData]. +class ContainerTabsDataFamily extends Family>> { + /// See also [containerTabsData]. + const ContainerTabsDataFamily(); + + /// See also [containerTabsData]. + ContainerTabsDataProvider call(String containerId) { + return ContainerTabsDataProvider(containerId); + } + + @override + ContainerTabsDataProvider getProviderOverride( + covariant ContainerTabsDataProvider provider, + ) { + return call(provider.containerId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'containerTabsDataProvider'; +} + +/// See also [containerTabsData]. +class ContainerTabsDataProvider + extends AutoDisposeStreamProvider> { + /// See also [containerTabsData]. + ContainerTabsDataProvider(String containerId) + : this._internal( + (ref) => containerTabsData(ref as ContainerTabsDataRef, containerId), + from: containerTabsDataProvider, + name: r'containerTabsDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containerTabsDataHash, + dependencies: ContainerTabsDataFamily._dependencies, + allTransitiveDependencies: + ContainerTabsDataFamily._allTransitiveDependencies, + containerId: containerId, + ); + + ContainerTabsDataProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerId, + }) : super.internal(); + + final String containerId; + + @override + Override overrideWith( + Stream> Function(ContainerTabsDataRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ContainerTabsDataProvider._internal( + (ref) => create(ref as ContainerTabsDataRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerId: containerId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _ContainerTabsDataProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ContainerTabsDataProvider && + other.containerId == containerId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ContainerTabsDataRef on AutoDisposeStreamProviderRef> { + /// The parameter `containerId` of this provider. + String get containerId; +} + +class _ContainerTabsDataProviderElement + extends AutoDisposeStreamProviderElement> + with ContainerTabsDataRef { + _ContainerTabsDataProviderElement(super.provider); + + @override + String get containerId => (origin as ContainerTabsDataProvider).containerId; +} + // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart index d241d570..977b5d1f 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart @@ -38,8 +38,7 @@ class SelectedContainer extends _$SelectedContainer { .read(localAuthenticationServiceProvider.notifier) .authenticate( authKey: 'container_access::${container.id}', - localizedReason: - 'Require authentication for container ${container.name ?? 'New Container'}', + localizedReason: 'Require authentication for container', settings: container.metadata.authSettings, useAuthCache: true, ); diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart index 49aec496..b4ec2240 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart @@ -25,7 +25,7 @@ final selectedContainerDataProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef SelectedContainerDataRef = AutoDisposeStreamProviderRef; -String _$selectedContainerHash() => r'736a60b8f19273d3bdc25d1717e3a613384e59a6'; +String _$selectedContainerHash() => r'34457f0adc45d437a9ab817387be4b1664cd2e7a'; /// See also [SelectedContainer]. @ProviderFor(SelectedContainer) diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.dart new file mode 100644 index 00000000..96dd8721 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:synchronized/synchronized.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/utils/lru_cache.dart'; + +part 'container_topic.g.dart'; + +@Riverpod(keepAlive: true) +class ContainerTopicRepository extends _$ContainerTopicRepository { + final _service = GeckoMlService(); + + final _lock = Lock(); + + final _cache = LRUCache, String>( + 50, + equals: (a, b) { + return const DeepCollectionEquality.unordered().equals(a, b); + }, + hashCode: (key) { + return const DeepCollectionEquality.unordered().hash(key); + }, + ); + + Future getContainerTopic(Set titles) async { + if (titles.isNotEmpty) { + if (_cache.get(titles) case final String title) { + return title; + } + + try { + final title = await _lock.synchronized(() async { + final title = await _service.getContainerTopic(titles); + + return _cache.set(titles, title); + }, timeout: const Duration(seconds: 120)); + + return title; + } on TimeoutException { + return null; + } + } + + return null; + } + + @override + void build() {} +} + +@Riverpod(keepAlive: true) +Future containerTopic(Ref ref, String containerId) async { + final titles = await ref.watch( + containerTabsDataProvider(containerId).selectAsync( + (tabData) => + EquatableValue(tabData.map((tab) => tab.title).nonNulls.toSet()), + ), + ); + + final topic = await ref + .read(containerTopicRepositoryProvider.notifier) + .getContainerTopic(titles.value); + + return topic; +} diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.g.dart new file mode 100644 index 00000000..a34fbd81 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/container_topic.g.dart @@ -0,0 +1,167 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'container_topic.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$containerTopicHash() => r'aa3fd27f26bb94b6c9e794d8470537240b8d2713'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [containerTopic]. +@ProviderFor(containerTopic) +const containerTopicProvider = ContainerTopicFamily(); + +/// See also [containerTopic]. +class ContainerTopicFamily extends Family> { + /// See also [containerTopic]. + const ContainerTopicFamily(); + + /// See also [containerTopic]. + ContainerTopicProvider call(String containerId) { + return ContainerTopicProvider(containerId); + } + + @override + ContainerTopicProvider getProviderOverride( + covariant ContainerTopicProvider provider, + ) { + return call(provider.containerId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'containerTopicProvider'; +} + +/// See also [containerTopic]. +class ContainerTopicProvider extends FutureProvider { + /// See also [containerTopic]. + ContainerTopicProvider(String containerId) + : this._internal( + (ref) => containerTopic(ref as ContainerTopicRef, containerId), + from: containerTopicProvider, + name: r'containerTopicProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containerTopicHash, + dependencies: ContainerTopicFamily._dependencies, + allTransitiveDependencies: + ContainerTopicFamily._allTransitiveDependencies, + containerId: containerId, + ); + + ContainerTopicProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.containerId, + }) : super.internal(); + + final String containerId; + + @override + Override overrideWith( + FutureOr Function(ContainerTopicRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ContainerTopicProvider._internal( + (ref) => create(ref as ContainerTopicRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + containerId: containerId, + ), + ); + } + + @override + FutureProviderElement createElement() { + return _ContainerTopicProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ContainerTopicProvider && other.containerId == containerId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, containerId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ContainerTopicRef on FutureProviderRef { + /// The parameter `containerId` of this provider. + String get containerId; +} + +class _ContainerTopicProviderElement extends FutureProviderElement + with ContainerTopicRef { + _ContainerTopicProviderElement(super.provider); + + @override + String get containerId => (origin as ContainerTopicProvider).containerId; +} + +String _$containerTopicRepositoryHash() => + r'20ae14772821ac8a4ec0c16bc551e29098556189'; + +/// See also [ContainerTopicRepository]. +@ProviderFor(ContainerTopicRepository) +final containerTopicRepositoryProvider = + NotifierProvider.internal( + ContainerTopicRepository.new, + name: r'containerTopicRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containerTopicRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ContainerTopicRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart new file mode 100644 index 00000000..ebc6f9e3 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart @@ -0,0 +1,23 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart'; + +part 'container_topic.g.dart'; + +@Riverpod() +class ContainerTopicController extends _$ContainerTopicController { + Future getContainerTopic(String containerId) async { + state = const AsyncLoading(); + + final result = await AsyncValue.guard(() async { + return await ref.read(containerTopicProvider(containerId).future); + }); + state = result; + + return result.valueOrNull; + } + + @override + AsyncValue build() { + return const AsyncData(null); + } +} diff --git a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart new file mode 100644 index 00000000..af8d881c --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'container_topic.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$containerTopicControllerHash() => + r'229709202edecb21b20432f8e4d0d7aee293f623'; + +/// See also [ContainerTopicController]. +@ProviderFor(ContainerTopicController) +final containerTopicControllerProvider = + AutoDisposeNotifierProvider< + ContainerTopicController, + AsyncValue + >.internal( + ContainerTopicController.new, + name: r'containerTopicControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$containerTopicControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ContainerTopicController = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index a03cb797..28bf2791 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/uuid.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/controllers/container_topic.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart'; import 'package:weblibre/features/user/domain/services/local_authentication.dart'; import 'package:weblibre/presentation/icons/tor_icons.dart'; @@ -59,6 +60,11 @@ class ContainerEditScreen extends HookConsumerWidget { text: initialContainer.name, ); + final containerHasTabs = switch (initialContainer) { + ContainerDataWithCount(:final tabCount?) when tabCount > 0 => true, + _ => false, + }; + return Scaffold( appBar: AppBar( title: Text(switch (_mode) { @@ -89,8 +95,7 @@ class ContainerEditScreen extends HookConsumerWidget { .read(localAuthenticationServiceProvider.notifier) .authenticate( authKey: 'container_access::${container.id}', - localizedReason: - 'Require authentication for container ${container.name ?? 'New Container'}', + localizedReason: 'Require authentication for container', ); if (!authResult) { @@ -140,6 +145,38 @@ class ContainerEditScreen extends HookConsumerWidget { ), ), label: const Text('Name'), + suffixIcon: + (_mode == _DialogMode.edit && containerHasTabs) + ? Consumer( + builder: (context, ref, child) { + final isLoading = ref.watch( + containerTopicControllerProvider.select( + (value) => value.isLoading, + ), + ); + + return IconButton( + onPressed: isLoading + ? null + : () async { + final topic = await ref + .read( + containerTopicControllerProvider + .notifier, + ) + .getContainerTopic( + initialContainer.id, + ); + + if (topic != null) { + textController.text = topic; + } + }, + icon: const Icon(MdiIcons.creation), + ); + }, + ) + : null, ), controller: textController, ), diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index e0165929..0957336a 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -9,6 +9,7 @@ import 'package:weblibre/data/models/drag_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; class ContainerChips extends HookConsumerWidget { @@ -75,7 +76,7 @@ class ContainerChips extends HookConsumerWidget { ), ), itemLabel: (container) => - Text(container.name ?? 'New Container'), + ContainerTitle(container: container), itemBadgeCount: (container) => container.tabCount, itemWrap: (child, container) { return HookBuilder( @@ -169,6 +170,7 @@ class ContainerChips extends HookConsumerWidget { ), if (displayMenu) IconButton( + visualDensity: VisualDensity.compact, onPressed: () async { await ContainerListRoute().push(context); }, diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart index 898d77e1..5c27f931 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; class ContainerListTile extends HookWidget { final ContainerData container; @@ -22,7 +23,7 @@ class ContainerListTile extends HookWidget { child: ListTile( selected: isSelected, leading: CircleAvatar(backgroundColor: container.color), - title: Text(container.name ?? 'New Container'), + title: ContainerTitle(container: container), onTap: onTap, trailing: const Icon(Icons.chevron_right), ), diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart new file mode 100644 index 00000000..0a652659 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart'; + +class ContainerTitle extends HookConsumerWidget { + final ContainerData container; + + const ContainerTitle({super.key, required this.container}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (container.name.isNotEmpty) { + return Text(container.name!); + } + + final topicAsync = ref.watch(containerTopicProvider(container.id)); + + return topicAsync.when( + skipLoadingOnReload: true, + data: (data) => + data.mapNotNull( + (name) => RichText( + text: TextSpan( + children: [ + TextSpan(text: name), + const WidgetSpan(child: SizedBox(width: 4)), + const WidgetSpan(child: Icon(MdiIcons.creation, size: 16)), + ], + ), + ), + ) ?? + const Text('New Container'), + error: (error, stackTrace) { + logger.e( + 'Could not determine container name ${container.id}', + error: error, + stackTrace: stackTrace, + ); + + return const Text('New Container'); + }, + loading: () => const Skeletonizer(child: Text('container')), + ); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 1a4944b6..f6cbeab4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -72,6 +72,7 @@ dependencies: url: https://github.com/FaFre/speech_to_text_google_dialog.git sqlite3: ^2.7.7 sqlite3_flutter_libs: ^0.5.36 + synchronized: ^3.4.0 text_scroll: ^0.2.0 timeago: ^3.7.1 tor: diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/ml.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/ml.js new file mode 100644 index 00000000..7c53ff46 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/ml.js @@ -0,0 +1,151 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { createEngine } = ChromeUtils.importESModule("chrome://global/content/ml/EngineProcess.sys.mjs"); + +const ML_TASK_FEATURE_EXTRACTION = "feature-extraction"; +const ML_TASK_TEXT2TEXT = "text2text-generation"; + +const SMART_TAB_GROUPING_CONFIG = { + embedding: { + dtype: "q8", + timeoutMS: 2 * 60 * 1000, // 2 minutes + taskName: ML_TASK_FEATURE_EXTRACTION, + featureId: "smart-tab-embedding", + backend: "onnx", + }, + topicGeneration: { + dtype: "q8", + timeoutMS: 2 * 60 * 1000, // 2 minutes + taskName: ML_TASK_TEXT2TEXT, + featureId: "smart-tab-topic", + backend: "onnx", + }, + // dataConfig: { + // titleKey: "label", + // descriptionKey: "description", + // }, + // clustering: { + // dimReductionMethod: null, // Not completed. + // clusterImplementation: CLUSTER_METHODS.KMEANS, + // clusteringTriesPerK: 3, + // anchorMethod: ANCHOR_METHODS.FIXED, + // pregroupedHandlingMethod: PREGROUPED_HANDLING_METHODS.EXCLUDE, + // pregroupedSilhouetteBoost: 2, // Relative weight of the cluster's score and all other cluster's combined + // suggestOtherTabsMethod: SUGGEST_OTHER_TABS_METHODS.NEAREST_NEIGHBOR, + // }, +}; + +/** + * Generate model input from keywords and documents + * @param {string []} keywords + * @param {string []} documents + */ +function createModelInput(keywords, documents) { + if (!keywords || keywords.length === 0) { + return `Topic from keywords: titles: \n${documents.join(" \n")}`; + } + return `Topic from keywords: ${keywords.join(", ")}. titles: \n${documents.join(" \n")}`; +} + +/** + * One artifact of the LLM output is that sometimes words are duplicated + * This function cuts the phrase when it sees the first duplicate word. + * Handles simple singluar / plural duplicates (-s only). + * @param {string} phrase Input phrase + * @returns {string} phrase cut before any duplicate word + */ +function cutAtDuplicateWords(phrase) { + if (!phrase.length) { + return phrase; + } + const wordsSet = new Set(); + const wordList = phrase.split(" "); + for (let i = 0; i < wordList.length; i++) { + let baseWord = wordList[i].toLowerCase(); + if (baseWord.length > 3) { + if (baseWord.slice(-1) === "s") { + baseWord = baseWord.slice(0, -1); + } + } + if (wordsSet.has(baseWord)) { + // We are seeing a baseWord word. Exit with just the words so far and don't + // add any new words + return wordList.slice(0, i).join(" "); + } + wordsSet.add(baseWord); + } + return phrase; // return original phrase +} + + +/** + * + * @param {MLEngine} engine the engine to check + * @return {boolean} true if the engine has not been initialized or closed + */ +function isEngineClosed(engine) { + return !engine || engine?.engineStatus === "closed"; +} + +this.ml = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + ml: { + async containerTopic(keywords, documents) { + if (isEngineClosed(this.topicEngine)) { + const { + featureId, + engineId, + dtype, + taskName, + timeoutMS, + modelId, + modelRevision, + backend, + } = SMART_TAB_GROUPING_CONFIG.topicGeneration; + + let initData = { + featureId, + engineId, + dtype, + taskName, + timeoutMS, + modelId, + modelRevision, + backend, + }; + + this.topicEngine = await createEngine(initData); + } + + const inputArgs = createModelInput( + keywords, + documents + ); + const requestInfo = { + inputArgs, + runOptions: { + max_length: 6, + }, + }; + const request = { + args: [requestInfo.inputArgs], + options: requestInfo.runOptions, + }; + + const res = await this.topicEngine.run(request); + + const generated = cutAtDuplicateWords((res[0]["generated_text"] || "").trim()); + + return generated; + } + } + } + }; + } +}; \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/nlp.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/nlp.js new file mode 100644 index 00000000..98a90e9b --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/api/nlp.js @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { KeywordExtractor } = ChromeUtils.importESModule( + "chrome://global/content/ml/NLPUtils.sys.mjs" +); + +this.nlp = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + nlp: { + async extractKeywords(corpus, maxKeywords = 3) { + try { + const keywordExtractor = new KeywordExtractor(); + const keywords = keywordExtractor.fitTransform(corpus, maxKeywords); + return keywords; + } catch (error) { + throw new ExtensionError(`Keyword extraction failed: ${error.message}`); + } + }, + } + } + }; + } +}; \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/background.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/background.js new file mode 100644 index 00000000..e8f69609 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/background.js @@ -0,0 +1,38 @@ +'use strict'; + +const port = browser.runtime.connectNative("mlEngine"); + +function sendJsonResultForRequest(id) { + return function (result) { + port.postMessage({ + "id": id, + "status": "success", + "result": result + }) + } +} + +function sendErrorForRequest(id) { + return function (error) { + console.error(error); + port.postMessage({ + "id": id, + "status": "error", + "error": error + }); + } +} + +port.onMessage.addListener(async (message) => { + let requestId = message["id"] + switch (message["action"]) { + case "getContainerTopic": + const documents = message["args"]; + const keywords = await browser.experiments.nlp.extractKeywords([documents.slice(0, 3).join(" ")]); + + browser.experiments.ml.containerTopic(keywords[0], documents) + .then(sendJsonResultForRequest(requestId)) + .catch(sendErrorForRequest(requestId)) + break + } +}); diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/manifest.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/manifest.json new file mode 100644 index 00000000..2d2bec86 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/manifest.json @@ -0,0 +1,61 @@ +{ + "manifest_version": 2, + "name": "ml-engine", + "version": "1.0", + "description": "WebLibre ML Engine", + "browser_specific_settings": { + "gecko": { + "id": "ml-engine@weblibre.eu" + } + }, + "experiment_apis": { + "nlp": { + "schema": "schema.json", + "parent": { + "scopes": [ + "addon_parent" + ], + "script": "api/nlp.js", + "paths": [ + [ + "experiments", + "nlp" + ] + ] + } + }, + "ml": { + "schema": "schema.json", + "parent": { + "scopes": [ + "addon_parent" + ], + "script": "api/ml.js", + "paths": [ + [ + "experiments", + "ml" + ] + ] + } + } + }, + "background": { + "scripts": [ + "background.js" + ] + }, + "optional_permissions": [ + "trialML" + ], + "permissions": [ + "nativeMessaging", + "nativeMessagingFromContent", + "geckoViewAddons", + "cookies", + "menus", + "scripting", + "storage", + "" + ] +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/schema.json b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/schema.json new file mode 100644 index 00000000..9f08b237 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/ml_engine/schema.json @@ -0,0 +1,61 @@ +[ + { + "namespace": "experiments.nlp", + "description": "Natural Language Processing utilities", + "functions": [ + { + "name": "extractKeywords", + "type": "function", + "description": "Extract keywords from a corpus of text documents", + "async": true, + "parameters": [ + { + "name": "corpus", + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of text documents to extract keywords from" + }, + { + "name": "maxKeywords", + "type": "integer", + "optional": true, + "default": 3, + "description": "Maximum number of keywords to extract per document" + } + ] + } + ] + }, + { + "namespace": "experiments.ml", + "description": "Machine Learning utilities", + "functions": [ + { + "name": "containerTopic", + "type": "function", + "description": "Generate topic from keywords and documents using ML engine", + "async": true, + "parameters": [ + { + "name": "keywords", + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of keywords to generate topic from" + }, + { + "name": "documents", + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of document titles/content" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt index 9fd47c74..f2f23bc9 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt @@ -9,6 +9,7 @@ import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature import eu.weblibre.flutter_mozilla_components.feature.CookieManagerFeature import eu.weblibre.flutter_mozilla_components.feature.PrefManagerFeature import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature +import eu.weblibre.flutter_mozilla_components.feature.MLEngineFeature import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import mozilla.components.browser.engine.gecko.GeckoEngine import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient @@ -39,7 +40,7 @@ object EngineProvider { builder.extensionsWebAPIEnabled(true) // Disable output for now to improve performance - //builder.consoleOutput(true) + builder.consoleOutput(true) runtime = GeckoRuntime.create(context, builder.build()) } @@ -57,6 +58,7 @@ object EngineProvider { PrefManagerFeature.install(it) ContainerProxyFeature.install(it) BrowserExtensionFeature.install(it, extensionEvents) + MLEngineFeature.install(it) } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt index 6be8a9ce..b2834e81 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBrowserApiImpl.kt @@ -27,6 +27,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoDownloadsApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoEngineSettingsApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoFindApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoIconsApi +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents @@ -152,6 +153,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi { GeckoTabsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTabsApiImpl()) GeckoIconsApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoIconsApiImpl()) GeckoCookieApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoCookieApiImpl()) + GeckoMlApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoMlApiImpl()) GeckoPrefApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPrefApiImpl()) GeckoContainerProxyApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoContainerProxyApiImpl()) GeckoFindApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoFindApiImpl()) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoMlApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoMlApiImpl.kt new file mode 100644 index 00000000..75b0ba99 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoMlApiImpl.kt @@ -0,0 +1,27 @@ +package eu.weblibre.flutter_mozilla_components.api + +import eu.weblibre.flutter_mozilla_components.feature.MLEngineFeature +import eu.weblibre.flutter_mozilla_components.feature.ResultConsumer +import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi +import org.json.JSONArray +import org.json.JSONObject + +class GeckoMlApiImpl : GeckoMlApi { + private fun List?.toJson(): JSONArray { + return JSONArray().apply { + this@toJson?.forEach { put(it) } + } + } + + override fun getContainerTopic(titles: List, callback: (Result) -> Unit) { + MLEngineFeature.scheduleRequest("getContainerTopic", titles.toJson(), object : ResultConsumer { + override fun success(result: JSONObject) { + callback(Result.success(result.getString("result"))) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + callback(Result.failure(Exception("$errorCode $errorMessage $errorDetails"))) + } + }) + } +} \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/MLEngineFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/MLEngineFeature.kt new file mode 100644 index 00000000..a6cdd588 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/MLEngineFeature.kt @@ -0,0 +1,126 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package eu.weblibre.flutter_mozilla_components.feature + +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.webextension.MessageHandler +import mozilla.components.concept.engine.webextension.Port +import mozilla.components.concept.engine.webextension.WebExtension +import mozilla.components.concept.engine.webextension.WebExtensionRuntime +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.webextensions.BuiltInWebExtensionController +import org.json.JSONObject + +object MLEngineFeature { + private val logger = Logger("ml-engine") + + private const val ML_ENGINE_REPORTER_EXTENSION_ID = "ml-engine@weblibre.eu" + private const val ML_ENGINE_REPORTER_EXTENSION_URL = "resource://android/assets/extensions/ml_engine/" + private const val ML_ENGINE_REPORTER_MESSAGING_ID = "mlEngine" + + private var nextRequestId: Int = 0 + private val requestHandlers = HashMap>() + private val mutex = Mutex() + + @VisibleForTesting + // This is an internal var to make it mutable for unit testing purposes only + internal var extensionController = BuiltInWebExtensionController( + ML_ENGINE_REPORTER_EXTENSION_ID, + ML_ENGINE_REPORTER_EXTENSION_URL, + ML_ENGINE_REPORTER_MESSAGING_ID, + ) + + fun scheduleRequest(command: String, args: Any, callback: ResultConsumer) { + val message = JSONObject() + message.put("action", command); + message.put("args", args) + + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + message.put("id", nextRequestId) + + requestHandlers[nextRequestId] = callback + + nextRequestId += 1 + + extensionController.sendBackgroundMessage(message) + } + } + } + } + + private class PrefManagerReporterBackgroundMessageHandler() : MessageHandler { + override fun onPortMessage(message: Any, port: Port) { + runBlocking { + withContext(Dispatchers.Default) { + mutex.withLock { + val messageJSON = message as JSONObject; + + val requestId = messageJSON.getInt("id") + val status = messageJSON.getString("status") + if (status == "success") { + requestHandlers[requestId]?.success(message) + } else { + requestHandlers[requestId]?.error( + "ML Engine", + "Failed to perform operation", + message.getString("error") + ) + } + } + } + } + } + } + + /** + * Installs the web extension in the runtime through the WebExtensionRuntime install method + * + * @param runtime a WebExtensionRuntime. + * @param productName a custom product name used to automatically label reports. Defaults to + * "android-components". + */ + fun install(runtime: WebExtensionRuntime) { + extensionController.registerBackgroundMessageHandler( + PrefManagerReporterBackgroundMessageHandler(), + ) + extensionController.install( + runtime, + onSuccess = { + logger.debug("Installed ml-engine webextension: ${it.id}") + + grantPermissions(runtime, it) + }, + onError = { throwable -> + logger.error("Failed to install ml-engine webextension: ", throwable) + }, + ) + } + + private fun grantPermissions(runtime: WebExtensionRuntime, extension: WebExtension) { + val permissions = listOf("trialML") + val origins = emptyList() // Add any host permissions if needed + + runtime.addOptionalPermissions( + ML_ENGINE_REPORTER_EXTENSION_ID, + permissions, + origins, + onSuccess = { grantedExtension -> + logger.debug("Successfully granted permissions to extension: ${grantedExtension.id}") + }, + onError = { throwable -> + logger.error("Failed to grant permissions to extension: ", throwable) + } + ) + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index 56be813d..5e960e37 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// Autogenerated from Pigeon (v25.5.0), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -3424,6 +3424,42 @@ interface GeckoPrefApi { } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GeckoMlApi { + fun getContainerTopic(titles: List, callback: (Result) -> Unit) + + companion object { + /** The codec used by GeckoMlApi. */ + val codec: MessageCodec by lazy { + GeckoPigeonCodec() + } + /** Sets up an instance of `GeckoMlApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: GeckoMlApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.getContainerTopic$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val titlesArg = args[0] as List + api.getContainerTopic(titlesArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(GeckoPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(GeckoPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoBrowserExtensionApi { fun getMarkdown(htmlList: List, callback: (Result>) -> Unit) diff --git a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart index 92134ea8..ccc19e7b 100644 --- a/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart +++ b/packages/flutter_mozilla_components/lib/flutter_mozilla_components.dart @@ -18,6 +18,7 @@ export 'src/domain/services/gecko_engine_settings.dart'; export 'src/domain/services/gecko_event.dart'; export 'src/domain/services/gecko_find_in_page.dart'; export 'src/domain/services/gecko_icon.dart'; +export 'src/domain/services/gecko_ml.dart'; export 'src/domain/services/gecko_pref.dart'; export 'src/domain/services/gecko_readerable.dart'; export 'src/domain/services/gecko_selection_action.dart'; diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_ml.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_ml.dart new file mode 100644 index 00000000..15fa782e --- /dev/null +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_ml.dart @@ -0,0 +1,15 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart'; + +final _apiInstance = GeckoMlApi(); + +class GeckoMlService { + Future getContainerTopic(Set titles, {int maxCount = 10}) { + return _apiInstance.getContainerTopic(titles.take(maxCount).toList()); + } +} diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 6bfd2a82..23e9c745 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// Autogenerated from Pigeon (v25.5.0), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -3955,6 +3955,48 @@ class GeckoPrefApi { } } +class GeckoMlApi { + /// Constructor for [GeckoMlApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + GeckoMlApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future getContainerTopic(List titles) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoMlApi.getContainerTopic$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([titles]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as String?)!; + } + } +} + class GeckoBrowserExtensionApi { /// Constructor for [GeckoBrowserExtensionApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 0cb2ac79..15590e2d 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -953,6 +953,12 @@ abstract class GeckoPrefApi { void resetPrefs(List? preferenceNames); } +@HostApi() +abstract class GeckoMlApi { + @async + String getContainerTopic(List titles); +} + @HostApi() abstract class GeckoBrowserExtensionApi { @async