pwa improvements: custom name and context selection
This commit is contained in:
@@ -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) {
|
||||
|
||||
+18
-4
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
+164
-70
@@ -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';
|
||||
|
||||
+307
-91
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+54
-31
@@ -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) {
|
||||
|
||||
+9
-4
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ final class SelectedContainerProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedContainerHash() => r'3d30966f0b8a8ee091afb48fb045be2274f4f417';
|
||||
String _$selectedContainerHash() => r'1f0828ce1d3f8b88fd2731a7aae2602a7323092c';
|
||||
|
||||
abstract class _$SelectedContainer extends $Notifier<String?> {
|
||||
String? build();
|
||||
|
||||
+15
-3
@@ -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) {
|
||||
|
||||
+5
@@ -16,11 +16,16 @@ object PwaConstants {
|
||||
const val EXTRA_PWA_TOKEN = "pwa_token"
|
||||
const val EXTRA_PWA_INSTALL_START_URL = "pwa_install_start_url"
|
||||
const val EXTRA_SHORTCUT_TYPE = "shortcut_type"
|
||||
const val EXTRA_SHORTCUT_CONTAINER_MODE = "shortcut_container_mode"
|
||||
|
||||
// Shortcut type values
|
||||
const val SHORTCUT_TYPE_BASIC = "basic"
|
||||
const val SHORTCUT_TYPE_PWA = "pwa"
|
||||
|
||||
// Shortcut container mode values
|
||||
const val SHORTCUT_CONTAINER_MODE_SPECIFIC = "specific"
|
||||
const val SHORTCUT_CONTAINER_MODE_UNASSIGNED = "unassigned"
|
||||
|
||||
// Profile and file paths
|
||||
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
|
||||
const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping"
|
||||
|
||||
+12
-4
@@ -222,14 +222,22 @@ class IntentReceiverActivity : Activity() {
|
||||
PwaConstants.PROFILE_MAPPING_PREFS,
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}"
|
||||
if (prefs.getString(tokenKey, null) == token) {
|
||||
// Tokens are keyed by (url, profile, contextId) since each install
|
||||
// variant gets its own token. Fall back to the legacy (url, profile)
|
||||
// key for shortcuts pinned before context-scoping was introduced.
|
||||
val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID).orEmpty()
|
||||
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}::${contextId}"
|
||||
val legacyTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${intentUrl}::${profileUuid}"
|
||||
if (prefs.getString(tokenKey, null) == token ||
|
||||
prefs.getString(legacyTokenKey, null) == token) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!installStartUrl.isNullOrEmpty() && installStartUrl != intentUrl) {
|
||||
val installTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}"
|
||||
if (prefs.getString(installTokenKey, null) == token) {
|
||||
val installTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}::${contextId}"
|
||||
val legacyInstallTokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${installStartUrl}::${profileUuid}"
|
||||
if (prefs.getString(installTokenKey, null) == token ||
|
||||
prefs.getString(legacyInstallTokenKey, null) == token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+265
-88
@@ -52,6 +52,14 @@ class GeckoPwaApiImpl(
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
}
|
||||
|
||||
private enum class ShortcutKind(
|
||||
val shortcutType: String,
|
||||
val idPrefix: String,
|
||||
) {
|
||||
PWA(PwaConstants.SHORTCUT_TYPE_PWA, "pwa"),
|
||||
BASIC(PwaConstants.SHORTCUT_TYPE_BASIC, "shortcut"),
|
||||
}
|
||||
|
||||
private val logger = Logger("GeckoPwaApiImpl")
|
||||
private val appPrefs by lazy {
|
||||
context.applicationContext.getSharedPreferences(
|
||||
@@ -68,6 +76,7 @@ class GeckoPwaApiImpl(
|
||||
tabId: String?,
|
||||
profileUuid: String,
|
||||
contextId: String?,
|
||||
overrideAppName: String?,
|
||||
callback: (Result<Boolean>) -> Unit
|
||||
) {
|
||||
logger.debug("installWebApp called for tabId: $tabId, profileUuid: $profileUuid, contextId: $contextId")
|
||||
@@ -86,7 +95,7 @@ class GeckoPwaApiImpl(
|
||||
return@launch
|
||||
}
|
||||
|
||||
val manifest = tab.content.webAppManifest ?: run {
|
||||
val baseManifest = tab.content.webAppManifest ?: run {
|
||||
// Generate a synthetic manifest for sites without one
|
||||
val url = tab.content.url
|
||||
val title = tab.content.title.ifBlank { url }
|
||||
@@ -99,17 +108,26 @@ class GeckoPwaApiImpl(
|
||||
)
|
||||
}
|
||||
|
||||
val manifest = overrideAppName?.takeIf { it.isNotBlank() }?.let { name ->
|
||||
baseManifest.copy(name = name, shortName = name)
|
||||
} ?: baseManifest
|
||||
|
||||
logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}")
|
||||
|
||||
val success = createPwaShortcut(
|
||||
manifest = manifest,
|
||||
profileUuid = profileUuid,
|
||||
contextId = contextId,
|
||||
tabFavicon = tab.content.icon,
|
||||
)
|
||||
|
||||
if (success) {
|
||||
components.core.webAppManifestStorage.saveManifest(manifest)
|
||||
storeProfileMapping(manifest.startUrl, profileUuid)
|
||||
// Persist the unmodified manifest so a second install
|
||||
// of the same URL with a different overrideAppName or
|
||||
// contextId does not clobber the first install's
|
||||
// standalone-window metadata. The user-chosen label
|
||||
// lives on the shortcut itself.
|
||||
components.core.webAppManifestStorage.saveManifest(baseManifest)
|
||||
logger.debug("Web app installation completed for tab ${tab.id}")
|
||||
} else {
|
||||
logger.warn("Failed to create PWA shortcut for tab ${tab.id}")
|
||||
@@ -130,6 +148,7 @@ class GeckoPwaApiImpl(
|
||||
manifest: WebAppManifest,
|
||||
profileUuid: String,
|
||||
contextId: String?,
|
||||
tabFavicon: Bitmap?,
|
||||
): Boolean = withContext(Dispatchers.Main) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
@@ -148,18 +167,30 @@ class GeckoPwaApiImpl(
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val (iconBitmap, isMaskable) = loadPwaIcon(manifest)
|
||||
// Prefer a manifest icon when available; for synthetic manifests
|
||||
// (no icons declared) fall back to the tab favicon so we always
|
||||
// ship a shortcut icon — some launchers crash when pinning a
|
||||
// shortcut without one.
|
||||
val iconBitmap = loadPwaIcon(manifest)
|
||||
?: loadTabFaviconBitmap(manifest.startUrl, tabFavicon)
|
||||
|
||||
val shortcutId = generateShortcutId(manifest.startUrl, profileUuid)
|
||||
val appName = manifest.shortName ?: manifest.name ?: "Web App"
|
||||
|
||||
val shortcutId = resolveShortcutId(
|
||||
shortcutManager = shortcutManager,
|
||||
url = manifest.startUrl,
|
||||
profileUuid = profileUuid,
|
||||
contextId = contextId,
|
||||
shortcutKind = ShortcutKind.PWA,
|
||||
)
|
||||
val launchToken = resolveLaunchToken(
|
||||
shortcutManager = shortcutManager,
|
||||
shortcutId = shortcutId,
|
||||
startUrl = manifest.startUrl,
|
||||
profileUuid = profileUuid,
|
||||
contextId = contextId,
|
||||
)
|
||||
|
||||
val appName = manifest.shortName ?: manifest.name ?: "Web App"
|
||||
|
||||
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = Uri.parse(manifest.startUrl)
|
||||
@@ -167,7 +198,11 @@ class GeckoPwaApiImpl(
|
||||
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
|
||||
putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken)
|
||||
putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, manifest.startUrl)
|
||||
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_PWA)
|
||||
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, ShortcutKind.PWA.shortcutType)
|
||||
putExtra(
|
||||
PwaConstants.EXTRA_SHORTCUT_CONTAINER_MODE,
|
||||
resolveShortcutContainerMode(contextId),
|
||||
)
|
||||
}
|
||||
|
||||
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
|
||||
@@ -176,19 +211,17 @@ class GeckoPwaApiImpl(
|
||||
setIntent(shortcutIntent)
|
||||
|
||||
if (iconBitmap != null) {
|
||||
// Only use adaptive bitmap for maskable icons (designed for adaptive shapes)
|
||||
// Regular icons should use createWithBitmap to display as-is
|
||||
if (isMaskable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setIcon(Icon.createWithAdaptiveBitmap(iconBitmap))
|
||||
} else {
|
||||
setIcon(Icon.createWithBitmap(iconBitmap))
|
||||
}
|
||||
// Always use createWithBitmap (never createWithAdaptiveBitmap):
|
||||
// Launcher3's pin-preview routes adaptive icons through
|
||||
// AdaptiveIconDrawable + BitmapShader and promotes intermediates
|
||||
// to HARDWARE, crashing the software preview canvas.
|
||||
setIcon(Icon.createWithBitmap(iconBitmap))
|
||||
}
|
||||
}.build()
|
||||
|
||||
// Update existing shortcut intent if one exists with the same ID
|
||||
// (e.g. upgrading a basic shortcut to PWA). requestPinShortcut alone
|
||||
// may reuse the cached intent on some launchers.
|
||||
// Update an existing install of the same kind in place. Some
|
||||
// launchers reuse cached shortcut metadata unless we explicitly
|
||||
// refresh the pinned record first.
|
||||
updateExistingShortcut(shortcutManager, shortcut)
|
||||
|
||||
val success = shortcutManager.requestPinShortcut(shortcut, null)
|
||||
@@ -218,53 +251,129 @@ class GeckoPwaApiImpl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a collision-resistant shortcut ID from URL + profile using SHA-256.
|
||||
* Resolves the shortcut ID for the current install kind.
|
||||
*
|
||||
* New installs use a kind-specific ID so a standalone PWA and a regular
|
||||
* shortcut for the same site do not overwrite each other. For minimal
|
||||
* migration handling, we still reuse the legacy shared ID if a pinned
|
||||
* shortcut with that ID already exists for the same kind.
|
||||
*/
|
||||
private fun generateShortcutId(url: String, profileUuid: String): String {
|
||||
private fun resolveShortcutId(
|
||||
shortcutManager: ShortcutManager,
|
||||
url: String,
|
||||
profileUuid: String,
|
||||
contextId: String? = null,
|
||||
shortcutKind: ShortcutKind,
|
||||
): String {
|
||||
val typedShortcutId = generateShortcutId(
|
||||
url = url,
|
||||
profileUuid = profileUuid,
|
||||
contextId = contextId,
|
||||
shortcutKind = shortcutKind,
|
||||
)
|
||||
if (shortcutManager.pinnedShortcuts.any { it.id == typedShortcutId }) {
|
||||
return typedShortcutId
|
||||
}
|
||||
|
||||
val legacyShortcutId = generateLegacyShortcutId(
|
||||
url = url,
|
||||
profileUuid = profileUuid,
|
||||
contextId = contextId,
|
||||
)
|
||||
val matchingLegacyShortcut = shortcutManager.pinnedShortcuts.firstOrNull { shortcut ->
|
||||
if (shortcut.id != legacyShortcutId) {
|
||||
return@firstOrNull false
|
||||
}
|
||||
|
||||
val shortcutIntent = shortcut.intent ?: return@firstOrNull false
|
||||
shortcutIntent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == profileUuid &&
|
||||
shortcutIntent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE) == shortcutKind.shortcutType
|
||||
}
|
||||
return matchingLegacyShortcut?.id ?: typedShortcutId
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a collision-resistant shortcut ID from (type, url, profile,
|
||||
* contextId) using SHA-256. The display label is deliberately excluded
|
||||
* because it is presentation, not install identity.
|
||||
*/
|
||||
private fun generateShortcutId(
|
||||
url: String,
|
||||
profileUuid: String,
|
||||
contextId: String? = null,
|
||||
shortcutKind: ShortcutKind,
|
||||
): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hash = digest.digest("$url::$profileUuid".toByteArray())
|
||||
val key = if (contextId.isNullOrEmpty()) {
|
||||
"${shortcutKind.shortcutType}::$url::$profileUuid"
|
||||
} else {
|
||||
"${shortcutKind.shortcutType}::$url::$profileUuid::$contextId"
|
||||
}
|
||||
val hash = digest.digest(key.toByteArray())
|
||||
val hex = hash.take(16).joinToString("") { "%02x".format(it) }
|
||||
return "${shortcutKind.idPrefix}_$hex"
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical shared shortcut ID used before install kind became part of
|
||||
* the identity. Both PWAs and basic shortcuts previously reused this ID.
|
||||
*/
|
||||
private fun generateLegacyShortcutId(
|
||||
url: String,
|
||||
profileUuid: String,
|
||||
contextId: String? = null,
|
||||
): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val key = if (contextId.isNullOrEmpty()) {
|
||||
"$url::$profileUuid"
|
||||
} else {
|
||||
"$url::$profileUuid::$contextId"
|
||||
}
|
||||
val hash = digest.digest(key.toByteArray())
|
||||
val hex = hash.take(16).joinToString("") { "%02x".format(it) }
|
||||
return "pwa_$hex"
|
||||
}
|
||||
|
||||
private fun resolveShortcutContainerMode(contextId: String?): String {
|
||||
return if (contextId.isNullOrEmpty()) {
|
||||
PwaConstants.SHORTCUT_CONTAINER_MODE_UNASSIGNED
|
||||
} else {
|
||||
PwaConstants.SHORTCUT_CONTAINER_MODE_SPECIFIC
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the PWA icon from the manifest using BrowserIcons.
|
||||
* Returns a pair of (bitmap, isMaskable) to determine proper icon format.
|
||||
* Loads the PWA icon from the manifest using BrowserIcons. Requested at
|
||||
* plain LAUNCHER size since the shortcut is set as a non-adaptive bitmap.
|
||||
*/
|
||||
private suspend fun loadPwaIcon(manifest: WebAppManifest): Pair<Bitmap?, Boolean> = withContext(Dispatchers.IO) {
|
||||
private suspend fun loadPwaIcon(manifest: WebAppManifest): Bitmap? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val iconResource = manifest.icons
|
||||
.filter { it.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE) ||
|
||||
it.purpose.contains(WebAppManifest.Icon.Purpose.ANY) }
|
||||
.maxByOrNull { (it.sizes?.maxOf { size -> size.width * size.height } ?: 0) }
|
||||
?: manifest.icons.firstOrNull()
|
||||
?: return@withContext null
|
||||
|
||||
if (iconResource != null) {
|
||||
val isMaskable = iconResource.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE)
|
||||
val iconRequest = IconRequest(
|
||||
url = manifest.startUrl,
|
||||
size = IconRequest.Size.LAUNCHER_ADAPTIVE,
|
||||
resources = listOf(
|
||||
IconRequest.Resource(
|
||||
url = iconResource.src,
|
||||
type = IconRequest.Resource.Type.MANIFEST_ICON,
|
||||
sizes = iconResource.sizes?.map { size ->
|
||||
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
|
||||
} ?: emptyList(),
|
||||
mimeType = iconResource.type,
|
||||
maskable = isMaskable
|
||||
)
|
||||
val iconRequest = IconRequest(
|
||||
url = manifest.startUrl,
|
||||
size = IconRequest.Size.LAUNCHER,
|
||||
resources = listOf(
|
||||
IconRequest.Resource(
|
||||
url = iconResource.src,
|
||||
type = IconRequest.Resource.Type.MANIFEST_ICON,
|
||||
sizes = iconResource.sizes?.map { size ->
|
||||
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
|
||||
} ?: emptyList(),
|
||||
mimeType = iconResource.type,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val iconResult = components.core.icons.loadIcon(iconRequest).await()
|
||||
Pair(iconResult?.bitmap, isMaskable)
|
||||
} else {
|
||||
Pair(null, false)
|
||||
}
|
||||
components.core.icons.loadIcon(iconRequest).await()?.bitmap
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to load PWA icon", e)
|
||||
Pair(null, false)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,16 +444,23 @@ class GeckoPwaApiImpl(
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val shortcutId = generateShortcutId(url, profileUuid)
|
||||
val shortLabel = title.ifBlank { url }
|
||||
|
||||
val shortcutId = resolveShortcutId(
|
||||
shortcutManager = shortcutManager,
|
||||
url = url,
|
||||
profileUuid = profileUuid,
|
||||
contextId = contextId,
|
||||
shortcutKind = ShortcutKind.BASIC,
|
||||
)
|
||||
val launchToken = resolveLaunchToken(
|
||||
shortcutManager = shortcutManager,
|
||||
shortcutId = shortcutId,
|
||||
startUrl = url,
|
||||
profileUuid = profileUuid,
|
||||
contextId = contextId,
|
||||
)
|
||||
|
||||
val shortLabel = title.ifBlank { url }
|
||||
|
||||
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = Uri.parse(url)
|
||||
@@ -352,7 +468,11 @@ class GeckoPwaApiImpl(
|
||||
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
|
||||
putExtra(PwaConstants.EXTRA_PWA_TOKEN, launchToken)
|
||||
putExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL, url)
|
||||
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, PwaConstants.SHORTCUT_TYPE_BASIC)
|
||||
putExtra(PwaConstants.EXTRA_SHORTCUT_TYPE, ShortcutKind.BASIC.shortcutType)
|
||||
putExtra(
|
||||
PwaConstants.EXTRA_SHORTCUT_CONTAINER_MODE,
|
||||
resolveShortcutContainerMode(contextId),
|
||||
)
|
||||
}
|
||||
|
||||
val icon = loadTabIcon(url, tabIcon)
|
||||
@@ -364,7 +484,7 @@ class GeckoPwaApiImpl(
|
||||
icon?.let { setIcon(it) }
|
||||
}.build()
|
||||
|
||||
// Update existing shortcut intent if one exists with the same ID
|
||||
// Update an existing install of the same kind in place.
|
||||
updateExistingShortcut(shortcutManager, shortcut)
|
||||
|
||||
val success = shortcutManager.requestPinShortcut(shortcut, null)
|
||||
@@ -377,31 +497,37 @@ class GeckoPwaApiImpl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an icon for the shortcut from the tab's favicon or BrowserIcons.
|
||||
* Loads a favicon-style bitmap for [url], preferring the in-memory tab icon
|
||||
* and falling back to BrowserIcons at LAUNCHER size. Returns a live bitmap
|
||||
* (caller is responsible for converting to software before use).
|
||||
*/
|
||||
private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? = withContext(Dispatchers.IO) {
|
||||
private suspend fun loadTabFaviconBitmap(
|
||||
url: String,
|
||||
tabIcon: Bitmap?,
|
||||
): Bitmap? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Try using the tab's existing favicon first
|
||||
val bitmap = tabIcon?.takeUnless { it.isRecycled }
|
||||
tabIcon?.takeUnless { it.isRecycled }
|
||||
?: run {
|
||||
// Fall back to loading via BrowserIcons
|
||||
val iconRequest = IconRequest(
|
||||
url = url,
|
||||
size = IconRequest.Size.LAUNCHER,
|
||||
)
|
||||
components.core.icons.loadIcon(iconRequest).await()?.bitmap
|
||||
}
|
||||
|
||||
bitmap?.takeUnless { it.isRecycled }?.let {
|
||||
val bitmapCopy = it.copy(it.config ?: Bitmap.Config.ARGB_8888, false)
|
||||
Icon.createWithBitmap(bitmapCopy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to load tab icon", e)
|
||||
logger.error("Failed to load tab favicon bitmap", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an icon for the shortcut from the tab's favicon or BrowserIcons.
|
||||
*/
|
||||
private suspend fun loadTabIcon(url: String, tabIcon: Bitmap?): Icon? {
|
||||
val bitmap = loadTabFaviconBitmap(url, tabIcon)
|
||||
return bitmap?.takeUnless { it.isRecycled }?.let(Icon::createWithBitmap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the scope from a URL (origin + path up to last segment).
|
||||
*/
|
||||
@@ -425,14 +551,51 @@ class GeckoPwaApiImpl(
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val storage = components.core.webAppManifestStorage
|
||||
val manifests = storage.loadShareableManifests(System.currentTimeMillis())
|
||||
val currentProfileUuid = getCurrentProfileUuid()
|
||||
val pwaManifests = manifests.filter { manifest ->
|
||||
val mappedProfile = getProfileMapping(manifest.startUrl)
|
||||
currentProfileUuid == null || mappedProfile == null || mappedProfile == currentProfileUuid
|
||||
}.map { manifest ->
|
||||
manifest.toPwaManifest()
|
||||
|
||||
// Pinned shortcuts are the source of truth for installs: each
|
||||
// pinned shortcut carries its own label, profile, and
|
||||
// contextId in its intent extras. Two installs of the same
|
||||
// URL with different contextIds or labels are two distinct
|
||||
// shortcuts here, even though Mozilla's manifest storage
|
||||
// keys the underlying manifest by URL only. We join the
|
||||
// shared manifest with per-install fields from each shortcut.
|
||||
val pwaShortcuts = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.getSystemService<ShortcutManager>()?.pinnedShortcuts
|
||||
?.filter { shortcut ->
|
||||
val intent = shortcut.intent ?: return@filter false
|
||||
intent.getStringExtra(PwaConstants.EXTRA_SHORTCUT_TYPE) ==
|
||||
PwaConstants.SHORTCUT_TYPE_PWA
|
||||
}
|
||||
?: emptyList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val pwaManifests = pwaShortcuts.mapNotNull { shortcut ->
|
||||
val intent = shortcut.intent ?: return@mapNotNull null
|
||||
val shortcutProfile =
|
||||
intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)
|
||||
if (currentProfileUuid != null &&
|
||||
shortcutProfile != null &&
|
||||
shortcutProfile != currentProfileUuid
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val installStartUrl =
|
||||
intent.getStringExtra(PwaConstants.EXTRA_PWA_INSTALL_START_URL)
|
||||
?: intent.dataString
|
||||
?: return@mapNotNull null
|
||||
val manifest = storage.loadManifest(installStartUrl)
|
||||
?: return@mapNotNull null
|
||||
|
||||
manifest.toPwaManifest(
|
||||
contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID),
|
||||
installLabel = shortcut.shortLabel?.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug("Found ${pwaManifests.size} installed web apps")
|
||||
callback(Result.success(pwaManifests))
|
||||
} catch (e: Exception) {
|
||||
@@ -442,19 +605,14 @@ class GeckoPwaApiImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private fun storeProfileMapping(startUrl: String, profileUuid: String) {
|
||||
appPrefs.edit()
|
||||
.putString(startUrl, profileUuid)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun resolveLaunchToken(
|
||||
shortcutManager: ShortcutManager,
|
||||
shortcutId: String,
|
||||
startUrl: String,
|
||||
profileUuid: String,
|
||||
contextId: String?,
|
||||
): String {
|
||||
val storedToken = getStoredLaunchToken(startUrl, profileUuid)
|
||||
val storedToken = getStoredLaunchToken(startUrl, profileUuid, contextId)
|
||||
val existingShortcutToken = shortcutManager.pinnedShortcuts
|
||||
.firstOrNull { shortcut -> shortcut.id == shortcutId }
|
||||
?.intent
|
||||
@@ -464,7 +622,7 @@ class GeckoPwaApiImpl(
|
||||
?.getStringExtra(PwaConstants.EXTRA_PWA_TOKEN)
|
||||
|
||||
if (!existingShortcutToken.isNullOrEmpty()) {
|
||||
val committed = storeLaunchToken(startUrl, profileUuid, existingShortcutToken)
|
||||
val committed = storeLaunchToken(startUrl, profileUuid, contextId, existingShortcutToken)
|
||||
if (!committed) {
|
||||
logger.warn("Failed to persist pinned shortcut PWA token for $startUrl")
|
||||
}
|
||||
@@ -472,7 +630,7 @@ class GeckoPwaApiImpl(
|
||||
}
|
||||
|
||||
if (!storedToken.isNullOrEmpty()) {
|
||||
val committed = storeLaunchToken(startUrl, profileUuid, storedToken)
|
||||
val committed = storeLaunchToken(startUrl, profileUuid, contextId, storedToken)
|
||||
if (!committed) {
|
||||
logger.warn("Failed to refresh stored PWA launch token index for $startUrl")
|
||||
}
|
||||
@@ -480,27 +638,40 @@ class GeckoPwaApiImpl(
|
||||
}
|
||||
|
||||
val generatedToken = UUID.randomUUID().toString()
|
||||
val committed = storeLaunchToken(startUrl, profileUuid, generatedToken)
|
||||
val committed = storeLaunchToken(startUrl, profileUuid, contextId, generatedToken)
|
||||
if (!committed) {
|
||||
logger.warn("Failed to persist PWA launch token for $startUrl")
|
||||
}
|
||||
return generatedToken
|
||||
}
|
||||
|
||||
private fun getStoredLaunchToken(startUrl: String, profileUuid: String): String? {
|
||||
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
|
||||
return appPrefs.getString(tokenKey, null)
|
||||
private fun tokenKey(startUrl: String, profileUuid: String, contextId: String?): String {
|
||||
// Keyed by (startUrl, profileUuid, contextId) so multiple installs of
|
||||
// the same URL with different storage contexts each keep their own
|
||||
// token and don't share or overwrite each other.
|
||||
return "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}::${contextId.orEmpty()}"
|
||||
}
|
||||
|
||||
private fun storeLaunchToken(startUrl: String, profileUuid: String, token: String): Boolean {
|
||||
val tokenKey = "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
|
||||
return appPrefs.edit()
|
||||
.putString(tokenKey, token)
|
||||
.commit()
|
||||
private fun legacyTokenKey(startUrl: String, profileUuid: String): String {
|
||||
return "${PwaConstants.PROFILE_MAPPING_TOKEN_PREFIX}${startUrl}::${profileUuid}"
|
||||
}
|
||||
|
||||
private fun getProfileMapping(startUrl: String): String? {
|
||||
return appPrefs.getString(startUrl, null)
|
||||
private fun getStoredLaunchToken(startUrl: String, profileUuid: String, contextId: String?): String? {
|
||||
return appPrefs.getString(tokenKey(startUrl, profileUuid, contextId), null)
|
||||
?: if (contextId.isNullOrEmpty()) {
|
||||
appPrefs.getString(legacyTokenKey(startUrl, profileUuid), null)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun storeLaunchToken(startUrl: String, profileUuid: String, contextId: String?, token: String): Boolean {
|
||||
return appPrefs.edit().apply {
|
||||
putString(tokenKey(startUrl, profileUuid, contextId), token)
|
||||
if (contextId.isNullOrEmpty()) {
|
||||
putString(legacyTokenKey(startUrl, profileUuid), token)
|
||||
}
|
||||
}.commit()
|
||||
}
|
||||
|
||||
private fun getCurrentProfileUuid(): String? {
|
||||
@@ -517,10 +688,16 @@ class GeckoPwaApiImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private fun WebAppManifest.toPwaManifest(currentUrl: String = startUrl): PwaManifest {
|
||||
private fun WebAppManifest.toPwaManifest(
|
||||
currentUrl: String = startUrl,
|
||||
contextId: String? = null,
|
||||
installLabel: String? = null,
|
||||
): PwaManifest {
|
||||
return PwaManifest(
|
||||
startUrl = startUrl,
|
||||
currentUrl = currentUrl,
|
||||
contextId = contextId,
|
||||
installLabel = installLabel,
|
||||
name = name,
|
||||
shortName = shortName,
|
||||
display = display?.name?.lowercase()?.replace("_", "-"),
|
||||
|
||||
+28
-5
@@ -5124,7 +5124,22 @@ data class PwaManifest (
|
||||
* The URL of the page when the manifest was detected.
|
||||
* Used for HTTPS/installability checks.
|
||||
*/
|
||||
val currentUrl: String
|
||||
val currentUrl: String,
|
||||
/**
|
||||
* Storage context (container contextualIdentity or isolated `iso1_…` id)
|
||||
* that this install was pinned with. Only populated by
|
||||
* `getInstalledWebApps` — read from the pinned shortcut's intent extras,
|
||||
* not from the manifest itself, because the same URL can have multiple
|
||||
* installs that differ only in contextId.
|
||||
*/
|
||||
val contextId: String? = null,
|
||||
/**
|
||||
* User-chosen launcher label for this specific install (from the pinned
|
||||
* shortcut's shortLabel). May differ from `name`/`shortName` because the
|
||||
* underlying manifest is shared across install variants of the same URL.
|
||||
* Only populated by `getInstalledWebApps`.
|
||||
*/
|
||||
val installLabel: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
@@ -5145,7 +5160,9 @@ data class PwaManifest (
|
||||
val preferRelatedApplications = pigeonVar_list[13] as Boolean
|
||||
val shareTarget = pigeonVar_list[14] as ShareTarget?
|
||||
val currentUrl = pigeonVar_list[15] as String
|
||||
return PwaManifest(startUrl, name, shortName, display, themeColor, backgroundColor, scope, description, icons, dir, lang, orientation, relatedApplications, preferRelatedApplications, shareTarget, currentUrl)
|
||||
val contextId = pigeonVar_list[16] as String?
|
||||
val installLabel = pigeonVar_list[17] as String?
|
||||
return PwaManifest(startUrl, name, shortName, display, themeColor, backgroundColor, scope, description, icons, dir, lang, orientation, relatedApplications, preferRelatedApplications, shareTarget, currentUrl, contextId, installLabel)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
@@ -5166,6 +5183,8 @@ data class PwaManifest (
|
||||
preferRelatedApplications,
|
||||
shareTarget,
|
||||
currentUrl,
|
||||
contextId,
|
||||
installLabel,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@@ -5176,7 +5195,7 @@ data class PwaManifest (
|
||||
return true
|
||||
}
|
||||
val other = other as PwaManifest
|
||||
return GeckoPigeonUtils.deepEquals(this.startUrl, other.startUrl) && GeckoPigeonUtils.deepEquals(this.name, other.name) && GeckoPigeonUtils.deepEquals(this.shortName, other.shortName) && GeckoPigeonUtils.deepEquals(this.display, other.display) && GeckoPigeonUtils.deepEquals(this.themeColor, other.themeColor) && GeckoPigeonUtils.deepEquals(this.backgroundColor, other.backgroundColor) && GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.description, other.description) && GeckoPigeonUtils.deepEquals(this.icons, other.icons) && GeckoPigeonUtils.deepEquals(this.dir, other.dir) && GeckoPigeonUtils.deepEquals(this.lang, other.lang) && GeckoPigeonUtils.deepEquals(this.orientation, other.orientation) && GeckoPigeonUtils.deepEquals(this.relatedApplications, other.relatedApplications) && GeckoPigeonUtils.deepEquals(this.preferRelatedApplications, other.preferRelatedApplications) && GeckoPigeonUtils.deepEquals(this.shareTarget, other.shareTarget) && GeckoPigeonUtils.deepEquals(this.currentUrl, other.currentUrl)
|
||||
return GeckoPigeonUtils.deepEquals(this.startUrl, other.startUrl) && GeckoPigeonUtils.deepEquals(this.name, other.name) && GeckoPigeonUtils.deepEquals(this.shortName, other.shortName) && GeckoPigeonUtils.deepEquals(this.display, other.display) && GeckoPigeonUtils.deepEquals(this.themeColor, other.themeColor) && GeckoPigeonUtils.deepEquals(this.backgroundColor, other.backgroundColor) && GeckoPigeonUtils.deepEquals(this.scope, other.scope) && GeckoPigeonUtils.deepEquals(this.description, other.description) && GeckoPigeonUtils.deepEquals(this.icons, other.icons) && GeckoPigeonUtils.deepEquals(this.dir, other.dir) && GeckoPigeonUtils.deepEquals(this.lang, other.lang) && GeckoPigeonUtils.deepEquals(this.orientation, other.orientation) && GeckoPigeonUtils.deepEquals(this.relatedApplications, other.relatedApplications) && GeckoPigeonUtils.deepEquals(this.preferRelatedApplications, other.preferRelatedApplications) && GeckoPigeonUtils.deepEquals(this.shareTarget, other.shareTarget) && GeckoPigeonUtils.deepEquals(this.currentUrl, other.currentUrl) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.installLabel, other.installLabel)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -5197,6 +5216,8 @@ data class PwaManifest (
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.preferRelatedApplications)
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.shareTarget)
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.currentUrl)
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.contextId)
|
||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.installLabel)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -10635,9 +10656,10 @@ interface GeckoPwaApi {
|
||||
* The [tabId] identifies which tab to install from. If null, uses the selected tab.
|
||||
* The [profileUuid] is the UUID of the current user profile.
|
||||
* The [contextId] is the container's contextual identity (optional, null for default container).
|
||||
* The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest.
|
||||
* Returns true if installation was successful.
|
||||
*/
|
||||
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result<Boolean>) -> Unit)
|
||||
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, overrideAppName: String?, callback: (Result<Boolean>) -> Unit)
|
||||
/** Returns a list of all installed PWA manifests. */
|
||||
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
|
||||
/**
|
||||
@@ -10672,7 +10694,8 @@ interface GeckoPwaApi {
|
||||
val tabIdArg = args[0] as String?
|
||||
val profileUuidArg = args[1] as String
|
||||
val contextIdArg = args[2] as String?
|
||||
api.installWebApp(tabIdArg, profileUuidArg, contextIdArg) { result: Result<Boolean> ->
|
||||
val overrideAppNameArg = args[3] as String?
|
||||
api.installWebApp(tabIdArg, profileUuidArg, contextIdArg, overrideAppNameArg) { result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(GeckoPigeonUtils.wrapError(error))
|
||||
|
||||
@@ -5474,6 +5474,8 @@ class PwaManifest {
|
||||
required this.preferRelatedApplications,
|
||||
this.shareTarget,
|
||||
required this.currentUrl,
|
||||
this.contextId,
|
||||
this.installLabel,
|
||||
});
|
||||
|
||||
String startUrl;
|
||||
@@ -5510,6 +5512,19 @@ class PwaManifest {
|
||||
/// Used for HTTPS/installability checks.
|
||||
String currentUrl;
|
||||
|
||||
/// Storage context (container contextualIdentity or isolated `iso1_…` id)
|
||||
/// that this install was pinned with. Only populated by
|
||||
/// `getInstalledWebApps` — read from the pinned shortcut's intent extras,
|
||||
/// not from the manifest itself, because the same URL can have multiple
|
||||
/// installs that differ only in contextId.
|
||||
String? contextId;
|
||||
|
||||
/// User-chosen launcher label for this specific install (from the pinned
|
||||
/// shortcut's shortLabel). May differ from `name`/`shortName` because the
|
||||
/// underlying manifest is shared across install variants of the same URL.
|
||||
/// Only populated by `getInstalledWebApps`.
|
||||
String? installLabel;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
startUrl,
|
||||
@@ -5528,6 +5543,8 @@ class PwaManifest {
|
||||
preferRelatedApplications,
|
||||
shareTarget,
|
||||
currentUrl,
|
||||
contextId,
|
||||
installLabel,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5553,6 +5570,8 @@ class PwaManifest {
|
||||
preferRelatedApplications: result[13]! as bool,
|
||||
shareTarget: result[14] as ShareTarget?,
|
||||
currentUrl: result[15]! as String,
|
||||
contextId: result[16] as String?,
|
||||
installLabel: result[17] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5565,7 +5584,7 @@ class PwaManifest {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(startUrl, other.startUrl) && _deepEquals(name, other.name) && _deepEquals(shortName, other.shortName) && _deepEquals(display, other.display) && _deepEquals(themeColor, other.themeColor) && _deepEquals(backgroundColor, other.backgroundColor) && _deepEquals(scope, other.scope) && _deepEquals(description, other.description) && _deepEquals(icons, other.icons) && _deepEquals(dir, other.dir) && _deepEquals(lang, other.lang) && _deepEquals(orientation, other.orientation) && _deepEquals(relatedApplications, other.relatedApplications) && _deepEquals(preferRelatedApplications, other.preferRelatedApplications) && _deepEquals(shareTarget, other.shareTarget) && _deepEquals(currentUrl, other.currentUrl);
|
||||
return _deepEquals(startUrl, other.startUrl) && _deepEquals(name, other.name) && _deepEquals(shortName, other.shortName) && _deepEquals(display, other.display) && _deepEquals(themeColor, other.themeColor) && _deepEquals(backgroundColor, other.backgroundColor) && _deepEquals(scope, other.scope) && _deepEquals(description, other.description) && _deepEquals(icons, other.icons) && _deepEquals(dir, other.dir) && _deepEquals(lang, other.lang) && _deepEquals(orientation, other.orientation) && _deepEquals(relatedApplications, other.relatedApplications) && _deepEquals(preferRelatedApplications, other.preferRelatedApplications) && _deepEquals(shareTarget, other.shareTarget) && _deepEquals(currentUrl, other.currentUrl) && _deepEquals(contextId, other.contextId) && _deepEquals(installLabel, other.installLabel);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -10499,15 +10518,16 @@ class GeckoPwaApi {
|
||||
/// The [tabId] identifies which tab to install from. If null, uses the selected tab.
|
||||
/// The [profileUuid] is the UUID of the current user profile.
|
||||
/// The [contextId] is the container's contextual identity (optional, null for default container).
|
||||
/// The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest.
|
||||
/// Returns true if installation was successful.
|
||||
Future<bool> installWebApp(String? tabId, String profileUuid, String? contextId) async {
|
||||
Future<bool> installWebApp(String? tabId, String profileUuid, String? contextId, String? overrideAppName) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installWebApp$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[tabId, profileUuid, contextId]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[tabId, profileUuid, contextId, overrideAppName]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
|
||||
@@ -2673,6 +2673,19 @@ class PwaManifest {
|
||||
/// Used for HTTPS/installability checks.
|
||||
final String currentUrl;
|
||||
|
||||
/// Storage context (container contextualIdentity or isolated `iso1_…` id)
|
||||
/// that this install was pinned with. Only populated by
|
||||
/// `getInstalledWebApps` — read from the pinned shortcut's intent extras,
|
||||
/// not from the manifest itself, because the same URL can have multiple
|
||||
/// installs that differ only in contextId.
|
||||
final String? contextId;
|
||||
|
||||
/// User-chosen launcher label for this specific install (from the pinned
|
||||
/// shortcut's shortLabel). May differ from `name`/`shortName` because the
|
||||
/// underlying manifest is shared across install variants of the same URL.
|
||||
/// Only populated by `getInstalledWebApps`.
|
||||
final String? installLabel;
|
||||
|
||||
const PwaManifest({
|
||||
required this.startUrl,
|
||||
required this.currentUrl,
|
||||
@@ -2690,6 +2703,8 @@ class PwaManifest {
|
||||
this.relatedApplications = const [],
|
||||
this.preferRelatedApplications = false,
|
||||
this.shareTarget,
|
||||
this.contextId,
|
||||
this.installLabel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2708,9 +2723,15 @@ abstract class GeckoPwaApi {
|
||||
/// The [tabId] identifies which tab to install from. If null, uses the selected tab.
|
||||
/// The [profileUuid] is the UUID of the current user profile.
|
||||
/// The [contextId] is the container's contextual identity (optional, null for default container).
|
||||
/// The [overrideAppName] customizes the installed app's displayed name and persists in the saved manifest.
|
||||
/// Returns true if installation was successful.
|
||||
@async
|
||||
bool installWebApp(String? tabId, String profileUuid, String? contextId);
|
||||
bool installWebApp(
|
||||
String? tabId,
|
||||
String profileUuid,
|
||||
String? contextId,
|
||||
String? overrideAppName,
|
||||
);
|
||||
|
||||
/// Returns a list of all installed PWA manifests.
|
||||
@async
|
||||
|
||||
Reference in New Issue
Block a user