pwa improvements: custom name and context selection

This commit is contained in:
Fabian Freund
2026-04-20 12:20:55 +02:00
parent 7af0f1d4e3
commit b699ef5fba
23 changed files with 1220 additions and 439 deletions
@@ -250,14 +250,25 @@ class TabTreeRoute extends GoRouteData with $TabTreeRoute {
class OpenSharedContentRoute extends GoRouteData with $OpenSharedContentRoute {
final String sharedUrl;
final String? contextId;
final String? containerMode;
const OpenSharedContentRoute({this.sharedUrl = 'about:blank'});
const OpenSharedContentRoute({
this.sharedUrl = 'about:blank',
this.contextId,
this.containerMode,
});
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return BottomSheetPage(
builder: (_) => OpenSharedContent(
sharedUrl: Uri.tryParse(sharedUrl) ?? Uri.parse('about:blank'),
contextId: contextId,
containerMode: IntentContainerMode.fromWireValue(
containerMode,
contextId: contextId,
),
),
);
}
@@ -81,6 +81,7 @@ import 'package:weblibre/features/settings/presentation/screens/tracking_protect
import 'package:weblibre/features/settings/presentation/screens/web_content_settings.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/share_intent/domain/entities/intent_container_mode.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';
@@ -860,6 +860,8 @@ mixin $OpenSharedContentRoute on GoRouteData {
static OpenSharedContentRoute _fromState(GoRouterState state) =>
OpenSharedContentRoute(
sharedUrl: state.uri.queryParameters['shared-url'] ?? 'about:blank',
contextId: state.uri.queryParameters['context-id'],
containerMode: state.uri.queryParameters['container-mode'],
);
OpenSharedContentRoute get _self => this as OpenSharedContentRoute;
@@ -869,6 +871,8 @@ mixin $OpenSharedContentRoute on GoRouteData {
'/browser/open_content',
queryParams: {
if (_self.sharedUrl != 'about:blank') 'shared-url': _self.sharedUrl,
if (_self.contextId != null) 'context-id': _self.contextId,
if (_self.containerMode != null) 'container-mode': _self.containerMode,
},
);
@@ -18,14 +18,21 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
class ReceivedIntentParameter with FastEquatable {
final String? content;
final String? tool;
final String? contextId;
final IntentContainerMode containerMode;
ReceivedIntentParameter(this.content, this.tool, {this.contextId});
ReceivedIntentParameter(
this.content,
this.tool, {
this.contextId,
this.containerMode = IntentContainerMode.useSelected,
});
@override
List<Object?> get hashParameters => [content, tool, contextId];
List<Object?> get hashParameters => [content, tool, contextId, containerMode];
}
@@ -35,8 +35,11 @@ final _contentParserTransformer =
StreamTransformer<ReceivedIntentParameter, SharedContent>.fromHandlers(
handleData: (parameter, sink) {
final parsed = parameter.content.mapNotNull(
(content) =>
SharedContent.parse(content, contextId: parameter.contextId),
(content) => SharedContent.parse(
content,
contextId: parameter.contextId,
containerMode: parameter.containerMode,
),
);
if (parsed != null) {
@@ -58,6 +58,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_inte
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatekeeper_replicator.dart';
import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
@@ -403,6 +404,7 @@ class _BrowserViewState extends ConsumerState<BrowserView>
final containerSelection = await _resolveContainerSelection(
ref,
sharedContent.contextId,
sharedContent.containerMode,
);
await ref
@@ -443,6 +445,8 @@ class _BrowserViewState extends ConsumerState<BrowserView>
case SharedUrl():
final route = OpenSharedContentRoute(
sharedUrl: sharedContent.url.toString(),
contextId: sharedContent.contextId,
containerMode: sharedContent.containerMode.queryValueOrNull,
);
await router.push(route.location);
case SharedText():
@@ -704,13 +708,19 @@ class _BrowserViewState extends ConsumerState<BrowserView>
}
}
/// Resolves a [TabContainerSelection] from a shortcut intent's context ID.
/// Returns [TabContainerSelection.useSelected] if no contextId or container not found.
/// Resolves a [TabContainerSelection] from incoming launch container metadata.
Future<TabContainerSelection> _resolveContainerSelection(
WidgetRef ref,
String? contextId,
IntentContainerMode containerMode,
) async {
if (contextId == null) return const TabContainerSelection.useSelected();
if (contextId == null) {
return switch (containerMode) {
IntentContainerMode.unassigned =>
const TabContainerSelection.unassigned(),
_ => const TabContainerSelection.useSelected(),
};
}
final container = await ref
.read(containerRepositoryProvider.notifier)
@@ -720,5 +730,9 @@ Future<TabContainerSelection> _resolveContainerSelection(
return TabContainerSelection.specific(container);
}
return const TabContainerSelection.useSelected();
return switch (containerMode) {
IntentContainerMode.useSelected =>
const TabContainerSelection.useSelected(),
_ => const TabContainerSelection.unassigned(),
};
}
@@ -27,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
@@ -35,22 +36,34 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/presentatio
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/hooks/debouncer.dart';
import 'package:weblibre/presentation/icons/weblibre_icons.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/ui_helper.dart';
class OpenSharedContent extends HookConsumerWidget {
final Uri sharedUrl;
final String? contextId;
final IntentContainerMode containerMode;
static final _appLinksService = GeckoAppLinksService();
const OpenSharedContent({super.key, required this.sharedUrl});
const OpenSharedContent({
super.key,
required this.sharedUrl,
this.contextId,
this.containerMode = IntentContainerMode.useSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -58,7 +71,81 @@ class OpenSharedContent extends HookConsumerWidget {
final textController = useTextEditingController(text: sharedUrl.toString());
final appColors = AppColors.of(context);
final selectedContainer = useState<ContainerData?>(null);
final globalSelectedContainer = ref.watch(
selectedContainerDataProvider.select((value) => value.value),
);
final selectedContainer = useState<ContainerData?>(
containerMode == IntentContainerMode.useSelected
? globalSelectedContainer
: null,
);
final containerSelectionTouched = useRef(false);
final defaultTabType = ref.read(
generalSettingsWithDefaultsProvider.select(
(value) => value.effectiveDefaultCreateTabType,
),
);
// If the intent carried an isolated context, preselect the isolated tab
// type and preserve the existing context id so opening doesn't mint a
// fresh one.
final carriedIsolatedContextId = useMemoized(
() => isIsolatedContextId(contextId) ? contextId : null,
[contextId],
);
final selectedTabType = useState(
carriedIsolatedContextId != null ? TabType.isolated : defaultTabType,
);
useEffect(() {
if (!containerSelectionTouched.value &&
containerMode == IntentContainerMode.useSelected) {
selectedContainer.value = globalSelectedContainer;
}
return null;
}, [globalSelectedContainer, containerMode]);
useEffect(() {
if (containerMode != IntentContainerMode.specific ||
contextId == null ||
isIsolatedContextId(contextId)) {
if (!containerSelectionTouched.value &&
containerMode == IntentContainerMode.unassigned) {
selectedContainer.value = null;
}
return null;
}
var cancelled = false;
unawaited(
Future(() async {
final container = await ref
.read(containerRepositoryProvider.notifier)
.getContainerByContextualIdentity(contextId!);
if (cancelled ||
!context.mounted ||
containerSelectionTouched.value) {
return;
}
selectedContainer.value = container;
}),
);
return () {
cancelled = true;
};
}, [containerMode, contextId]);
TabMode resolveTabMode() {
if (selectedTabType.value == TabType.isolated &&
carriedIsolatedContextId != null) {
return TabMode.isolated(carriedIsolatedContextId);
}
return TabMode.fromTabType(selectedTabType.value);
}
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
@@ -151,7 +238,7 @@ class OpenSharedContent extends HookConsumerWidget {
}
}
Future<void> openCustomTab(bool isPrivate) async {
Future<void> openCustomTab(TabMode tabMode) async {
if (formKey.currentState?.validate() == true) {
final parsedUrl = parseValidatedUrl(
textController.text,
@@ -161,10 +248,14 @@ class OpenSharedContent extends HookConsumerWidget {
return;
}
final contextId = tabMode is IsolatedTabMode
? tabMode.isolationContextId
: selectedContainer.value?.metadata.contextualIdentity;
await GeckoBrowserService().openInCustomTab(
url: parsedUrl,
private: isPrivate,
contextId: selectedContainer.value?.id,
private: tabMode is PrivateTabMode,
contextId: contextId,
);
if (context.mounted) {
@@ -203,18 +294,7 @@ class OpenSharedContent extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Open link', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
if (settings.showContainerUi)
ContainerChips(
displayMenu: false,
selectedContainer: selectedContainer.value,
onSelected: (container) {
selectedContainer.value = container;
},
onDeleted: (container) {
selectedContainer.value = null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: textController,
keyboardType: TextInputType.url,
@@ -337,64 +417,78 @@ class OpenSharedContent extends HookConsumerWidget {
icon: Icons.open_in_new,
onTap: openInApp,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Builder(
builder: (context) {
final tabTypeSwitcher = AnimatedTabTypeSwitcher(
selected: selectedTabType.value,
onChanged: (value) => selectedTabType.value = value,
showIsolatedOption:
settings.showIsolatedTabUi ||
carriedIsolatedContextId != null,
selectedBackgroundColor: switch (selectedTabType.value) {
TabType.regular => null,
TabType.private => appColors.privateSelectionOverlay,
TabType.isolated => appColors.isolatedSelectionOverlay,
TabType.child => null,
},
);
if (!settings.showContainerUi) {
return Center(child: tabTypeSwitcher);
}
return Row(
children: [
Expanded(
flex: 4,
child: Align(
alignment: Alignment.centerLeft,
child: tabTypeSwitcher,
),
),
const SizedBox(width: 8),
Flexible(
flex: 2,
child: Align(
alignment: Alignment.centerRight,
child: CompactContainerSelector(
selectedContainer: selectedContainer.value,
onSelectionChanged: (selection) async {
containerSelectionTouched.value = true;
switch (selection) {
case ContainerSelectionSelected(
:final containerId,
):
selectedContainer.value = await ref
.read(
containerRepositoryProvider.notifier,
)
.getContainerData(containerId);
case ContainerSelectionUnassigned():
selectedContainer.value = null;
}
},
),
),
),
],
);
},
),
),
_OpenActionTile(
title: 'Open in new tab',
subtitle: 'Add to your browser tabs',
icon: MdiIcons.tab,
trailing: PopupMenuButton<TabMode>(
icon: const Icon(WebLibreIcons.tabType, size: 24),
tooltip: settings.showIsolatedTabUi
? 'Private / Isolated'
: 'Private',
onSelected: openTab,
itemBuilder: (context) => [
PopupMenuItem(
value: TabMode.private,
child: Row(
children: [
Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 20,
),
const SizedBox(width: 12),
const Text('Private'),
],
),
),
if (settings.showIsolatedTabUi)
PopupMenuItem(
value: TabMode.newIsolated(),
child: Row(
children: [
Icon(
MdiIcons.snowflake,
color: appColors.isolatedTabTeal,
size: 20,
),
const SizedBox(width: 12),
const Text('Isolated'),
],
),
),
],
),
onTap: () => openTab(TabMode.regular),
onTap: () => openTab(resolveTabMode()),
),
_OpenActionTile(
title: 'Open in custom tab',
subtitle: 'Open in a separate window',
icon: MdiIcons.applicationOutline,
trailing: IconButton(
icon: Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 24,
),
tooltip: 'Private',
onPressed: () => openCustomTab(true),
),
onTap: () => openCustomTab(false),
onTap: () => openCustomTab(resolveTabMode()),
),
],
),
@@ -491,7 +585,7 @@ class _OpenActionTile extends StatelessWidget {
final IconData icon;
final Widget? trailing;
final bool showTrailingDivider;
final VoidCallback onTap;
final VoidCallback? onTap;
const _OpenActionTile({
required this.title,
@@ -499,7 +593,7 @@ class _OpenActionTile extends StatelessWidget {
required this.icon,
this.trailing,
this.showTrailingDivider = true,
required this.onTap,
this.onTap,
});
@override
@@ -29,8 +29,6 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
part 'providers.g.dart';
@@ -95,7 +93,11 @@ bool isCurrentTabInstallable(Ref ref) {
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
@Riverpod()
Future<bool> installCurrentWebApp(Ref ref) async {
Future<bool> installCurrentWebApp(
Ref ref, {
String? overrideName,
String? contextId,
}) async {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId == null) {
@@ -104,18 +106,12 @@ Future<bool> installCurrentWebApp(Ref ref) async {
final profileUuid = filesystem.selectedProfile.uuid;
final selectedContainerId = ref.read(selectedContainerProvider);
String? contextId;
if (selectedContainerId != null) {
final containerRepository = ref.read(containerRepositoryProvider.notifier);
final containerData = await containerRepository.getContainerData(
selectedContainerId,
);
contextId = containerData?.metadata.contextualIdentity;
}
return GeckoPwaApi().installWebApp(selectedTabId, profileUuid, contextId);
return GeckoPwaApi().installWebApp(
selectedTabId,
profileUuid,
contextId,
overrideName,
);
}
/// Returns all installed PWAs.
@@ -139,7 +135,11 @@ bool isCurrentTabShortcutable(Ref ref) {
/// Creates a basic bookmark shortcut on the home screen for the current tab.
@Riverpod()
Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
Future<bool> installBasicShortcut(
Ref ref, {
String? overrideName,
String? contextId,
}) async {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId == null) {
@@ -148,17 +148,6 @@ Future<bool> installBasicShortcut(Ref ref, {String? overrideName}) async {
final profileUuid = filesystem.selectedProfile.uuid;
final selectedContainerId = ref.read(selectedContainerProvider);
String? contextId;
if (selectedContainerId != null) {
final containerRepository = ref.read(containerRepositoryProvider.notifier);
final containerData = await containerRepository.getContainerData(
selectedContainerId,
);
contextId = containerData?.metadata.contextualIdentity;
}
return GeckoPwaApi().installBasicShortcut(
selectedTabId,
profileUuid,
@@ -213,7 +213,7 @@ String _$isCurrentTabInstallableHash() =>
/// in the shortcut intent so the PWA reopens with the same isolation.
@ProviderFor(installCurrentWebApp)
final installCurrentWebAppProvider = InstallCurrentWebAppProvider._();
final installCurrentWebAppProvider = InstallCurrentWebAppFamily._();
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
@@ -223,20 +223,27 @@ final class InstallCurrentWebAppProvider
with $FutureModifier<bool>, $FutureProvider<bool> {
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
InstallCurrentWebAppProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'installCurrentWebAppProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
InstallCurrentWebAppProvider._({
required InstallCurrentWebAppFamily super.from,
required ({String? overrideName, String? contextId}) super.argument,
}) : super(
retry: null,
name: r'installCurrentWebAppProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$installCurrentWebAppHash();
@override
String toString() {
return r'installCurrentWebAppProvider'
''
'$argument';
}
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
@@ -244,12 +251,61 @@ final class InstallCurrentWebAppProvider
@override
FutureOr<bool> create(Ref ref) {
return installCurrentWebApp(ref);
final argument =
this.argument as ({String? overrideName, String? contextId});
return installCurrentWebApp(
ref,
overrideName: argument.overrideName,
contextId: argument.contextId,
);
}
@override
bool operator ==(Object other) {
return other is InstallCurrentWebAppProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$installCurrentWebAppHash() =>
r'da536e2aca886831e0bbbc5b70eae03ac4a4ea9f';
r'0e0013fac7b70441983f7af1e6db3c6470c23900';
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
final class InstallCurrentWebAppFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<bool>,
({String? overrideName, String? contextId})
> {
InstallCurrentWebAppFamily._()
: super(
retry: null,
name: r'installCurrentWebAppProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
InstallCurrentWebAppProvider call({
String? overrideName,
String? contextId,
}) => InstallCurrentWebAppProvider._(
argument: (overrideName: overrideName, contextId: contextId),
from: this,
);
@override
String toString() => r'installCurrentWebAppProvider';
}
/// Returns all installed PWAs.
@@ -357,7 +413,7 @@ final class InstallBasicShortcutProvider
/// Creates a basic bookmark shortcut on the home screen for the current tab.
InstallBasicShortcutProvider._({
required InstallBasicShortcutFamily super.from,
required String? super.argument,
required ({String? overrideName, String? contextId}) super.argument,
}) : super(
retry: null,
name: r'installBasicShortcutProvider',
@@ -373,7 +429,7 @@ final class InstallBasicShortcutProvider
String toString() {
return r'installBasicShortcutProvider'
''
'($argument)';
'$argument';
}
@$internal
@@ -383,8 +439,13 @@ final class InstallBasicShortcutProvider
@override
FutureOr<bool> create(Ref ref) {
final argument = this.argument as String?;
return installBasicShortcut(ref, overrideName: argument);
final argument =
this.argument as ({String? overrideName, String? contextId});
return installBasicShortcut(
ref,
overrideName: argument.overrideName,
contextId: argument.contextId,
);
}
@override
@@ -399,12 +460,16 @@ final class InstallBasicShortcutProvider
}
String _$installBasicShortcutHash() =>
r'aa8a96e94eac19e3e0b2a087bc6dde12d28f48f2';
r'fcb5bec79f375b32a7a0953ad22859a0db222167';
/// Creates a basic bookmark shortcut on the home screen for the current tab.
final class InstallBasicShortcutFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
with
$FunctionalFamilyOverride<
FutureOr<bool>,
({String? overrideName, String? contextId})
> {
InstallBasicShortcutFamily._()
: super(
retry: null,
@@ -416,8 +481,13 @@ final class InstallBasicShortcutFamily extends $Family
/// Creates a basic bookmark shortcut on the home screen for the current tab.
InstallBasicShortcutProvider call({String? overrideName}) =>
InstallBasicShortcutProvider._(argument: overrideName, from: this);
InstallBasicShortcutProvider call({
String? overrideName,
String? contextId,
}) => InstallBasicShortcutProvider._(
argument: (overrideName: overrideName, contextId: contextId),
from: this,
);
@override
String toString() => r'installBasicShortcutProvider';
@@ -18,41 +18,67 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
/// The type of home screen shortcut the user chose.
enum ShortcutInstallType { shortcut, app }
/// Shows a bottom sheet to confirm adding a PWA to the home screen.
///
/// Returns true if the user confirms, null if dismissed.
Future<bool?> showPwaInstallBottomSheet(
/// Result of the install configuration sheets.
class PwaInstallConfig {
final String name;
final String? contextId;
const PwaInstallConfig({required this.name, required this.contextId});
}
/// Result of the shortcut choice sheet (basic vs app + config).
class ShortcutInstallConfig {
final ShortcutInstallType type;
final String name;
final String? contextId;
const ShortcutInstallConfig({
required this.type,
required this.name,
required this.contextId,
});
}
/// Shows a bottom sheet to confirm installing a PWA with an editable name
/// and a storage (contextId) selection.
Future<PwaInstallConfig?> showPwaInstallBottomSheet(
BuildContext context, {
required String name,
required String defaultName,
required Uri url,
}) {
return showModalBottomSheet<bool>(
return showModalBottomSheet<ShortcutInstallConfig>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ShortcutSheetHeader(name: name, url: url),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.install_mobile),
title: const Text('Install as App'),
subtitle: const Text('Runs standalone with its own window.'),
onTap: () => Navigator.of(context).pop(true),
),
],
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: _InstallConfigSheet(
defaultName: defaultName,
url: url,
showAppOption: true,
showShortcutOption: false,
),
),
);
).then((result) {
if (result == null) return null;
return PwaInstallConfig(name: result.name, contextId: result.contextId);
});
}
/// Shows a bottom sheet for non-manifest sites offering a choice between
@@ -60,99 +86,289 @@ Future<bool?> showPwaInstallBottomSheet(
///
/// [showAppOption] controls whether the "Install as App" option is visible
/// (requires the allowNonManifestPwaInstall setting to be enabled).
///
/// Returns [ShortcutInstallType] or null if dismissed.
Future<ShortcutInstallType?> showShortcutChoiceBottomSheet(
Future<ShortcutInstallConfig?> showShortcutChoiceBottomSheet(
BuildContext context, {
required String name,
required String defaultName,
required Uri url,
required bool showAppOption,
}) {
return showModalBottomSheet<ShortcutInstallType>(
return showModalBottomSheet<ShortcutInstallConfig>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ShortcutSheetHeader(name: name, url: url),
const Divider(height: 1),
if (showAppOption)
ListTile(
leading: const Icon(Icons.install_mobile),
title: const Text('Install as App'),
subtitle: const Text('Runs standalone with its own window.'),
onTap: () =>
Navigator.of(context).pop(ShortcutInstallType.app),
),
ListTile(
leading: const Icon(Icons.shortcut),
title: const Text('Add Shortcut'),
subtitle: const Text('Opens as a standard tab in the browser.'),
onTap: () =>
Navigator.of(context).pop(ShortcutInstallType.shortcut),
),
],
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: _InstallConfigSheet(
defaultName: defaultName,
url: url,
showAppOption: showAppOption,
showShortcutOption: true,
),
),
);
}
class _ShortcutSheetHeader extends StatelessWidget {
final String name;
final Uri url;
/// Internal storage options backing the radio selector.
sealed class _StorageOption {
const _StorageOption();
String? get contextId;
}
const _ShortcutSheetHeader({required this.name, required this.url});
class _StorageDefault extends _StorageOption {
const _StorageDefault();
@override
String? get contextId => null;
}
class _StorageContainer extends _StorageOption {
final String label;
@override
final String contextId;
const _StorageContainer({required this.label, required this.contextId});
}
class _StorageInheritIsolated extends _StorageOption {
@override
final String contextId;
const _StorageInheritIsolated(this.contextId);
}
class _StorageNewIsolated extends _StorageOption {
@override
final String contextId;
_StorageNewIsolated() : contextId = newIsolatedContextId();
}
class _InstallConfigSheet extends HookConsumerWidget {
final String defaultName;
final Uri url;
final bool showAppOption;
final bool showShortcutOption;
const _InstallConfigSheet({
required this.defaultName,
required this.url,
required this.showAppOption,
required this.showShortcutOption,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Add to Home Screen', style: textTheme.titleMedium),
const SizedBox(height: 12),
Row(
children: [
RepaintBoundary(child: UrlIcon([url], iconSize: 32)),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
UriBreadcrumb(
uri: url,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
final nameController = useTextEditingController(text: defaultName);
final selectedTabId = ref.watch(selectedTabProvider);
final tabContextId = selectedTabId != null
? ref.watch(tabStateProvider(selectedTabId))?.contextId
: null;
final containerAsync = ref.watch(selectedContainerDataProvider);
final containerData = containerAsync.asData?.value;
final options = useMemoized<List<_StorageOption>>(
() {
final list = <_StorageOption>[const _StorageDefault()];
// If the current tab is in an isolated context, offer to inherit it.
if (isIsolatedContextId(tabContextId)) {
list.add(_StorageInheritIsolated(tabContextId!));
}
// If a regular (non-isolated) container is active, offer it.
final containerContextId = containerData?.metadata.contextualIdentity;
if (containerData != null &&
containerContextId != null &&
!isIsolatedContextId(containerContextId)) {
list.add(
_StorageContainer(
label: containerData.name ?? 'Container',
contextId: containerContextId,
),
);
}
list.add(_StorageNewIsolated());
return list;
},
[tabContextId, containerData?.id, containerData?.metadata.contextualIdentity],
);
// Pick sensible default selection based on current context.
final defaultIndex = useMemoized(() {
for (var i = 0; i < options.length; i++) {
final o = options[i];
if (o is _StorageInheritIsolated) return i;
if (o is _StorageContainer) return i;
}
return 0;
}, [options]);
final selectedIndex = useState(defaultIndex);
final userTouched = useRef(false);
// When options resolve async (e.g. selected container stream arrives
// after first build), re-sync the radio to the computed default — unless
// the user has already made a choice.
useEffect(() {
if (!userTouched.value) {
selectedIndex.value = defaultIndex;
}
return null;
}, [defaultIndex]);
void submit(ShortcutInstallType type) {
final name = nameController.text.trim();
Navigator.of(context).pop(
ShortcutInstallConfig(
type: type,
name: name.isEmpty ? defaultName : name,
contextId: options[selectedIndex.value].contextId,
),
);
}
return SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
],
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Add to Home Screen', style: textTheme.titleMedium),
const SizedBox(height: 12),
Row(
children: [
RepaintBoundary(child: UrlIcon([url], iconSize: 32)),
const SizedBox(width: 14),
Expanded(
child: UriBreadcrumb(
uri: url,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
isDense: true,
),
textInputAction: TextInputAction.done,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
'Storage',
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
RadioGroup<int>(
groupValue: selectedIndex.value,
onChanged: (v) {
if (v != null) {
userTouched.value = true;
selectedIndex.value = v;
}
},
child: Column(
children: [
for (var i = 0; i < options.length; i++)
_StorageTile(option: options[i], value: i),
],
),
),
const SizedBox(height: 8),
const Divider(height: 1),
if (showAppOption)
ListTile(
leading: const Icon(Icons.install_mobile),
title: const Text('Install as App'),
subtitle: const Text('Runs standalone with its own window.'),
onTap: () => submit(ShortcutInstallType.app),
),
if (showShortcutOption)
ListTile(
leading: const Icon(Icons.shortcut),
title: const Text('Add Shortcut'),
subtitle: const Text(
'Opens as a standard tab in the browser.',
),
onTap: () => submit(ShortcutInstallType.shortcut),
),
const SizedBox(height: 8),
],
),
),
);
}
}
class _StorageTile extends StatelessWidget {
final _StorageOption option;
final int value;
const _StorageTile({required this.option, required this.value});
@override
Widget build(BuildContext context) {
final (title, subtitle, icon) = switch (option) {
_StorageDefault() => (
'Default',
'Uses the default browser storage (no container).',
Icons.public,
),
_StorageContainer(:final label) => (
'Container "$label"',
'Shares cookies and data with the selected container.',
Icons.folder_outlined,
),
_StorageInheritIsolated() => (
'Inherit current isolated context',
'Shares storage with the currently open isolated session.',
Icons.link,
),
_StorageNewIsolated() => (
'New isolated context',
'Creates a fresh storage jar just for this installation.',
Icons.shield_outlined,
),
};
return RadioListTile<int>(
value: value,
dense: true,
secondary: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
);
}
}
@@ -32,44 +32,52 @@ import 'package:weblibre/utils/ui_helper.dart';
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
final selectedTabId = ref.read(selectedTabProvider);
final manifest = ref.read(currentTabManifestProvider);
final name = manifest?.name ?? manifest?.shortName ?? 'this web app';
final defaultName =
manifest?.shortName ?? manifest?.name ?? 'this web app';
final tabState = selectedTabId != null
? ref.read(tabStateProvider(selectedTabId))
: null;
final url = tabState?.url ?? Uri.parse('about:blank');
final confirmed = await showPwaInstallBottomSheet(
final config = await showPwaInstallBottomSheet(
context,
name: name,
defaultName: defaultName,
url: url,
);
if (confirmed == true) {
try {
final success = await ref.read(installCurrentWebAppProvider.future);
if (config == null) return;
if (context.mounted) {
if (success) {
showInfoMessage(context, '$name added to home screen');
} else {
showErrorMessage(
context,
'Failed to add $name. The site may not support installation.',
);
}
final name = config.name;
try {
final success = await ref.read(
installCurrentWebAppProvider(
overrideName: name == defaultName ? null : name,
contextId: config.contextId,
).future,
);
if (context.mounted) {
if (success) {
showInfoMessage(context, '$name added to home screen');
} else {
showErrorMessage(
context,
'Failed to add $name. The site may not support installation.',
);
}
} catch (e, stackTrace) {
logger.e('Failed to install PWA', error: e, stackTrace: stackTrace);
}
} catch (e, stackTrace) {
logger.e('Failed to install PWA', error: e, stackTrace: stackTrace);
if (context.mounted) {
var errorMessage = 'Failed to add $name to home screen';
if (context.mounted) {
var errorMessage = 'Failed to add $name to home screen';
if (e is StateError) {
errorMessage = 'No tab selected. Please try again.';
}
showErrorMessage(context, errorMessage);
if (e is StateError) {
errorMessage = 'No tab selected. Please try again.';
}
showErrorMessage(context, errorMessage);
}
}
}
@@ -84,28 +92,43 @@ Future<void> showShortcutInstallDialog(
if (selectedTabId == null) return;
final tabState = ref.read(tabStateProvider(selectedTabId));
final name = tabState?.title ?? 'this site';
final defaultName = tabState?.title.trim().isNotEmpty == true
? tabState!.title
: 'this site';
final url = tabState?.url ?? Uri.parse('about:blank');
final settings = ref.read(generalSettingsWithDefaultsProvider);
final showAppOption = settings.allowNonManifestPwaInstall;
final choice = await showShortcutChoiceBottomSheet(
final config = await showShortcutChoiceBottomSheet(
context,
name: name,
defaultName: defaultName,
url: url,
showAppOption: showAppOption,
);
if (choice == null) return;
if (config == null) return;
final name = config.name;
final overrideName = name == defaultName ? null : name;
try {
final bool success;
switch (choice) {
switch (config.type) {
case ShortcutInstallType.shortcut:
success = await ref.read(installBasicShortcutProvider().future);
success = await ref.read(
installBasicShortcutProvider(
overrideName: overrideName,
contextId: config.contextId,
).future,
);
case ShortcutInstallType.app:
success = await ref.read(installCurrentWebAppProvider.future);
success = await ref.read(
installCurrentWebAppProvider(
overrideName: overrideName,
contextId: config.contextId,
).future,
);
}
if (context.mounted) {
@@ -51,21 +51,26 @@ class SelectedContainer extends _$SelectedContainer {
return null;
}
Future<SetContainerResult> setContainerId(String id) async {
Future<SetContainerResult> setContainerId(
String id, {
bool Function()? shouldApply,
}) async {
final container = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(id);
if (ref.mounted && container != null) {
bool canApply() => shouldApply?.call() ?? true;
if (ref.mounted && container != null && canApply()) {
if (container.metadata.useProxy) {
final proxyPluginHealthy = await GeckoContainerProxyService()
.healthcheck();
if (proxyPluginHealthy) {
if (ref.mounted && proxyPluginHealthy && canApply()) {
state = id;
return SetContainerResult.successHasProxy;
}
} else {
} else if (canApply()) {
state = id;
return SetContainerResult.success;
}
@@ -41,7 +41,7 @@ final class SelectedContainerProvider
}
}
String _$selectedContainerHash() => r'3d30966f0b8a8ee091afb48fb045be2274f4f417';
String _$selectedContainerHash() => r'1f0828ce1d3f8b88fd2731a7aae2602a7323092c';
abstract class _$SelectedContainer extends $Notifier<String?> {
String? build();
@@ -36,8 +36,14 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart
/// Long pressing opens the edit screen for the selected container.
class CompactContainerSelector extends ConsumerWidget {
final ContainerData? selectedContainer;
final Future<void> Function(ContainerSelectionResult selection)?
onSelectionChanged;
const CompactContainerSelector({super.key, this.selectedContainer});
const CompactContainerSelector({
super.key,
this.selectedContainer,
this.onSelectionChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -73,6 +79,14 @@ class CompactContainerSelector extends ConsumerWidget {
onPressed: () async {
final selection = await const ContainerSelectionRoute()
.push<ContainerSelectionResult?>(context);
if (selection == null) {
return;
}
if (onSelectionChanged != null) {
await onSelectionChanged!(selection);
return;
}
switch (selection) {
case ContainerSelectionSelected(:final containerId):
@@ -81,8 +95,6 @@ class CompactContainerSelector extends ConsumerWidget {
.setContainerId(containerId);
case ContainerSelectionUnassigned():
ref.read(selectedContainerProvider.notifier).clearContainer();
case null:
break;
}
},
),
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
enum IntentContainerMode {
useSelected('use-selected'),
unassigned('unassigned'),
specific('specific');
const IntentContainerMode(this.wireValue);
final String wireValue;
String? get queryValueOrNull =>
this == IntentContainerMode.useSelected ? null : wireValue;
static IntentContainerMode fromWireValue(
String? wireValue, {
String? contextId,
}) {
for (final mode in values) {
if (mode.wireValue == wireValue) {
return mode;
}
}
if (contextId != null) {
return IntentContainerMode.specific;
}
return IntentContainerMode.useSelected;
}
}
@@ -18,29 +18,42 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
import 'package:weblibre/utils/input_classification.dart';
sealed class SharedContent with FastEquatable {
final String? contextId;
final IntentContainerMode containerMode;
SharedContent({this.contextId});
SharedContent({
this.contextId,
this.containerMode = IntentContainerMode.useSelected,
});
factory SharedContent.parse(String content, {String? contextId}) {
factory SharedContent.parse(
String content, {
String? contextId,
IntentContainerMode containerMode = IntentContainerMode.useSelected,
}) {
if (parseSharedIntentUrl(content) case final Uri uri) {
return SharedUrl(uri, contextId: contextId);
return SharedUrl(uri, contextId: contextId, containerMode: containerMode);
} else {
return SharedText(content, contextId: contextId);
return SharedText(
content,
contextId: contextId,
containerMode: containerMode,
);
}
}
@override
List<Object?> get hashParameters => [contextId];
List<Object?> get hashParameters => [contextId, containerMode];
}
final class SharedUrl extends SharedContent {
final Uri url;
SharedUrl(this.url, {super.contextId});
SharedUrl(this.url, {super.contextId, super.containerMode});
@override
String toString() => url.toString();
@@ -52,7 +65,7 @@ final class SharedUrl extends SharedContent {
final class SharedText extends SharedContent {
final String text;
SharedText(this.text, {super.contextId});
SharedText(this.text, {super.contextId, super.containerMode});
@override
String toString() => text;
@@ -28,93 +28,109 @@ import 'package:uri_to_file/uri_to_file.dart' as uri_to_file;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/data/models/received_intent_parameter.dart';
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
part 'sharing_intent.g.dart';
StreamTransformer<Intent, ReceivedIntentParameter>
_buildSharingIntentTransformer(IntentGatekeeper gatekeeper) =>
StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
handleData: (intent, sink) async {
// PWA shortcut intents carry our own signed context id — always allow.
final pwaContextId =
intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;
_buildSharingIntentTransformer(
IntentGatekeeper gatekeeper,
) => StreamTransformer<Intent, ReceivedIntentParameter>.fromHandlers(
handleData: (intent, sink) async {
final shortcutContextId = intent.action == 'android.intent.action.VIEW'
? intent.extra['pwa_context_id'] as String?
: null;
final containerMode = intent.action == 'android.intent.action.VIEW'
? IntentContainerMode.fromWireValue(
intent.extra['shortcut_container_mode'] as String?,
contextId: shortcutContextId,
)
: IntentContainerMode.useSelected;
if (pwaContextId == null) {
final allowed = await gatekeeper.shouldAllow(
fromPackageName: intent.fromPackageName,
url: intent.data,
);
if (!allowed) {
logger.i(
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
);
return;
}
}
final allowed = await gatekeeper.shouldAllow(
fromPackageName: intent.fromPackageName,
url: intent.data,
);
if (!allowed) {
logger.i(
'Blocked intent from ${intent.fromPackageName ?? 'unknown app'}',
);
return;
}
final data = switch (intent.action) {
'android.intent.action.PROCESS_TEXT' =>
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
'android.intent.action.WEB_SEARCH' =>
intent.extra['query'] as String?,
'android.intent.action.VIEW' => intent.data,
'android.intent.action.SEND' =>
intent.extra['android.intent.extra.STREAM'] as String? ??
intent.extra['android.intent.extra.TEXT'] as String?,
_ => null,
};
final data = switch (intent.action) {
'android.intent.action.PROCESS_TEXT' =>
intent.extra['android.intent.extra.PROCESS_TEXT'] as String?,
'android.intent.action.WEB_SEARCH' => intent.extra['query'] as String?,
'android.intent.action.VIEW' => intent.data,
'android.intent.action.SEND' =>
intent.extra['android.intent.extra.STREAM'] as String? ??
intent.extra['android.intent.extra.TEXT'] as String?,
_ => null,
};
// Extract container context from shortcut intents
final contextId = pwaContextId;
// Extract container context from shortcut intents.
final contextId = shortcutContextId;
if (data != null) {
if (uri_to_file.isUriSupported(data)) {
var path = data;
if (p.extension(data).whenNotEmpty == null) {
if (intent.mimeType.whenNotEmpty != null) {
final ext = mime.extensionFromMime(intent.mimeType!);
if (ext != null) {
path = p.setExtension(path, '.$ext');
} else {
logger.w(
'Could not determine file extension for: ${intent.mimeType}',
);
}
} else {
logger.w(
'Received intent without extension and mime type $path',
);
}
}
try {
final file = await uri_to_file.toFile(path);
final mimeType = mime.lookupMimeType(file.path);
switch (mimeType) {
case 'application/pdf':
sink.add(
ReceivedIntentParameter(path, null, contextId: contextId),
);
default:
logger.w('Unhandled mime type: $mimeType');
}
} catch (e) {
logger.e('Failed to convert URI to file: $e');
// Fallback: pass the original URI
sink.add(
ReceivedIntentParameter(data, null, contextId: contextId),
if (data != null) {
if (uri_to_file.isUriSupported(data)) {
var path = data;
if (p.extension(data).whenNotEmpty == null) {
if (intent.mimeType.whenNotEmpty != null) {
final ext = mime.extensionFromMime(intent.mimeType!);
if (ext != null) {
path = p.setExtension(path, '.$ext');
} else {
logger.w(
'Could not determine file extension for: ${intent.mimeType}',
);
}
} else {
sink.add(
ReceivedIntentParameter(data, null, contextId: contextId),
);
logger.w('Received intent without extension and mime type $path');
}
}
},
);
try {
final file = await uri_to_file.toFile(path);
final mimeType = mime.lookupMimeType(file.path);
switch (mimeType) {
case 'application/pdf':
sink.add(
ReceivedIntentParameter(
path,
null,
contextId: contextId,
containerMode: containerMode,
),
);
default:
logger.w('Unhandled mime type: $mimeType');
}
} catch (e) {
logger.e('Failed to convert URI to file: $e');
// Fallback: pass the original URI
sink.add(
ReceivedIntentParameter(
data,
null,
contextId: contextId,
containerMode: containerMode,
),
);
}
} else {
sink.add(
ReceivedIntentParameter(
data,
null,
contextId: contextId,
containerMode: containerMode,
),
);
}
}
},
);
@Riverpod(keepAlive: true)
Raw<Stream<ReceivedIntentParameter>> sharingIntentStream(Ref ref) {