initial pwa + custom tabs

This commit is contained in:
Fabian Freund
2026-02-10 16:20:54 +01:00
parent 592c6afdc5
commit 188dfdd003
47 changed files with 3974 additions and 130 deletions
+41 -8
View File
@@ -100,12 +100,35 @@
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.WEB_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Intent router: receives all VIEW intents and routes Custom Tab / PWA to
ExternalAppBrowserActivity, regular VIEW intents to MainActivity -->
<activity
android:name="eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity"
android:theme="@style/Theme.AppCompat.Translucent"
android:exported="true"
android:excludeFromRecents="true"
android:relinquishTaskIdentity="true"
android:taskAffinity="">
<!-- Required exactly like this to appear in both default app choosers and when explicitly browsing -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="mozilla.components.pwa.category.SHORTCUT" />
<data android:scheme="http" />
<data android:scheme="https" />
@@ -133,19 +156,29 @@
<data android:mimeType="application/pdf" />
</intent-filter>
<!-- PWA launch from home screen shortcut -->
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.WEB_SEARCH" />
<action android:name="mozilla.components.feature.pwa.VIEW_PWA" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="https" />
</intent-filter>
</activity>
<!-- Native activity for Custom Tabs and PWA browsing (non-Flutter) -->
<activity
android:name="eu.weblibre.flutter_mozilla_components.activities.ExternalAppBrowserActivity"
android:exported="false"
android:taskAffinity=""
android:documentLaunchMode="always"
android:autoRemoveFromRecents="false"
android:theme="@style/ExternalAppBrowserTheme"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:persistableMode="persistNever"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<activity
android:theme="@style/AddonsActivityTheme"
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
@@ -30,6 +30,11 @@
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
</style>
<style name="ExternalAppBrowserTheme" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="AddonsActivityTheme" parent="AppTheme">
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
</style>
+4
View File
@@ -70,6 +70,10 @@ class _Filesystem {
return fs.clearMozillaProfileCache(profileId);
}
List<String> getMozillaProfileIds(UuidValue uuid) {
return fs.getMozillaProfileIds(getProfileDir(uuid));
}
Future<String?> checkForDuplicateMozillaProfile(UuidValue profile) async {
final duplicates = await fs
.getProfilesWithDuplicateMozillaProfiles(profilesDir)
@@ -48,6 +48,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/services/eng
import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
@@ -503,6 +504,20 @@ class _BrowserViewState extends ConsumerState<BrowserView>
);
},
);
// Ensure PWA manifest state is collected and stays alive
ref.listenManual(
fireImmediately: true,
pwaManifestStateProvider,
(previous, next) {},
onError: (error, stackTrace) {
logger.e(
'Error listening to pwaManifestStateProvider',
error: error,
stackTrace: stackTrace,
);
},
);
}
@override
@@ -37,6 +37,8 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
@@ -195,6 +197,27 @@ class TabMenu extends HookConsumerWidget {
).push(context);
},
),
Consumer(
builder: (context, ref, child) {
final isInstallable = ref.watch(isCurrentTabInstallableProvider);
return Visibility(
visible: isInstallable,
child: MenuItemButton(
closeOnActivate: false,
leadingIcon: const Icon(Icons.add_to_home_screen),
child: const Text('Add to Home Screen'),
onPressed: () async {
await showPwaInstallDialog(context, ref);
if (context.mounted) {
MenuController.maybeOf(context)?.close();
}
},
),
);
},
),
if (enableCloneTab)
SubmenuButton(
menuChildren: [
@@ -0,0 +1,123 @@
/*
* 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/>.
*/
import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/filesystem.dart' show filesystem;
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.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';
/// Stream of manifest update events from the event service.
@Riverpod(keepAlive: true)
Stream<ManifestUpdateEvent> manifestUpdateEventsStream(Ref ref) {
final eventService = ref.watch(eventServiceProvider);
return eventService.manifestUpdateEvents;
}
/// Manages PWA manifest state with a long-running subscription.
@Riverpod(keepAlive: true)
class PwaManifestState extends _$PwaManifestState {
PwaManifest? getManifest(String tabId) => state[tabId];
@override
Map<String, PwaManifest?> build() {
// Listen to manifest update events using ref.listenManual
ref.listen(
manifestUpdateEventsStreamProvider,
(previous, next) {
if (next.hasValue) {
final event = next.value!;
// Update state with new manifest - this triggers rebuilds
state = {...state, event.tabId: event.manifest};
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to manifest update events',
error: error,
stackTrace: stackTrace,
);
},
);
return {};
}
}
/// PWA manifest for the currently selected tab.
@Riverpod()
PwaManifest? currentTabManifest(Ref ref) {
final selectedTabId = ref.watch(selectedTabProvider);
final manifestState = ref.watch(pwaManifestStateProvider);
if (selectedTabId == null) return null;
return manifestState[selectedTabId];
}
/// Boolean indicating if the current tab is installable as a PWA.
@Riverpod()
bool isCurrentTabInstallable(Ref ref) {
final manifest = ref.watch(currentTabManifestProvider);
if (manifest == null) return false;
return isManifestInstallable(manifest);
}
/// 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 {
final selectedTabId = ref.read(selectedTabProvider);
if (selectedTabId == null) {
throw StateError('No tab selected');
}
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);
}
/// Returns all installed PWAs.
@Riverpod()
Future<List<PwaManifest>> installedWebApps(Ref ref) {
return GeckoPwaApi().getInstalledWebApps();
}
@@ -0,0 +1,298 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Stream of manifest update events from the event service.
@ProviderFor(manifestUpdateEventsStream)
final manifestUpdateEventsStreamProvider =
ManifestUpdateEventsStreamProvider._();
/// Stream of manifest update events from the event service.
final class ManifestUpdateEventsStreamProvider
extends
$FunctionalProvider<
AsyncValue<ManifestUpdateEvent>,
ManifestUpdateEvent,
Stream<ManifestUpdateEvent>
>
with
$FutureModifier<ManifestUpdateEvent>,
$StreamProvider<ManifestUpdateEvent> {
/// Stream of manifest update events from the event service.
ManifestUpdateEventsStreamProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'manifestUpdateEventsStreamProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$manifestUpdateEventsStreamHash();
@$internal
@override
$StreamProviderElement<ManifestUpdateEvent> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<ManifestUpdateEvent> create(Ref ref) {
return manifestUpdateEventsStream(ref);
}
}
String _$manifestUpdateEventsStreamHash() =>
r'60035756c129c1801f2f1f4e7da6384abf78d8fa';
/// Manages PWA manifest state with a long-running subscription.
@ProviderFor(PwaManifestState)
final pwaManifestStateProvider = PwaManifestStateProvider._();
/// Manages PWA manifest state with a long-running subscription.
final class PwaManifestStateProvider
extends $NotifierProvider<PwaManifestState, Map<String, PwaManifest?>> {
/// Manages PWA manifest state with a long-running subscription.
PwaManifestStateProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pwaManifestStateProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pwaManifestStateHash();
@$internal
@override
PwaManifestState create() => PwaManifestState();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, PwaManifest?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, PwaManifest?>>(value),
);
}
}
String _$pwaManifestStateHash() => r'e5237d2aae5dce9cb805484eb2a09cc3f6aceda1';
/// Manages PWA manifest state with a long-running subscription.
abstract class _$PwaManifestState extends $Notifier<Map<String, PwaManifest?>> {
Map<String, PwaManifest?> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<Map<String, PwaManifest?>, Map<String, PwaManifest?>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Map<String, PwaManifest?>, Map<String, PwaManifest?>>,
Map<String, PwaManifest?>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
/// PWA manifest for the currently selected tab.
@ProviderFor(currentTabManifest)
final currentTabManifestProvider = CurrentTabManifestProvider._();
/// PWA manifest for the currently selected tab.
final class CurrentTabManifestProvider
extends $FunctionalProvider<PwaManifest?, PwaManifest?, PwaManifest?>
with $Provider<PwaManifest?> {
/// PWA manifest for the currently selected tab.
CurrentTabManifestProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentTabManifestProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentTabManifestHash();
@$internal
@override
$ProviderElement<PwaManifest?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
PwaManifest? create(Ref ref) {
return currentTabManifest(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PwaManifest? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PwaManifest?>(value),
);
}
}
String _$currentTabManifestHash() =>
r'6121287eaaa18d080154c8ffe6e2a4b8c2d144d3';
/// Boolean indicating if the current tab is installable as a PWA.
@ProviderFor(isCurrentTabInstallable)
final isCurrentTabInstallableProvider = IsCurrentTabInstallableProvider._();
/// Boolean indicating if the current tab is installable as a PWA.
final class IsCurrentTabInstallableProvider
extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Boolean indicating if the current tab is installable as a PWA.
IsCurrentTabInstallableProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isCurrentTabInstallableProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isCurrentTabInstallableHash();
@$internal
@override
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
bool create(Ref ref) {
return isCurrentTabInstallable(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$isCurrentTabInstallableHash() =>
r'293cdb6dcea24446343330ccdb30dc9f21f03618';
/// Installs the current tab as a PWA, embedding profile and container context
/// in the shortcut intent so the PWA reopens with the same isolation.
@ProviderFor(installCurrentWebApp)
final installCurrentWebAppProvider = InstallCurrentWebAppProvider._();
/// 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 InstallCurrentWebAppProvider
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
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,
);
@override
String debugGetCreateSourceHash() => _$installCurrentWebAppHash();
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<bool> create(Ref ref) {
return installCurrentWebApp(ref);
}
}
String _$installCurrentWebAppHash() =>
r'da536e2aca886831e0bbbc5b70eae03ac4a4ea9f';
/// Returns all installed PWAs.
@ProviderFor(installedWebApps)
final installedWebAppsProvider = InstalledWebAppsProvider._();
/// Returns all installed PWAs.
final class InstalledWebAppsProvider
extends
$FunctionalProvider<
AsyncValue<List<PwaManifest>>,
List<PwaManifest>,
FutureOr<List<PwaManifest>>
>
with
$FutureModifier<List<PwaManifest>>,
$FutureProvider<List<PwaManifest>> {
/// Returns all installed PWAs.
InstalledWebAppsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'installedWebAppsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$installedWebAppsHash();
@$internal
@override
$FutureProviderElement<List<PwaManifest>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<PwaManifest>> create(Ref ref) {
return installedWebApps(ref);
}
}
String _$installedWebAppsHash() => r'ff185620ccd25bf6b71415e34e3e0c0f20d5e59d';
@@ -0,0 +1,132 @@
/*
* 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/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
/// Display modes that are valid for installable PWAs per W3C spec.
const _validDisplayModes = {
'standalone',
'fullscreen',
'minimal-ui',
'window-controls-overlay',
};
/// Determines if a PWA manifest meets installability criteria per W3C spec.
///
/// A web app is installable if:
/// 1. Served over HTTPS (or localhost for development)
/// 2. Has a valid manifest with required fields:
/// - name (or short_name)
/// - start_url (must be same-origin and within scope)
/// - display mode (standalone, fullscreen, minimal-ui, or
/// window-controls-overlay)
/// 3. start_url is within the scope
/// 4. prefer_related_applications is not true
bool isManifestInstallable(PwaManifest manifest) {
// Check HTTPS requirement (relaxed for localhost)
final isSecure =
manifest.currentUrl.startsWith('https://') ||
manifest.currentUrl.startsWith('http://localhost') ||
manifest.currentUrl.startsWith('http://127.0.0.1');
if (!isSecure) {
return false;
}
// prefer_related_applications must not be true
if (manifest.preferRelatedApplications) {
return false;
}
// Check required manifest fields
final hasValidName =
manifest.name?.isNotEmpty == true ||
manifest.shortName?.isNotEmpty == true;
// W3C §1.10.6: start_url must be same-origin as the document URL
final hasValidStartUrl =
manifest.startUrl.isNotEmpty &&
_isSameOrigin(manifest.startUrl, manifest.currentUrl);
final hasValidDisplay =
manifest.display != null &&
_validDisplayModes.contains(manifest.display!.toLowerCase());
// Check that start_url is within scope
final isInScope = _isStartUrlInScope(
manifest.startUrl,
manifest.scope,
);
return hasValidName &&
hasValidStartUrl &&
hasValidDisplay &&
isInScope;
}
/// Returns true if two URLs share the same origin (scheme + host + port).
bool _isSameOrigin(String url1, String url2) {
try {
final uri1 = Uri.parse(url1);
final uri2 = Uri.parse(url2);
return uri1.scheme == uri2.scheme &&
uri1.host == uri2.host &&
uri1.port == uri2.port;
} catch (e) {
return false;
}
}
/// Checks if startUrl is within scope using URL path containment.
///
/// Per W3C spec: when scope is absent, the default scope is the start_url
/// with its last path segment, query, and fragment removed.
/// Scope matching uses path-prefix comparison on `/` boundaries.
bool _isStartUrlInScope(String startUrl, String? scope) {
try {
final startUri = Uri.parse(startUrl);
if (scope == null || scope.isEmpty) {
// Per W3C: default scope = start_url with filename/query/fragment removed
// The start_url is trivially within its own default scope.
return true;
}
final scopeUri = Uri.parse(scope);
// Must be same origin
if (startUri.scheme != scopeUri.scheme ||
startUri.host != scopeUri.host ||
startUri.port != scopeUri.port) {
return false;
}
// Path containment: scope path must be a prefix of start_url path
// on a `/` boundary to avoid "/app" matching "/application"
final scopePath =
scopeUri.path.endsWith('/') ? scopeUri.path : '${scopeUri.path}/';
final startPath =
startUri.path.endsWith('/') ? startUri.path : '${startUri.path}/';
return startPath.startsWith(scopePath) || startUri.path == scopeUri.path;
} catch (e) {
return false;
}
}
@@ -0,0 +1,77 @@
/*
* 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/>.
*/
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
import 'package:weblibre/utils/ui_helper.dart';
Future<void> showPwaInstallDialog(BuildContext context, WidgetRef ref) async {
final manifest = ref.read(currentTabManifestProvider);
final name = manifest?.name ?? manifest?.shortName ?? 'this web app';
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add to Home Screen'),
content: Text('Add "$name" to your home screen?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Add'),
),
],
),
);
if (confirmed == true) {
try {
final success = await ref.read(installCurrentWebAppProvider.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);
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);
}
}
}
}
@@ -62,6 +62,12 @@ class ProfileRepository extends _$ProfileRepository {
return false;
}
// Clean up Mozilla cache directories before deleting the profile
final mozillaProfileIds = filesystem.getMozillaProfileIds(uuid);
for (final profileId in mozillaProfileIds) {
await filesystem.clearMozillaProfileCache(profileId);
}
await filesystem.getProfileDir(uuid).delete(recursive: true);
ref.invalidateSelf();
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
ProfileRepository create() => ProfileRepository();
}
String _$profileRepositoryHash() => r'249240ce0e775d4fd9ee3558e6b05326d09d79de';
String _$profileRepositoryHash() => r'e925dba74b0f15244fea8fff8391b5b67509be09';
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
FutureOr<List<Profile>> build();
@@ -51,6 +51,8 @@ class UserBackupService extends _$UserBackupService {
Stream<File> getBackupListStream() async* {
final backupDirectory = await getBackupDirectory();
if (!await backupDirectory.exists()) return;
await for (final entity in backupDirectory.list(recursive: true)) {
if (entity is File) {
yield entity;
@@ -144,7 +146,12 @@ class UserBackupService extends _$UserBackupService {
final existingProfile = await filesystem.readProfileMetadata(
outputDirectory,
);
if (existingProfile != null) {
if (existingProfile == null) {
throw Exception(
'Backup does not contain valid profile metadata',
);
}
if (existingProfile.uuidValue == filesystem.selectedProfile) {
throw Exception(
'Unable to override active User, please switch to another User and try again',
@@ -162,15 +169,27 @@ class UserBackupService extends _$UserBackupService {
await profileDir.delete(recursive: true);
await outputDirectory.rename(profileDir.path);
}
}
} else {
// Profile doesn't exist yet, just move the restored data into place
await outputDirectory.rename(profileDir.path);
}
});
ref.invalidate(profileRepositoryProvider);
return true;
} finally {
try {
if (await outputDirectory.exists()) {
await outputDirectory.delete(recursive: true);
}
} catch (e, s) {
logger.w(
'Failed to cleanup temporary backup directory: ${outputDirectory.path}',
error: e,
stackTrace: s,
);
}
}
}
@override
@@ -41,7 +41,7 @@ final class UserBackupServiceProvider
}
}
String _$userBackupServiceHash() => r'cbeffa04bb52d3c7d50a2eb38678750f2a0362cc';
String _$userBackupServiceHash() => r'a0bcf458e6b0a982f1fc90fce99e60cc3418f4d9';
abstract class _$UserBackupService extends $Notifier<void> {
void build();
+24
View File
@@ -75,6 +75,20 @@ Future<void> clearMozillaProfileCache(String profileId) async {
}
}
/// Returns the list of Mozilla profile IDs (`.default` directory names)
/// inside the given profile directory's `mozilla/` subdirectory.
List<String> getMozillaProfileIds(Directory profileDir) {
final mozillaDir = Directory(p.join(profileDir.path, 'mozilla'));
if (!mozillaDir.existsSync()) return [];
return mozillaDir
.listSync()
.whereType<Directory>()
.map((dir) => p.basename(dir.path))
.where((name) => name.endsWith('.default'))
.toList();
}
Future<List<Directory>> getProfilesWithDuplicateMozillaProfiles(
Directory profilesDir,
) async {
@@ -122,6 +136,16 @@ Future<UuidValue?> selectStartupProfile(Directory profilesDir) async {
var startupProfile = await readStartupProfile(profilesDir);
final availableProfiles = await getAvailableProfileDirectories(profilesDir);
// Verify the startup profile directory actually exists
if (startupProfile != null) {
final profileDir = getProfileDir(profilesDir, startupProfile);
final exists = availableProfiles.any((dir) => dir.path == profileDir.path);
if (!exists) {
logger.w('Startup profile directory missing, selecting fallback');
startupProfile = null;
}
}
if (startupProfile == null) {
final sortedDirs = await sortByAccessTime(availableProfiles);
@@ -127,6 +127,8 @@ dependencies {
implementation "org.mozilla.components:feature-webcompat:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-webnotifications:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-webauthn:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-pwa:$mozillaComponentsVersion"
implementation "org.mozilla.components:feature-intent:$mozillaComponentsVersion"
implementation "org.mozilla.components:ui-widgets:$mozillaComponentsVersion"
implementation "org.mozilla.components:lib-publicsuffixlist:$mozillaComponentsVersion"
@@ -134,6 +136,8 @@ dependencies {
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'com.google.android.material:material:1.13.0'
implementation 'com.mikepenz:iconics-core:5.4.0'
implementation 'com.mikepenz:community-material-typeface:7.0.96.1-kotlin@aar'
//https://stackoverflow.com/questions/73782320/onbackinvokedcallback-is-not-enabled-for-the-application-in-set-androidenableo
implementation 'androidx.activity:activity-ktx:1.12.3'
implementation 'androidx.paging:paging-runtime-ktx:3.4.0'
@@ -73,7 +73,7 @@ import mozilla.components.support.webextensions.WebExtensionPopupObserver
*/
@SuppressWarnings("LargeClass")
abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler {
private val sessionFeature = ViewBoundFeatureWrapper<SessionFeature>()
protected val sessionFeature = ViewBoundFeatureWrapper<SessionFeature>()
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
@@ -136,6 +136,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
protected abstract fun createEngine(components: Components): EngineView
// Track this fragment's EngineView instance to reassign singleton when fragment becomes active
private var fragmentEngineView: EngineView? = null
private lateinit var requestDownloadPermissionsLauncher: ActivityResultLauncher<Array<String>>
private lateinit var requestSitePermissionsLauncher: ActivityResultLauncher<Array<String>>
private lateinit var requestPromptsPermissionsLauncher: ActivityResultLauncher<Array<String>>
@@ -219,6 +222,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
ProfileContext(requireContext(), components.profileApplicationContext.relativePath)
val engineView = createEngine(components)
fragmentEngineView = engineView // Track for lifecycle management
val originalContext = ActivityContextWrapper.getOriginalContext(requireActivity())
?.let { ProfileContext(it, components.profileApplicationContext.relativePath) }
val engineNativeView = engineView.asView()
@@ -228,17 +232,14 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
binding.swipeToRefresh.addView(engineNativeView)
components.engineView = engineView
// Apply any pending viewport settings that were set before engineView was ready
GlobalComponents.viewportApi?.applyPendingSettings()
components.activeEngineView = engineView
sessionFeature.set(
feature = SessionFeature(
components.core.store,
components.useCases.sessionUseCases.goBack,
components.useCases.sessionUseCases.goForward,
components.engineView!!,
engineView,
sessionId,
),
owner = this,
@@ -468,7 +469,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
thumbnailsFeature.set(
feature = BrowserThumbnails(
profileContext,
components.engineView!!,
engineView,
components.core.store
),
owner = this,
@@ -496,12 +497,20 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
}
}
onEngineSetupComplete()
} catch (e: Exception) {
Log.e("EngineCreation", "Failed to create engine: ${e.message}", e)
context?.let { restartApp(it) }
}
}
/**
* Called after the engine view is fully set up and added to the view hierarchy.
* Subclasses can override to perform additional setup that requires an attached engine view.
*/
protected open fun onEngineSetupComplete() {}
private fun openPopup(webExtensionState: WebExtensionState) {
val store = components.core.store
val popupSession = store.state.extensions[webExtensionState.id]?.popupSession ?: return
@@ -587,6 +596,14 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
}
}
override fun onResume() {
super.onResume()
// Reassign active engine view to this fragment's EngineView when fragment becomes active
fragmentEngineView?.let {
components.activeEngineView = it
}
}
override fun onDestroyView() {
super.onDestroyView()
@@ -595,7 +612,12 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
keyboardVisibilityFeature = null
GlobalComponents.onPullToRefreshEnabledChanged = null
components.engineView?.setActivityContext(null)
val engineView = fragmentEngineView
engineView?.setActivityContext(null)
if (components.activeEngineView == engineView) {
components.activeEngineView = null
}
_binding = null
fragmentEngineView = null
}
}
@@ -21,6 +21,8 @@ class BrowserFragment() : BaseBrowserFragment(), UserInteractionHandler {
//We cannot introduce here our wrapped context since a activity type is required to make features work correctly like context menu
return components.core.engine.createView(requireContext()).apply {
selectionActionDelegate = components.selectionAction
}.also { engineView ->
components.mainBrowserEngineView = engineView
}
}
@@ -35,9 +37,18 @@ class BrowserFragment() : BaseBrowserFragment(), UserInteractionHandler {
super.onViewCreated(view, savedInstanceState)
}
override fun onEngineSetupComplete() {
GlobalComponents.viewportApi?.applyPendingToolbarHeight()
}
override fun onBackPressed(): Boolean =
super.readerViewFeature.onBackPressed() || super.onBackPressed()
override fun onDestroyView() {
super.onDestroyView()
components.mainBrowserEngineView = null
}
companion object {
fun create(sessionId: String? = null) = BrowserFragment().apply {
arguments = Bundle().apply {
@@ -44,12 +44,16 @@ class Components(val profileApplicationContext: ProfileContext,
) {
val core by lazy { Core(profileApplicationContext, this, flutterEvents, extensionEvents) }
val events by lazy { Events(flutterEvents) }
val useCases by lazy { UseCases(profileApplicationContext, core.engine, core.store) }
val useCases by lazy { UseCases(profileApplicationContext, core.engine, core.store, core.webAppShortcutManager) }
val services by lazy { Services(profileApplicationContext, core.store, useCases.tabsUseCases) }
val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) }
val search by lazy { Search(profileApplicationContext, core, useCases) }
var engineView: EngineView? = null
var mainBrowserEngineView: EngineView? = null
var externalAppEngineView: EngineView? = null
var activeEngineView: EngineView? = null
var engineReportedInitialized = false
private val notificationManagerCompat = NotificationManagerCompat.from(profileApplicationContext)
@@ -0,0 +1,371 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.PopupWindow
import androidx.annotation.CallSuper
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.color.MaterialColors
import com.google.android.material.materialswitch.MaterialSwitch
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import eu.weblibre.flutter_mozilla_components.widget.CustomTabToolbar
import eu.weblibre.flutter_mozilla_components.widget.CustomTabToolbarFeature
import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.browser.state.state.ExternalAppType
import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.customtabs.CustomTabWindowFeature
import mozilla.components.feature.pwa.feature.ManifestUpdateFeature
import mozilla.components.feature.pwa.feature.WebAppActivityFeature
import mozilla.components.feature.pwa.feature.WebAppContentFeature
import mozilla.components.feature.pwa.feature.WebAppHideToolbarFeature
import mozilla.components.feature.pwa.feature.WebAppSiteControlsFeature
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.arch.lifecycle.addObservers
/**
* Fragment used for browsing the web within external apps (Custom Tabs and PWAs).
* Extends [BaseBrowserFragment] with Custom Tab toolbar features and PWA support.
*/
class ExternalAppBrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
private val customTabsToolbarFeature = ViewBoundFeatureWrapper<CustomTabToolbarFeature>()
private val hideToolbarFeature = ViewBoundFeatureWrapper<WebAppHideToolbarFeature>()
private val windowFeature = ViewBoundFeatureWrapper<CustomTabWindowFeature>()
private var customTabToolbar: CustomTabToolbar? = null
private var activePopup: PopupWindow? = null
private val customTabSessionId: String?
get() = arguments?.getString(CUSTOM_TAB_SESSION_ID_KEY)
private val webAppManifestUrl: String?
get() = arguments?.getString(WEB_APP_MANIFEST_URL_KEY)
override fun createEngine(components: Components): EngineView {
return components.core.engine.createView(requireContext()).apply {
selectionActionDelegate = components.selectionAction
}.also { engineView ->
components.externalAppEngineView = engineView
}
}
@Suppress("LongMethod")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val sessionId = customTabSessionId ?: return
view.post {
if (GlobalComponents.components == null) return@post
initializeCustomTabFeatures(view, sessionId)
}
}
override fun onEngineSetupComplete() {
val sessionId = customTabSessionId ?: return
val store = components.core.store
val customTab = store.state.findCustomTab(sessionId) ?: return
val isPwaOrTwa = customTab.config.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP ||
customTab.config.externalAppType == ExternalAppType.TRUSTED_WEB_ACTIVITY
if (!isPwaOrTwa) {
setupCustomTabToolbar(sessionId)
}
}
private fun setupCustomTabToolbar(sessionId: String) {
val view = requireView()
val toolbar = CustomTabToolbar(requireContext()).apply {
layoutParams = AppBarLayout.LayoutParams(
AppBarLayout.LayoutParams.MATCH_PARENT,
AppBarLayout.LayoutParams.WRAP_CONTENT
)
}
customTabToolbar = toolbar
binding.customTabAppBar.apply {
removeAllViews()
addView(toolbar)
visibility = View.VISIBLE
}
toolbar.onCloseListener = {
requireActivity().finishAndRemoveTask()
}
toolbar.onShareListener = {
shareCurrentUrl(sessionId)
}
toolbar.onOpenInBrowserListener = {
openInBrowser(sessionId)
}
toolbar.onMenuListener = {
showCustomTabMenu(sessionId)
}
customTabsToolbarFeature.set(
feature = CustomTabToolbarFeature(
store = components.core.store,
toolbar = toolbar,
sessionId = sessionId,
window = requireActivity().window
),
owner = this,
view = view
)
}
private fun showCustomTabMenu(sessionId: String) {
val toolbar = customTabToolbar ?: return
val store = components.core.store
val customTab = store.state.findCustomTab(sessionId) ?: return
val anchorView = toolbar.getMenuButton()
val menuView = LayoutInflater.from(requireContext())
.inflate(R.layout.custom_tab_menu, null)
val popup = PopupWindow(
menuView,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
true
).apply {
elevation = 8f
isOutsideTouchable = true
}
activePopup = popup
val iconColor = MaterialColors.getColor(anchorView, com.google.android.material.R.attr.colorOnSurface)
val disabledColor = MaterialColors.getColor(anchorView, com.google.android.material.R.attr.colorOnSurfaceVariant)
// Navigation row icons
val canGoBack = customTab.content.canGoBack
val canGoForward = customTab.content.canGoForward
val backBtn = menuView.findViewById<ImageButton>(R.id.menuBack)
val forwardBtn = menuView.findViewById<ImageButton>(R.id.menuForward)
backBtn.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_arrow_left, 20, if (canGoBack) iconColor else disabledColor))
backBtn.isEnabled = canGoBack
backBtn.alpha = if (canGoBack) 1.0f else 0.38f
backBtn.setOnClickListener {
components.useCases.sessionUseCases.goBack(sessionId)
popup.dismiss()
}
forwardBtn.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_arrow_right, 20, if (canGoForward) iconColor else disabledColor))
forwardBtn.isEnabled = canGoForward
forwardBtn.alpha = if (canGoForward) 1.0f else 0.38f
forwardBtn.setOnClickListener {
components.useCases.sessionUseCases.goForward(sessionId)
popup.dismiss()
}
// Menu item icons
menuView.findViewById<ImageView>(R.id.menuRefreshIcon)
.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_refresh, 20, iconColor))
menuView.findViewById<ImageView>(R.id.menuShareIcon)
.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 20, iconColor))
menuView.findViewById<ImageView>(R.id.menuDesktopIcon)
.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_monitor, 20, iconColor))
menuView.findViewById<ImageView>(R.id.menuOpenInBrowserIcon)
.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 20, iconColor))
// Refresh
menuView.findViewById<View>(R.id.menuRefresh).setOnClickListener {
components.useCases.sessionUseCases.reload(sessionId)
popup.dismiss()
}
// Share
menuView.findViewById<View>(R.id.menuShare).setOnClickListener {
shareCurrentUrl(sessionId)
popup.dismiss()
}
// Desktop site toggle
val isDesktop = customTab.content.desktopMode
val desktopSwitch = menuView.findViewById<MaterialSwitch>(R.id.menuDesktopSwitch)
desktopSwitch.isChecked = isDesktop
val desktopRow = menuView.findViewById<View>(R.id.menuDesktopSite)
desktopRow.setOnClickListener {
val newState = !desktopSwitch.isChecked
components.useCases.sessionUseCases.requestDesktopSite(newState, sessionId)
popup.dismiss()
}
desktopSwitch.setOnCheckedChangeListener { _, isChecked ->
components.useCases.sessionUseCases.requestDesktopSite(isChecked, sessionId)
popup.dismiss()
}
// Open in browser
menuView.findViewById<View>(R.id.menuOpenInBrowser).setOnClickListener {
openInBrowser(sessionId)
popup.dismiss()
}
popup.showAsDropDown(anchorView, 0, 0, Gravity.END)
}
private fun mdiIcon(icon: IIcon, sizeDp: Int, color: Int): IconicsDrawable {
return IconicsDrawable(requireContext(), icon).apply {
this.sizeDp = sizeDp
this.colorInt = color
}
}
private fun openInBrowser(sessionId: String) {
val activity = requireActivity()
sessionFeature?.get()?.release()
components.useCases.customTabsUseCases.migrate(sessionId, select = true)
val mainIntent = activity.packageManager.getLaunchIntentForPackage(activity.packageName)
mainIntent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
mainIntent?.let { activity.startActivity(it) }
activity.finishAndRemoveTask()
}
private fun shareCurrentUrl(sessionId: String) {
val store = components.core.store
store.state.findCustomTab(sessionId)?.let { tab ->
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, tab.content.url)
putExtra(Intent.EXTRA_SUBJECT, tab.content.title)
}
startActivity(Intent.createChooser(shareIntent, null))
}
}
private fun initializeCustomTabFeatures(view: View, sessionId: String) {
val activity = requireActivity()
val store = components.core.store
val customTab = store.state.findCustomTab(sessionId) ?: return
components.activeEngineView?.setDynamicToolbarMaxHeight(0)
val manifest = webAppManifestUrl?.ifEmpty { null }?.let { url ->
components.core.webAppManifestStorage.getManifestCache(url)
}
windowFeature.set(
feature = CustomTabWindowFeature(activity, store, sessionId),
owner = this,
view = view,
)
val isPwaOrTwa = customTab.config.externalAppType == ExternalAppType.PROGRESSIVE_WEB_APP ||
customTab.config.externalAppType == ExternalAppType.TRUSTED_WEB_ACTIVITY
if (isPwaOrTwa) {
hideToolbarFeature.set(
feature = WebAppHideToolbarFeature(
store = store,
customTabsStore = components.core.customTabsStore,
tabId = sessionId,
manifest = manifest,
) { toolbarVisible ->
Logger.debug("Custom tab toolbar visibility: $toolbarVisible")
},
owner = this,
view = view,
)
}
if (manifest != null) {
activity.lifecycle.addObservers(
WebAppActivityFeature(
activity,
components.core.icons,
manifest,
),
WebAppContentFeature(
store = store,
tabId = sessionId,
manifest,
),
ManifestUpdateFeature(
activity.applicationContext,
store,
components.core.webAppShortcutManager,
components.core.webAppManifestStorage,
sessionId,
manifest,
),
)
viewLifecycleOwner.lifecycle.addObserver(
WebAppSiteControlsFeature(
activity.applicationContext,
store,
components.useCases.sessionUseCases.reload,
sessionId,
manifest,
notificationsDelegate = components.notificationsDelegate,
),
)
}
}
@Deprecated("Deprecated in Java")
@CallSuper
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super<BaseBrowserFragment>.onActivityResult(requestCode, data, resultCode)
}
override fun onBackPressed(): Boolean {
val sessionId = customTabSessionId ?: return super.onBackPressed()
val tab = components.core.store.state.findCustomTab(sessionId)
if (tab?.content?.canGoBack == true) {
components.useCases.sessionUseCases.goBack(sessionId)
return true
}
requireActivity().finishAndRemoveTask()
return true
}
override fun onDestroyView() {
super.onDestroyView()
activePopup?.dismiss()
activePopup = null
customTabToolbar = null
components.externalAppEngineView = null
}
companion object {
private const val CUSTOM_TAB_SESSION_ID_KEY = "custom_tab_session_id"
private const val WEB_APP_MANIFEST_URL_KEY = "web_app_manifest_url"
fun create(
customTabSessionId: String,
webAppManifestUrl: String? = null,
) = ExternalAppBrowserFragment().apply {
arguments = Bundle().apply {
putSessionId(customTabSessionId)
putString(CUSTOM_TAB_SESSION_ID_KEY, customTabSessionId)
putString(WEB_APP_MANIFEST_URL_KEY, webAppManifestUrl)
}
}
}
}
@@ -2,6 +2,7 @@ package eu.weblibre.flutter_mozilla_components
import android.content.Context
import android.content.ContextWrapper
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.os.Build
import androidx.annotation.RequiresApi
@@ -13,6 +14,8 @@ class ProfileContext(private val base: Context, val relativePath: String) :
private val subfolderRoot =
File(base.filesDir, relativePath) // /data/user/0/com.app/profiles/default
private val profilePrefix = File(relativePath).name
private var customFilesDir: File = File(subfolderRoot, "files")
private var customNoBackupFilesDir: File = File(subfolderRoot, "no_backup")
private var customObbDir: File = File(subfolderRoot, "obb")
@@ -124,4 +127,8 @@ class ProfileContext(private val base: Context, val relativePath: String) :
parentFile?.mkdirs()
}
}
override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
return base.getSharedPreferences("${profilePrefix}_$name", mode)
}
}
@@ -0,0 +1,21 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components
object PwaConstants {
// Intent extras keys for PWA metadata
const val EXTRA_PWA_PROFILE_UUID = "pwa_profile_uuid"
const val EXTRA_PWA_CONTEXT_ID = "pwa_context_id"
// Profile and file paths
const val CURRENT_PROFILE_FILE = "weblibre_profiles/current_profile"
const val PROFILE_MAPPING_PREFS = "pwa_profile_mapping"
// Component initialization timeouts
const val COMPONENT_INIT_TIMEOUT_MS = 10000L
const val COMPONENT_INIT_CHECK_INTERVAL_MS = 100L
}
@@ -0,0 +1,252 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import eu.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.ExternalAppBrowserFragment
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.ui.LoadingScreenManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.log.logger.Logger
/**
* Native activity that hosts [ExternalAppBrowserFragment] for Custom Tab and PWA sessions.
* This is a non-Flutter activity — it renders GeckoView directly in a native layout.
*
* Uses an empty taskAffinity so Custom Tabs appear as a separate task from the main app.
*/
class ExternalAppBrowserActivity : AppCompatActivity() {
private val logger = Logger("ExternalAppBrowserActivity")
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var loadingScreenManager: LoadingScreenManager? = null
private val customTabSessionId: String?
get() = intent?.getStringExtra(EXTRA_CUSTOM_TAB_SESSION_ID)
private val webAppManifestUrl: String?
get() = intent?.getStringExtra(EXTRA_WEB_APP_MANIFEST_URL)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sessionId = customTabSessionId
if (sessionId == null) {
logger.error("No custom tab session ID provided, finishing.")
finish()
return
}
WindowCompat.setDecorFitsSystemWindows(window, false)
setContentView(R.layout.activity_external_app_browser)
val components = GlobalComponents.components
if (components == null) {
logger.debug("Components not yet initialized, waiting...")
showLoading()
waitForComponents(sessionId)
return
}
showFragment(sessionId)
}
private fun showLoading() {
val container = findViewById<FrameLayout>(R.id.container)
loadingScreenManager = LoadingScreenManager.forActivity(this, container)
// Show branded placeholder immediately based on available data
// If we have a manifest URL, it's likely a PWA
val url = webAppManifestUrl ?: ""
if (url.isNotEmpty()) {
// Try to show PWA placeholder
loadingScreenManager?.showLoadingForIntent(
Intent().apply {
data = android.net.Uri.parse(url)
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, "placeholder")
}
)
} else {
// Show Custom Tab placeholder
loadingScreenManager?.showLoadingForIntent(Intent())
}
}
private fun waitForComponents(sessionId: String) {
coroutineScope.launch {
var elapsedMs = 0L
while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) {
val components = GlobalComponents.components
if (components != null) {
// Enhance the existing loading screen with actual data
enhanceLoadingScreen(components, sessionId)
// Brief delay to show the enhanced loading screen
delay(200)
showFragment(sessionId)
return@launch
}
delay(PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS)
elapsedMs += PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS
}
// Timeout reached
if (isActive) {
logger.error("Timeout waiting for components after ${PwaConstants.COMPONENT_INIT_TIMEOUT_MS}ms")
finish()
}
}
}
/**
* Enhances the existing loading screen with actual data once components are ready.
*/
private fun enhanceLoadingScreen(components: Components, sessionId: String) {
val session = components.core.store.state.findCustomTab(sessionId) ?: return
val url = session.content.url
val manifestUrl = webAppManifestUrl
loadingScreenManager?.let { manager ->
when (session.config.externalAppType) {
mozilla.components.browser.state.state.ExternalAppType.PROGRESSIVE_WEB_APP,
mozilla.components.browser.state.state.ExternalAppType.TRUSTED_WEB_ACTIVITY -> {
// Enhance PWA loading with manifest data
coroutineScope.launch(Dispatchers.IO) {
val manifest = manifestUrl?.let { manifestUrl ->
components.core.webAppManifestStorage.loadManifest(manifestUrl)
}
withContext(Dispatchers.Main) {
manifest?.let {
manager.enhancePwaLoading(it, components.core.icons, coroutineScope)
} ?: manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope)
}
}
}
else -> {
// Enhance Custom Tab loading with favicon
manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope)
}
}
}
}
private fun showFragment(sessionId: String) {
val components = GlobalComponents.components ?: run {
logger.error("Components still null after waiting, finishing.")
finish()
return
}
// Verify session exists
if (components.core.store.state.findCustomTab(sessionId) == null) {
logger.error("Custom tab session $sessionId not found in store, finishing.")
finish()
return
}
val fragment = ExternalAppBrowserFragment.create(
customTabSessionId = sessionId,
webAppManifestUrl = webAppManifestUrl,
)
supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment)
.runOnCommit { loadingScreenManager?.hideLoading() }
.commit()
}
override fun onResume() {
super.onResume()
// If the session was removed while we were in the background, finish
val sessionId = customTabSessionId ?: return
val components = GlobalComponents.components ?: return
if (components.core.store.state.findCustomTab(sessionId) == null) {
logger.debug("Custom tab session $sessionId gone, finishing activity.")
finish()
}
}
override fun onDestroy() {
super.onDestroy()
// Cancel any pending coroutines
coroutineScope.cancel()
// Clean up loading screen manager
loadingScreenManager?.cleanup()
loadingScreenManager = null
// Only clean up when the activity is actually finishing (user closed it),
// not when the system temporarily destroys it (e.g. switching to main app).
if (isFinishing) {
val sessionId = customTabSessionId
if (sessionId != null) {
val components = GlobalComponents.components
if (components != null) {
val customTab = components.core.store.state.findCustomTab(sessionId)
if (customTab != null) {
components.useCases.customTabsUseCases.remove(sessionId)
}
}
}
}
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
val fragment = supportFragmentManager.findFragmentById(R.id.container)
if (fragment is UserInteractionHandler && fragment.onBackPressed()) {
return
}
super.onBackPressed()
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
val fragment = supportFragmentManager.findFragmentById(R.id.container)
if (fragment is ExternalAppBrowserFragment) {
fragment.onPictureInPictureModeChanged(isInPictureInPictureMode)
}
}
companion object {
const val EXTRA_CUSTOM_TAB_SESSION_ID = "custom_tab_session_id"
const val EXTRA_WEB_APP_MANIFEST_URL = "web_app_manifest_url"
fun createIntent(
context: Context,
customTabSessionId: String,
webAppManifestUrl: String? = null,
): Intent {
return Intent(context, ExternalAppBrowserActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
putExtra(EXTRA_CUSTOM_TAB_SESSION_ID, customTabSessionId)
webAppManifestUrl?.let { putExtra(EXTRA_WEB_APP_MANIFEST_URL, it) }
}
}
}
}
@@ -0,0 +1,385 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.activities
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.widget.FrameLayout
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.ui.LoadingScreenManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.feature.customtabs.CustomTabIntentProcessor
import mozilla.components.feature.intent.ext.getSessionId
import mozilla.components.feature.pwa.intent.WebAppIntentProcessor
import mozilla.components.support.base.log.logger.Logger
import java.io.File
/**
* Lightweight transparent activity that receives all ACTION_VIEW intents and routes them
* to the appropriate activity:
* - Custom Tab intents → [ExternalAppBrowserActivity]
* - PWA launch intents → [ExternalAppBrowserActivity] (with profile/context tracking)
* - Regular VIEW intents → MainActivity (Flutter)
*
* For PWA intents created by our custom installer, checks profile match and shows dialog
* if the current profile differs from the installation profile.
*/
class IntentReceiverActivity : Activity() {
private val logger = Logger("IntentReceiverActivity")
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var pendingIntent: Intent? = null
private var loadingScreenManager: LoadingScreenManager? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = intent?.let { Intent(it) } ?: Intent()
logger.debug("onCreate: action=${intent.action} data=${intent.dataString}")
// Strip flags that could interfere with task management
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv()
intent.flags = intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK.inv()
processIntent(intent)
}
override fun onDestroy() {
super.onDestroy()
coroutineScope.cancel()
loadingScreenManager?.cleanup()
loadingScreenManager = null
}
private fun processIntent(intent: Intent) {
val components = GlobalComponents.components
if (components == null) {
logger.warn("Components not initialized, waiting for initialization...")
pendingIntent = intent
showLoadingIndicator(intent)
waitForComponentsWithTimeout()
return
}
routeIntent(intent)
}
private fun routeIntent(intent: Intent) {
// Check if this is our custom PWA intent with profile metadata
val profileUuid = intent.getStringExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID)
val contextId = intent.getStringExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID)
if (profileUuid != null) {
logger.debug("PWA intent with profile metadata: profileUuid=$profileUuid, contextId=$contextId")
handlePwaIntent(intent, profileUuid, contextId)
return
}
// Fall back to standard intent processors for Custom Tabs and legacy PWAs
val components = GlobalComponents.components
?: run {
logger.error("Components became null during routing")
handleRegularIntent(intent)
return
}
val processors = listOf(
"CustomTab" to CustomTabIntentProcessor(
components.useCases.customTabsUseCases.add,
resources,
isPrivate = false,
),
"PWA" to WebAppIntentProcessor(
components.core.store,
components.useCases.customTabsUseCases.addWebApp,
components.useCases.sessionUseCases.loadUrl,
components.core.webAppManifestStorage,
),
)
for ((name, processor) in processors) {
logger.debug("Trying $name processor...")
try {
val result = processor.process(intent)
logger.debug("$name processor result: $result")
if (result) {
val sessionId = intent.getSessionId()
logger.debug("$name session ID from intent: $sessionId")
if (sessionId != null) {
val externalIntent = ExternalAppBrowserActivity.createIntent(
context = this,
customTabSessionId = sessionId,
webAppManifestUrl = if (name == "PWA") intent.dataString else null,
)
startActivity(externalIntent)
finish()
return
} else {
logger.warn("$name processor succeeded but no session ID in intent!")
}
}
} catch (e: Exception) {
logger.error("Error in $name processor", e)
}
}
logger.debug("No processor matched, routing to MainActivity")
handleRegularIntent(intent)
}
/**
* Handles PWA intents with profile and context metadata.
* Checks if current profile matches and shows dialog if different.
*/
private fun handlePwaIntent(
intent: Intent,
profileUuid: String,
contextId: String?,
) {
val url = intent.dataString
if (url == null) {
logger.error("PWA intent has no URL")
handleRegularIntent(intent)
return
}
val currentProfileUuid = getCurrentProfileUuid()
if (currentProfileUuid != null && currentProfileUuid != profileUuid) {
logger.debug("Profile mismatch: current=$currentProfileUuid, expected=$profileUuid")
showProfileMismatchDialog(url, contextId)
} else {
logger.debug("Profile match or indeterminate, launching PWA with contextId=$contextId")
launchPwaWithContext(url, contextId)
}
}
/**
* Reads the current profile UUID from the filesystem.
* The Flutter side persists this as a plain text file at:
* <filesDir>/weblibre_profiles/current_profile
*/
private fun getCurrentProfileUuid(): String? {
return try {
val startupProfileFile = File(filesDir, PwaConstants.CURRENT_PROFILE_FILE)
if (startupProfileFile.exists()) {
startupProfileFile.readText().trim().ifEmpty { null }
} else {
null
}
} catch (e: Exception) {
logger.error("Failed to read current profile UUID", e)
null
}
}
/**
* Shows a dialog when the current profile doesn't match the PWA's installation profile.
*/
private fun showProfileMismatchDialog(
url: String,
contextId: String?,
) {
val message = "This PWA was originally installed in a different profile. " +
"Opening it here will use your current profile's data and settings, " +
"which means you won't see the same content, preferences, or saved data " +
"that you had in the original profile.\n\n" +
"Do you want to proceed anyway?"
AlertDialog.Builder(this)
.setTitle("PWA Profile Mismatch")
.setMessage(message)
.setPositiveButton("Open Anyway") { _, _ ->
logger.debug("User chose to open PWA despite profile mismatch")
launchPwaWithContext(url, contextId)
}
.setNegativeButton("Cancel") { _, _ ->
logger.debug("User cancelled PWA launch due to profile mismatch")
finish()
}
.setOnCancelListener {
finish()
}
.show()
}
/**
* Launches the PWA with the specified context ID for storage isolation.
*/
private fun launchPwaWithContext(url: String, contextId: String?) {
val components = GlobalComponents.components
?: run {
logger.error("Components not available for PWA launch")
handleRegularIntent(intent)
return
}
coroutineScope.launch {
try {
val manifest = withContext(Dispatchers.IO) {
components.core.webAppManifestStorage.loadManifest(url)
}
val sessionId = createPwaSession(
url = url,
contextId = contextId,
manifest = manifest
)
logger.debug("Created PWA session: contextId=$contextId, sessionId=$sessionId")
val externalIntent = ExternalAppBrowserActivity.createIntent(
context = this@IntentReceiverActivity,
customTabSessionId = sessionId,
webAppManifestUrl = url,
)
startActivity(externalIntent)
finish()
} catch (e: Exception) {
logger.error("Failed to launch PWA with context", e)
handleRegularIntent(intent)
}
}
}
/**
* Creates a custom tab session for a PWA with the specified context ID.
*/
private fun createPwaSession(
url: String,
contextId: String?,
manifest: mozilla.components.concept.engine.manifest.WebAppManifest?
): String {
val components = GlobalComponents.components
?: throw IllegalStateException("Components not initialized")
val customTabConfig = mozilla.components.browser.state.state.CustomTabConfig(
externalAppType = mozilla.components.browser.state.state.ExternalAppType.PROGRESSIVE_WEB_APP
)
val tab = mozilla.components.browser.state.state.createCustomTab(
url = url,
contextId = contextId,
config = customTabConfig,
webAppManifest = manifest,
source = mozilla.components.browser.state.state.SessionState.Source.Internal.CustomTab,
private = false
)
components.core.store.dispatch(
mozilla.components.browser.state.action.CustomTabListAction.AddCustomTabAction(tab)
)
val loadUrlFlags = mozilla.components.concept.engine.EngineSession.LoadUrlFlags.external()
components.useCases.sessionUseCases.loadUrl(url, tab.id, loadUrlFlags)
return tab.id
}
/**
* Shows a branded loading screen immediately based on intent type.
* This avoids showing a minimal spinner and shows proper placeholders right away.
*/
private fun showLoadingIndicator(intent: Intent) {
// Create a container layout
val container = FrameLayout(this).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
setBackgroundColor(android.graphics.Color.TRANSPARENT)
}
setContentView(container)
// Initialize loading screen manager and show branded screen immediately
loadingScreenManager = LoadingScreenManager.forActivity(this, container)
loadingScreenManager?.showLoadingForIntent(intent)
}
/**
* Enhances the existing loading screen with actual data once components are initialized.
* This updates the placeholder with real manifest/icon data.
*/
private fun enhanceLoadingScreen(intent: Intent) {
val components = GlobalComponents.components ?: return
val url = intent.dataString ?: return
loadingScreenManager?.let { manager ->
when {
// PWA intent - enhance with manifest data
LoadingScreenManager.isPwaIntent(intent) -> {
coroutineScope.launch {
val manifest = components.core.webAppManifestStorage.loadManifest(url)
manifest?.let {
manager.enhancePwaLoading(it, components.core.icons, coroutineScope)
}
}
}
// Custom Tab - enhance with favicon
else -> {
manager.enhanceCustomTabLoading(url, components.core.icons, coroutineScope)
}
}
}
}
/**
* Waits for GlobalComponents to be initialized with a timeout.
* Once components are ready, shows branded loading screen before routing.
* Falls back to MainActivity if timeout is reached (10 seconds).
*/
private fun waitForComponentsWithTimeout() {
coroutineScope.launch {
var elapsedMs = 0L
while (isActive && elapsedMs < PwaConstants.COMPONENT_INIT_TIMEOUT_MS) {
if (GlobalComponents.components != null) {
logger.debug("Components initialized after ${elapsedMs}ms")
pendingIntent?.let { intent ->
// Enhance the existing loading screen with actual data
enhanceLoadingScreen(intent)
// Small delay to show the enhanced loading screen (200ms)
delay(200)
routeIntent(intent)
}
pendingIntent = null
return@launch
}
delay(PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS)
elapsedMs += PwaConstants.COMPONENT_INIT_CHECK_INTERVAL_MS
}
if (isActive) {
logger.warn("Timeout waiting for components after ${PwaConstants.COMPONENT_INIT_TIMEOUT_MS}ms, falling back to MainActivity")
pendingIntent?.let { intent ->
handleRegularIntent(intent)
}
pendingIntent = null
}
}
}
private fun handleRegularIntent(intent: Intent) {
val mainActivityIntent = Intent(intent).apply {
setClassName(this@IntentReceiverActivity, "eu.weblibre.gecko.MainActivity")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(mainActivityIntent)
finish()
}
}
@@ -40,6 +40,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTrackingProtectionApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoLogging
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionController
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
@@ -275,6 +276,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GeckoTrackingProtectionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTrackingProtectionApiImpl())
GeckoAppLinksApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAppLinksApiImpl(profileApplicationContext))
// PWA API for web app installation and management
GeckoPwaApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPwaApiImpl(profileApplicationContext))
// Viewport API for dynamic toolbar and keyboard handling
val viewportEvents = GeckoViewportEvents(_flutterPluginBinding.binaryMessenger)
val viewportApi = GeckoViewportApiImpl()
@@ -0,0 +1,324 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.api
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import androidx.core.content.getSystemService
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.activities.IntentReceiverActivity
import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPwaApi
import eu.weblibre.flutter_mozilla_components.pigeons.PwaIcon
import eu.weblibre.flutter_mozilla_components.pigeons.PwaManifest
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTarget
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetFiles
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.icons.IconRequest
import mozilla.components.browser.state.selector.findTab
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.support.base.log.logger.Logger
import java.io.File
import java.security.MessageDigest
/**
* Implementation of GeckoPwaApi that provides PWA install and query functionality.
*
* Creates custom shortcuts with profile and container metadata embedded in intent extras,
* ensuring PWAs reopen with the same profile and container context.
*/
class GeckoPwaApiImpl(
private val context: Context
) : GeckoPwaApi {
companion object {
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}
private val logger = Logger("GeckoPwaApiImpl")
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
override fun installWebApp(
tabId: String?,
profileUuid: String,
contextId: String?,
callback: (Result<Boolean>) -> Unit
) {
logger.debug("installWebApp called for tabId: $tabId, profileUuid: $profileUuid, contextId: $contextId")
coroutineScope.launch {
try {
val store = components.core.store
val tab = if (tabId != null) {
store.state.findTab(tabId)
} else {
store.state.selectedTab
}
if (tab == null) {
logger.warn("Tab not found for installWebApp: $tabId")
callback(Result.success(false))
return@launch
}
val manifest = tab.content.webAppManifest
if (manifest == null) {
logger.warn("No manifest found for tab ${tab.id}")
callback(Result.success(false))
return@launch
}
logger.debug("Installing web app for tab ${tab.id}: ${manifest.startUrl}")
val success = createPwaShortcut(
manifest = manifest,
profileUuid = profileUuid,
contextId = contextId,
)
if (success) {
components.core.webAppManifestStorage.saveManifest(manifest)
storeProfileMapping(manifest.startUrl, profileUuid)
logger.debug("Web app installation completed for tab ${tab.id}")
} else {
logger.warn("Failed to create PWA shortcut for tab ${tab.id}")
}
callback(Result.success(success))
} catch (e: Exception) {
logger.error("Failed to install web app", e)
callback(Result.failure(e))
}
}
}
/**
* Creates a PWA shortcut with profile and container metadata in intent extras.
*/
private suspend fun createPwaShortcut(
manifest: WebAppManifest,
profileUuid: String,
contextId: String?,
): Boolean = withContext(Dispatchers.Main) {
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
logger.warn("Pinned shortcuts require Android O or later")
return@withContext false
}
val shortcutManager = context.getSystemService<ShortcutManager>()
?: run {
logger.error("ShortcutManager not available")
return@withContext false
}
if (!shortcutManager.isRequestPinShortcutSupported) {
logger.warn("Pinning shortcuts is not supported")
return@withContext false
}
val shortcutIntent = Intent(context, IntentReceiverActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = Uri.parse(manifest.startUrl)
putExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID, profileUuid)
putExtra(PwaConstants.EXTRA_PWA_CONTEXT_ID, contextId)
}
val (iconBitmap, isMaskable) = loadPwaIcon(manifest)
val shortcutId = generateShortcutId(manifest.startUrl)
val shortcut = ShortcutInfo.Builder(context, shortcutId).apply {
setShortLabel(manifest.shortName ?: manifest.name ?: "Web App")
setLongLabel(manifest.name ?: manifest.shortName ?: "Web App")
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))
}
}
}.build()
val success = shortcutManager.requestPinShortcut(shortcut, null)
logger.debug("PWA shortcut creation result: $success")
success
} catch (e: Exception) {
logger.error("Failed to create PWA shortcut", e)
false
}
}
/**
* Generates a collision-resistant shortcut ID from a URL using SHA-256.
*/
private fun generateShortcutId(url: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(url.toByteArray())
val hex = hash.take(16).joinToString("") { "%02x".format(it) }
return "pwa_$hex"
}
/**
* Loads the PWA icon from the manifest using BrowserIcons.
* Returns a pair of (bitmap, isMaskable) to determine proper icon format.
*/
private suspend fun loadPwaIcon(manifest: WebAppManifest): Pair<Bitmap?, Boolean> = 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()
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 iconResult = components.core.icons.loadIcon(iconRequest).await()
Pair(iconResult?.bitmap, isMaskable)
} else {
Pair(null, false)
}
} catch (e: Exception) {
logger.error("Failed to load PWA icon", e)
Pair(null, false)
}
}
override fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit) {
logger.debug("getInstalledWebApps called")
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()
}
logger.debug("Found ${pwaManifests.size} installed web apps")
callback(Result.success(pwaManifests))
} catch (e: Exception) {
logger.error("Failed to get installed web apps", e)
callback(Result.failure(e))
}
}
}
private fun storeProfileMapping(startUrl: String, profileUuid: String) {
context.getSharedPreferences(PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(startUrl, profileUuid)
.apply()
}
private fun getProfileMapping(startUrl: String): String? {
return context.getSharedPreferences(PwaConstants.PROFILE_MAPPING_PREFS, Context.MODE_PRIVATE)
.getString(startUrl, null)
}
private fun getCurrentProfileUuid(): String? {
return try {
val startupProfileFile = File(context.filesDir, PwaConstants.CURRENT_PROFILE_FILE)
if (startupProfileFile.exists()) {
startupProfileFile.readText().trim().ifEmpty { null }
} else {
null
}
} catch (e: Exception) {
logger.error("Failed to read current profile UUID", e)
null
}
}
private fun WebAppManifest.toPwaManifest(currentUrl: String = startUrl): PwaManifest {
return PwaManifest(
startUrl = startUrl,
currentUrl = currentUrl,
name = name,
shortName = shortName,
display = display?.name?.lowercase(),
themeColor = themeColor?.let { String.format("#%06X", 0xFFFFFF and it) },
backgroundColor = backgroundColor?.let { String.format("#%06X", 0xFFFFFF and it) },
scope = scope,
description = description,
icons = icons.map { icon ->
PwaIcon(
src = icon.src,
sizes = icon.sizes?.joinToString(" ") { "${it.width}x${it.height}" },
type = icon.type,
)
},
dir = dir?.name?.lowercase(),
lang = lang,
orientation = orientation?.name?.lowercase(),
relatedApplications = relatedApplications.map { app ->
ExternalApplicationResource(
platform = app.platform,
url = app.url,
id = app.id,
minVersion = app.minVersion,
)
},
preferRelatedApplications = preferRelatedApplications,
shareTarget = shareTarget?.let { target ->
ShareTarget(
action = target.action,
method = target.method?.name,
encType = target.encType?.type,
params = target.params?.let { params ->
ShareTargetParams(
title = params.title,
text = params.text,
url = params.url,
files = params.files.map { file ->
ShareTargetFiles(
name = file.name,
accept = file.accept,
)
},
)
},
)
},
)
}
}
@@ -254,7 +254,7 @@ class GeckoSessionApiImpl : GeckoSessionApi {
return
}
components.engineView?.captureThumbnail { bitmap ->
components.mainBrowserEngineView?.captureThumbnail { bitmap ->
try {
if (bitmap != null) {
components.core.store.dispatch(ContentAction.UpdateThumbnailAction(tab.id, bitmap))
@@ -14,8 +14,12 @@ import mozilla.components.support.base.log.logger.Logger
* Implementation of GeckoViewportApi that controls GeckoView's viewport behavior
* for dynamic toolbar and keyboard handling.
*
* This allows Flutter to control how GeckoView adjusts its internal viewport
* without resizing the platform view itself, avoiding visual flickering.
* Toolbar height and vertical clipping target the main browser's EngineView specifically,
* not the active/foreground EngineView. This prevents toolbar settings from leaking
* to PWA/Custom Tab EngineViews.
*
* If the main browser EngineView is not yet available when setDynamicToolbarMaxHeight
* is called, the value is stored and applied when the EngineView becomes available.
*/
class GeckoViewportApiImpl : GeckoViewportApi {
companion object {
@@ -28,66 +32,57 @@ class GeckoViewportApiImpl : GeckoViewportApi {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
// Store the current dynamic toolbar max height
private var currentDynamicToolbarMaxHeight: Int = 0
private var pendingToolbarHeight: Int? = null
/**
* Sets the maximum height that dynamic toolbars (top + bottom) can occupy.
*
* GeckoView will adjust its internal viewport calculations to account for
* this space. The website will receive proper viewport dimensions through
* standard web APIs (CSS viewport units, window.innerHeight).
* Targets the main browser EngineView specifically. If the main browser
* EngineView is not yet available, the height is stored and applied when
* it becomes available via [applyPendingToolbarHeight].
*/
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
val height = heightPx.toInt()
currentDynamicToolbarMaxHeight = height
val engineView = components.engineView
val engineView = components.mainBrowserEngineView
if (engineView == null) {
logger.warn("$TAG: setDynamicToolbarMaxHeight called but engineView is null")
logger.debug("$TAG: setDynamicToolbarMaxHeight($height) - mainBrowserEngineView not ready, storing as pending")
pendingToolbarHeight = height
return
}
pendingToolbarHeight = null
logger.debug("$TAG: setDynamicToolbarMaxHeight($height)")
engineView.setDynamicToolbarMaxHeight(height)
}
/**
* Applies any pending toolbar height to the main browser EngineView.
* Called when mainBrowserEngineView becomes available.
*/
fun applyPendingToolbarHeight() {
val pending = pendingToolbarHeight ?: return
val engineView = components.mainBrowserEngineView ?: return
pendingToolbarHeight = null
logger.debug("$TAG: Applying pending toolbar height: $pending")
engineView.setDynamicToolbarMaxHeight(pending)
}
/**
* Sets the vertical clipping offset for the GeckoView content.
*
* Use this as the toolbar animates to clip content at the bottom.
* Negative values clip from the bottom (for bottom toolbar sliding up).
* Positive values clip from the top (for top toolbar sliding down).
* Targets the main browser EngineView specifically.
*/
override fun setVerticalClipping(clippingPx: Long) {
val clipping = clippingPx.toInt()
val engineView = components.engineView
val engineView = components.mainBrowserEngineView
if (engineView == null) {
logger.warn("$TAG: setVerticalClipping called but engineView is null")
logger.warn("$TAG: setVerticalClipping called but mainBrowserEngineView is null")
return
}
logger.debug("$TAG: setVerticalClipping($clipping)")
engineView.setVerticalClipping(clipping)
}
/**
* Applies any pending viewport settings that were set before engineView was available.
*
* Call this method after setting components.engineView to ensure that any
* setDynamicToolbarMaxHeight calls made during startup are properly applied.
*/
fun applyPendingSettings() {
val engineView = components.engineView
if (engineView == null) {
logger.warn("$TAG: applyPendingSettings called but engineView is still null")
return
}
if (currentDynamicToolbarMaxHeight > 0) {
logger.debug("$TAG: Applying pending dynamicToolbarMaxHeight: $currentDynamicToolbarMaxHeight")
engineView.setDynamicToolbarMaxHeight(currentDynamicToolbarMaxHeight)
}
}
}
@@ -47,6 +47,8 @@ import mozilla.components.feature.addons.amo.AMOAddonsProvider
import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
import mozilla.components.feature.addons.update.DefaultAddonUpdater
import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
import mozilla.components.feature.pwa.ManifestStorage
import mozilla.components.feature.pwa.WebAppShortcutManager
import mozilla.components.feature.downloads.DownloadMiddleware
import mozilla.components.feature.media.MediaSessionFeature
import mozilla.components.feature.media.middleware.LastMediaAccessMiddleware
@@ -230,6 +232,14 @@ class Core(
*/
val customTabsStore by lazy { CustomTabsServiceStore() }
// Must use the base application context (not ProfileContext) so the database
// matches what WebAppLauncherActivity (from the library) uses when loading manifests.
val webAppManifestStorage by lazy { ManifestStorage(context.applicationContext) }
val webAppShortcutManager by lazy {
WebAppShortcutManager(context, client, webAppManifestStorage)
}
/**
* The storage component for persisting browser tab sessions.
*/
@@ -10,13 +10,18 @@ import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource
import eu.weblibre.flutter_mozilla_components.pigeons.FindResultState
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.PwaIcon
import eu.weblibre.flutter_mozilla_components.pigeons.PwaManifest
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryItem
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryState
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderableState
import eu.weblibre.flutter_mozilla_components.pigeons.SecurityInfoState
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTarget
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetFiles
import eu.weblibre.flutter_mozilla_components.pigeons.ShareTargetParams
import eu.weblibre.flutter_mozilla_components.pigeons.TabContentState
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
@@ -25,10 +30,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.feature.addons.logger
import mozilla.components.lib.state.Store
import mozilla.components.lib.state.ext.flowScoped
import kotlinx.coroutines.flow.distinctUntilChangedBy
import mozilla.components.support.ktx.kotlinx.coroutines.flow.filterChanged
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
@@ -53,12 +60,27 @@ class Events(
}
stateFlow.flowScoped { flow ->
var previousTabs = emptySet<String>()
flow.mapNotNull { state -> state.tabs.map { tab -> tab.id } }
.distinctUntilChanged()
// Make sure this is sent after tabadded action
.debounce { 25 }
.collect { tabs ->
flutterEvents.onTabListChange(System.currentTimeMillis(), tabs) { _ -> }
val currentTabs = tabs.toSet()
if (previousTabs.isNotEmpty()) {
val removedTabs = previousTabs - currentTabs
if (removedTabs.isNotEmpty()) {
removedTabs.forEach { tabId ->
flutterEvents.onManifestUpdate(
EventSequence.next(),
tabId,
null
) { _ -> }
}
}
}
previousTabs = currentTabs
flutterEvents.onTabListChange(EventSequence.next(), tabs) { _ -> }
}
}
@@ -187,5 +209,89 @@ class Events(
) { _ -> }
}
}
// PWA manifest availability events - following Fenix MenuPresenter pattern
stateFlow.flowScoped { flow ->
flow.mapNotNull { state -> state.selectedTab }
.ifAnyChanged { tab ->
arrayOf(
tab.content.loading,
tab.content.canGoBack,
tab.content.canGoForward,
tab.content.webAppManifest,
)
}
.collect { tab ->
val manifest = tab.content.webAppManifest
val currentUrl = tab.content.url
// If manifest is null, clear PWA state for this tab
if (manifest == null) {
flutterEvents.onManifestUpdate(
EventSequence.next(),
tab.id,
null
) { _ -> }
return@collect
}
val pwaManifest = PwaManifest(
startUrl = manifest.startUrl,
currentUrl = currentUrl,
name = manifest.name,
shortName = manifest.shortName,
display = manifest.display?.name?.lowercase(),
themeColor = manifest.themeColor?.let { String.format("#%06X", 0xFFFFFF and it) },
backgroundColor = manifest.backgroundColor?.let { String.format("#%06X", 0xFFFFFF and it) },
scope = manifest.scope,
description = manifest.description,
icons = manifest.icons.map { icon ->
PwaIcon(
src = icon.src,
sizes = icon.sizes?.joinToString(" ") { "${it.width}x${it.height}" },
type = icon.type,
)
},
dir = manifest.dir?.name?.lowercase(),
lang = manifest.lang,
orientation = manifest.orientation?.name?.lowercase(),
relatedApplications = manifest.relatedApplications.map { app ->
ExternalApplicationResource(
platform = app.platform,
url = app.url,
id = app.id,
minVersion = app.minVersion,
)
},
preferRelatedApplications = manifest.preferRelatedApplications,
shareTarget = manifest.shareTarget?.let { target ->
ShareTarget(
action = target.action,
method = target.method?.name,
encType = target.encType?.type,
params = target.params?.let { params ->
ShareTargetParams(
title = params.title,
text = params.text,
url = params.url,
files = params.files.map { file ->
ShareTargetFiles(
name = file.name,
accept = file.accept,
)
},
)
},
)
},
)
flutterEvents.onManifestUpdate(
EventSequence.next(),
tab.id,
pwaManifest
) { _ -> }
}
}
}
}
@@ -12,6 +12,8 @@ import mozilla.components.feature.downloads.DownloadsUseCases
import mozilla.components.feature.session.SessionUseCases
import mozilla.components.feature.session.SettingsUseCases
import mozilla.components.feature.session.TrackingProtectionUseCases
import mozilla.components.feature.pwa.WebAppShortcutManager
import mozilla.components.feature.pwa.WebAppUseCases
import mozilla.components.feature.tabs.CustomTabsUseCases
import mozilla.components.feature.tabs.TabsUseCases
@@ -23,6 +25,7 @@ class UseCases(
private val context: Context,
private val engine: Engine,
private val store: BrowserStore,
private val shortcutManager: WebAppShortcutManager? = null,
) {
/**
* Use cases that provide engine interactions for a given browser session.
@@ -52,4 +55,8 @@ class UseCases(
val appLinksUseCases by lazy { AppLinksUseCases(context) }
val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) }
val webAppUseCases by lazy {
WebAppUseCases(context, store, shortcutManager ?: throw IllegalStateException("WebAppShortcutManager not provided"))
}
}
@@ -2972,6 +2972,286 @@ data class TrackingProtectionException (
override fun hashCode(): Int = toList().hashCode()
}
/**
* Represents an icon from a PWA manifest.
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class PwaIcon (
val src: String,
val sizes: String? = null,
val type: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): PwaIcon {
val src = pigeonVar_list[0] as String
val sizes = pigeonVar_list[1] as String?
val type = pigeonVar_list[2] as String?
return PwaIcon(src, sizes, type)
}
}
fun toList(): List<Any?> {
return listOf(
src,
sizes,
type,
)
}
override fun equals(other: Any?): Boolean {
if (other !is PwaIcon) {
return false
}
if (this === other) {
return true
}
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Represents a file entry in share target params.
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class ShareTargetFiles (
val name: String,
val accept: List<String?>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ShareTargetFiles {
val name = pigeonVar_list[0] as String
val accept = pigeonVar_list[1] as List<String?>
return ShareTargetFiles(name, accept)
}
}
fun toList(): List<Any?> {
return listOf(
name,
accept,
)
}
override fun equals(other: Any?): Boolean {
if (other !is ShareTargetFiles) {
return false
}
if (this === other) {
return true
}
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Represents share target params.
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class ShareTargetParams (
val title: String? = null,
val text: String? = null,
val url: String? = null,
val files: List<ShareTargetFiles?>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ShareTargetParams {
val title = pigeonVar_list[0] as String?
val text = pigeonVar_list[1] as String?
val url = pigeonVar_list[2] as String?
val files = pigeonVar_list[3] as List<ShareTargetFiles?>
return ShareTargetParams(title, text, url, files)
}
}
fun toList(): List<Any?> {
return listOf(
title,
text,
url,
files,
)
}
override fun equals(other: Any?): Boolean {
if (other !is ShareTargetParams) {
return false
}
if (this === other) {
return true
}
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Represents a share target for PWA.
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class ShareTarget (
val action: String,
val method: String? = null,
val encType: String? = null,
val params: ShareTargetParams? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ShareTarget {
val action = pigeonVar_list[0] as String
val method = pigeonVar_list[1] as String?
val encType = pigeonVar_list[2] as String?
val params = pigeonVar_list[3] as ShareTargetParams?
return ShareTarget(action, method, encType, params)
}
}
fun toList(): List<Any?> {
return listOf(
action,
method,
encType,
params,
)
}
override fun equals(other: Any?): Boolean {
if (other !is ShareTarget) {
return false
}
if (this === other) {
return true
}
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Represents an external application resource.
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class ExternalApplicationResource (
val platform: String,
val url: String? = null,
val id: String? = null,
val minVersion: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ExternalApplicationResource {
val platform = pigeonVar_list[0] as String
val url = pigeonVar_list[1] as String?
val id = pigeonVar_list[2] as String?
val minVersion = pigeonVar_list[3] as String?
return ExternalApplicationResource(platform, url, id, minVersion)
}
}
fun toList(): List<Any?> {
return listOf(
platform,
url,
id,
minVersion,
)
}
override fun equals(other: Any?): Boolean {
if (other !is ExternalApplicationResource) {
return false
}
if (this === other) {
return true
}
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
/**
* Represents a PWA web app manifest.
*
* Mirrors Mozilla Android Components' WebAppManifest structure.
* https://firefox-source-docs.mozilla.org/mobile/android/geckoview/api/mozilla.components.concept.engine.manifest.WebAppManifest.html
*
* Generated class from Pigeon that represents data sent in messages.
*/
data class PwaManifest (
val startUrl: String,
val name: String? = null,
val shortName: String? = null,
val display: String? = null,
val themeColor: String? = null,
val backgroundColor: String? = null,
val scope: String? = null,
val description: String? = null,
val icons: List<PwaIcon?>,
val dir: String? = null,
val lang: String? = null,
val orientation: String? = null,
val relatedApplications: List<ExternalApplicationResource?>,
val preferRelatedApplications: Boolean,
val shareTarget: ShareTarget? = null,
/**
* The URL of the page when the manifest was detected.
* Used for HTTPS/installability checks.
*/
val currentUrl: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): PwaManifest {
val startUrl = pigeonVar_list[0] as String
val name = pigeonVar_list[1] as String?
val shortName = pigeonVar_list[2] as String?
val display = pigeonVar_list[3] as String?
val themeColor = pigeonVar_list[4] as String?
val backgroundColor = pigeonVar_list[5] as String?
val scope = pigeonVar_list[6] as String?
val description = pigeonVar_list[7] as String?
val icons = pigeonVar_list[8] as List<PwaIcon?>
val dir = pigeonVar_list[9] as String?
val lang = pigeonVar_list[10] as String?
val orientation = pigeonVar_list[11] as String?
val relatedApplications = pigeonVar_list[12] as List<ExternalApplicationResource?>
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)
}
}
fun toList(): List<Any?> {
return listOf(
startUrl,
name,
shortName,
display,
themeColor,
backgroundColor,
scope,
description,
icons,
dir,
lang,
orientation,
relatedApplications,
preferRelatedApplications,
shareTarget,
currentUrl,
)
}
override fun equals(other: Any?): Boolean {
if (other !is PwaManifest) {
return false
}
if (this === other) {
return true
}
return GeckoPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
private open class GeckoPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -3400,6 +3680,36 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
TrackingProtectionException.fromList(it)
}
}
214.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PwaIcon.fromList(it)
}
}
215.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ShareTargetFiles.fromList(it)
}
}
216.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ShareTargetParams.fromList(it)
}
}
217.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ShareTarget.fromList(it)
}
}
218.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ExternalApplicationResource.fromList(it)
}
}
219.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PwaManifest.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@@ -3745,6 +4055,30 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
stream.write(213)
writeValue(stream, value.toList())
}
is PwaIcon -> {
stream.write(214)
writeValue(stream, value.toList())
}
is ShareTargetFiles -> {
stream.write(215)
writeValue(stream, value.toList())
}
is ShareTargetParams -> {
stream.write(216)
writeValue(stream, value.toList())
}
is ShareTarget -> {
stream.write(217)
writeValue(stream, value.toList())
}
is ExternalApplicationResource -> {
stream.write(218)
writeValue(stream, value.toList())
}
is PwaManifest -> {
stream.write(219)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@@ -5320,12 +5654,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
GeckoPigeonCodec()
}
}
fun onViewReadyStateChange(timestampArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
fun onViewReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onViewReadyStateChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, stateArg)) {
channel.send(listOf(sequenceArg, stateArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5337,12 +5671,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onEngineReadyStateChange(timestampArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
fun onEngineReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onEngineReadyStateChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, stateArg)) {
channel.send(listOf(sequenceArg, stateArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5354,12 +5688,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onIconUpdate(timestampArg: Long, urlArg: String, bytesArg: ByteArray, callback: (Result<Unit>) -> Unit)
fun onIconUpdate(sequenceArg: Long, urlArg: String, bytesArg: ByteArray, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, urlArg, bytesArg)) {
channel.send(listOf(sequenceArg, urlArg, bytesArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5371,12 +5705,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onTabAdded(timestampArg: Long, tabIdArg: String, callback: (Result<Unit>) -> Unit)
fun onTabAdded(sequenceArg: Long, tabIdArg: String, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabAdded$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, tabIdArg)) {
channel.send(listOf(sequenceArg, tabIdArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5388,12 +5722,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onTabListChange(timestampArg: Long, tabIdsArg: List<String>, callback: (Result<Unit>) -> Unit)
fun onTabListChange(sequenceArg: Long, tabIdsArg: List<String>, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabListChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, tabIdsArg)) {
channel.send(listOf(sequenceArg, tabIdsArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5405,12 +5739,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onSelectedTabChange(timestampArg: Long, idArg: String?, callback: (Result<Unit>) -> Unit)
fun onSelectedTabChange(sequenceArg: Long, idArg: String?, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSelectedTabChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, idArg)) {
channel.send(listOf(sequenceArg, idArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5422,12 +5756,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onTabContentStateChange(timestampArg: Long, stateArg: TabContentState, callback: (Result<Unit>) -> Unit)
fun onTabContentStateChange(sequenceArg: Long, stateArg: TabContentState, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabContentStateChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, stateArg)) {
channel.send(listOf(sequenceArg, stateArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5439,12 +5773,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onHistoryStateChange(timestampArg: Long, idArg: String, stateArg: HistoryState, callback: (Result<Unit>) -> Unit)
fun onHistoryStateChange(sequenceArg: Long, idArg: String, stateArg: HistoryState, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onHistoryStateChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, idArg, stateArg)) {
channel.send(listOf(sequenceArg, idArg, stateArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5456,12 +5790,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onReaderableStateChange(timestampArg: Long, idArg: String, stateArg: ReaderableState, callback: (Result<Unit>) -> Unit)
fun onReaderableStateChange(sequenceArg: Long, idArg: String, stateArg: ReaderableState, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onReaderableStateChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, idArg, stateArg)) {
channel.send(listOf(sequenceArg, idArg, stateArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5473,12 +5807,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onSecurityInfoStateChange(timestampArg: Long, idArg: String, stateArg: SecurityInfoState, callback: (Result<Unit>) -> Unit)
fun onSecurityInfoStateChange(sequenceArg: Long, idArg: String, stateArg: SecurityInfoState, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSecurityInfoStateChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, idArg, stateArg)) {
channel.send(listOf(sequenceArg, idArg, stateArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5490,12 +5824,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onIconChange(timestampArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result<Unit>) -> Unit)
fun onIconChange(sequenceArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, idArg, bytesArg)) {
channel.send(listOf(sequenceArg, idArg, bytesArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5507,12 +5841,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onThumbnailChange(timestampArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result<Unit>) -> Unit)
fun onThumbnailChange(sequenceArg: Long, idArg: String, bytesArg: ByteArray?, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onThumbnailChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, idArg, bytesArg)) {
channel.send(listOf(sequenceArg, idArg, bytesArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5524,12 +5858,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onFindResults(timestampArg: Long, idArg: String, resultsArg: List<FindResultState>, callback: (Result<Unit>) -> Unit)
fun onFindResults(sequenceArg: Long, idArg: String, resultsArg: List<FindResultState>, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onFindResults$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, idArg, resultsArg)) {
channel.send(listOf(sequenceArg, idArg, resultsArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5541,12 +5875,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onLongPress(timestampArg: Long, idArg: String, hitResultArg: HitResult, callback: (Result<Unit>) -> Unit)
fun onLongPress(sequenceArg: Long, idArg: String, hitResultArg: HitResult, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onLongPress$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, idArg, hitResultArg)) {
channel.send(listOf(sequenceArg, idArg, hitResultArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5558,12 +5892,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onPreferenceChange(timestampArg: Long, valueArg: GeckoPref, callback: (Result<Unit>) -> Unit)
fun onPreferenceChange(sequenceArg: Long, valueArg: GeckoPref, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onPreferenceChange$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, valueArg)) {
channel.send(listOf(sequenceArg, valueArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5575,12 +5909,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onContainerSiteAssignment(timestampArg: Long, detailsArg: ContainerSiteAssignment, callback: (Result<Unit>) -> Unit)
fun onContainerSiteAssignment(sequenceArg: Long, detailsArg: ContainerSiteAssignment, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onContainerSiteAssignment$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, detailsArg)) {
channel.send(listOf(sequenceArg, detailsArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5592,12 +5926,29 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onMlProgress(timestampArg: Long, progressArg: MlProgressData, callback: (Result<Unit>) -> Unit)
fun onMlProgress(sequenceArg: Long, progressArg: MlProgressData, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onMlProgress$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, progressArg)) {
channel.send(listOf(sequenceArg, progressArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName)))
}
}
}
fun onManifestUpdate(sequenceArg: Long, tabIdArg: String, manifestArg: PwaManifest?, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onManifestUpdate$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(sequenceArg, tabIdArg, manifestArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5695,12 +6046,12 @@ class ReaderViewController(private val binaryMessenger: BinaryMessenger, private
GeckoPigeonCodec()
}
}
fun appearanceButtonVisibility(timestampArg: Long, visibleArg: Boolean, callback: (Result<Unit>) -> Unit)
fun appearanceButtonVisibility(sequenceArg: Long, visibleArg: Boolean, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.ReaderViewController.appearanceButtonVisibility$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, visibleArg)) {
channel.send(listOf(sequenceArg, visibleArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5853,12 +6204,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val
GeckoPigeonCodec()
}
}
fun onUpsertWebExtensionAction(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, extensionDataArg: WebExtensionData, callback: (Result<Unit>) -> Unit)
fun onUpsertWebExtensionAction(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, extensionDataArg: WebExtensionData, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpsertWebExtensionAction$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg, extensionDataArg)) {
channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg, extensionDataArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5870,12 +6221,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onRemoveWebExtensionAction(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, callback: (Result<Unit>) -> Unit)
fun onRemoveWebExtensionAction(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onRemoveWebExtensionAction$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg)) {
channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5887,12 +6238,12 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onUpdateWebExtensionIcon(timestampArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, iconArg: ByteArray, callback: (Result<Unit>) -> Unit)
fun onUpdateWebExtensionIcon(sequenceArg: Long, extensionIdArg: String, actionTypeArg: WebExtensionActionType, iconArg: ByteArray, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpdateWebExtensionIcon$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, extensionIdArg, actionTypeArg, iconArg)) {
channel.send(listOf(sequenceArg, extensionIdArg, actionTypeArg, iconArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5969,12 +6320,12 @@ class GeckoSuggestionEvents(private val binaryMessenger: BinaryMessenger, privat
GeckoPigeonCodec()
}
}
fun onSuggestionResult(timestampArg: Long, suggestionTypeArg: GeckoSuggestionType, suggestionsArg: List<GeckoSuggestion>, callback: (Result<Unit>) -> Unit)
fun onSuggestionResult(sequenceArg: Long, suggestionTypeArg: GeckoSuggestionType, suggestionsArg: List<GeckoSuggestion>, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionEvents.onSuggestionResult$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, suggestionTypeArg, suggestionsArg)) {
channel.send(listOf(sequenceArg, suggestionTypeArg, suggestionsArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -5995,12 +6346,12 @@ class GeckoTabContentEvents(private val binaryMessenger: BinaryMessenger, privat
GeckoPigeonCodec()
}
}
fun onContentUpdate(timestampArg: Long, contentArg: TabContent, callback: (Result<Unit>) -> Unit)
fun onContentUpdate(sequenceArg: Long, contentArg: TabContent, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabContentEvents.onContentUpdate$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, contentArg)) {
channel.send(listOf(sequenceArg, contentArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -6384,12 +6735,12 @@ class BrowserExtensionEvents(private val binaryMessenger: BinaryMessenger, priva
GeckoPigeonCodec()
}
}
fun onFeedRequested(timestampArg: Long, urlArg: String, callback: (Result<Unit>) -> Unit)
fun onFeedRequested(sequenceArg: Long, urlArg: String, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, urlArg)) {
channel.send(listOf(sequenceArg, urlArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -6546,17 +6897,17 @@ class GeckoViewportEvents(private val binaryMessenger: BinaryMessenger, private
* This is detected natively using WindowInsets API and provides
* accurate keyboard height information.
*
* [timestamp] Event timestamp for ordering.
* [sequence] Event sequence number for ordering.
* [heightPx] Keyboard height in pixels (0 when hidden).
* [isVisible] Whether the keyboard is currently visible.
* [isAnimating] Whether the keyboard is currently animating.
*/
fun onKeyboardVisibilityChanged(timestampArg: Long, heightPxArg: Long, isVisibleArg: Boolean, isAnimatingArg: Boolean, callback: (Result<Unit>) -> Unit)
fun onKeyboardVisibilityChanged(sequenceArg: Long, heightPxArg: Long, isVisibleArg: Boolean, isAnimatingArg: Boolean, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onKeyboardVisibilityChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(timestampArg, heightPxArg, isVisibleArg, isAnimatingArg)) {
channel.send(listOf(sequenceArg, heightPxArg, isVisibleArg, isAnimatingArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
@@ -7237,3 +7588,80 @@ interface GeckoAppLinksApi {
}
}
}
/**
* API for PWA (Progressive Web App) installation and management.
*
* Wraps Mozilla Android Components' WebAppUseCases and ManifestStorage
* to provide PWA install and query functionality to Flutter.
*
* Generated interface from Pigeon that represents a handler of messages from Flutter.
*/
interface GeckoPwaApi {
/**
* Installs the current page as a PWA (adds to home screen).
*
* Creates an Android shortcut with profile and container metadata embedded
* in the intent extras. This ensures the PWA opens with the same profile
* and container context that was active during installation.
*
* 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).
* Returns true if installation was successful.
*/
fun installWebApp(tabId: String?, profileUuid: String, contextId: String?, callback: (Result<Boolean>) -> Unit)
/** Returns a list of all installed PWA manifests. */
fun getInstalledWebApps(callback: (Result<List<PwaManifest>>) -> Unit)
companion object {
/** The codec used by GeckoPwaApi. */
val codec: MessageCodec<Any?> by lazy {
GeckoPigeonCodec()
}
/** Sets up an instance of `GeckoPwaApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: GeckoPwaApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.installWebApp$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
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 error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeckoPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeckoPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoPwaApi.getInstalledWebApps$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.getInstalledWebApps{ result: Result<List<PwaManifest>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeckoPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeckoPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -0,0 +1,297 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.graphics.ColorUtils
import eu.weblibre.flutter_mozilla_components.PwaConstants
import eu.weblibre.flutter_mozilla_components.R
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.browser.icons.IconRequest
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.support.base.log.logger.Logger
import kotlinx.coroutines.*
/**
* Manager for displaying branded loading screens during PWA and Custom Tab initialization.
* Handles loading of icons, theming, and animations.
*/
class LoadingScreenManager private constructor(
private val activity: Activity,
private val container: FrameLayout
) {
private val logger = Logger("LoadingScreenManager")
private var currentLoadingView: View? = null
private var pulseAnimator: ObjectAnimator? = null
companion object {
/**
* Creates a LoadingScreenManager for the given activity.
* The container should be the root view where loading screens will be added.
*/
fun forActivity(activity: Activity, container: FrameLayout): LoadingScreenManager {
return LoadingScreenManager(activity, container)
}
/**
* Detects if an intent is for a PWA based on the extras.
*/
fun isPwaIntent(intent: Intent?): Boolean {
return intent?.hasExtra(PwaConstants.EXTRA_PWA_PROFILE_UUID) == true
}
/**
* Extracts URL from an intent.
*/
fun extractUrl(intent: Intent?): String? {
return intent?.dataString
}
}
/**
* Shows the appropriate loading screen immediately based on intent analysis.
* This can be called before components are initialized.
*
* @param intent The intent to analyze for type detection
*/
fun showLoadingForIntent(intent: Intent) {
when {
isPwaIntent(intent) -> showPwaPlaceholder(intent)
else -> showCustomTabPlaceholder(intent)
}
}
/**
* Shows a PWA placeholder loading screen immediately (before components are ready).
* Uses URL to extract domain as temporary app name.
*/
private fun showPwaPlaceholder(intent: Intent) {
cleanup()
val view = LayoutInflater.from(activity).inflate(
R.layout.pwa_loading_screen,
container,
false
)
// Extract URL and use domain as temporary name
val url = intent.dataString
val domain = url?.let { extractDomain(it) } ?: "Web App"
// Set temporary app name (will be replaced with actual name once manifest loads)
val nameView = view.findViewById<TextView>(R.id.pwa_name)
nameView.text = domain
// Show WebLibre logo as placeholder (already set in XML layout)
val iconView = view.findViewById<ImageView>(R.id.pwa_icon)
iconView.alpha = 0.5f
// Start pulsing animation
startPulseAnimation(view)
container.addView(view)
currentLoadingView = view
}
/**
* Shows a Custom Tab placeholder loading screen immediately (before components are ready).
*/
private fun showCustomTabPlaceholder(intent: Intent) {
cleanup()
val view = LayoutInflater.from(activity).inflate(
R.layout.custom_tab_loading_screen,
container,
false
)
// Extract and display domain
val url = intent.dataString ?: return
val domainView = view.findViewById<TextView>(R.id.custom_tab_domain)
domainView.text = extractDomain(url)
container.addView(view)
currentLoadingView = view
}
/**
* Enhances the current loading screen with actual PWA data once components are ready.
* This updates the placeholder with real manifest data.
*/
fun enhancePwaLoading(
manifest: WebAppManifest,
browserIcons: BrowserIcons,
coroutineScope: CoroutineScope
) {
currentLoadingView?.let { view ->
// Apply theme colors if available
manifest.themeColor?.let { colorInt ->
view.setBackgroundColor(colorInt)
val isDark = ColorUtils.calculateLuminance(colorInt) < 0.5
val primaryTextColor = if (isDark) Color.WHITE else Color.BLACK
val secondaryTextColor = ColorUtils.setAlphaComponent(primaryTextColor, 0xB3)
view.findViewById<TextView>(R.id.pwa_name)?.setTextColor(primaryTextColor)
view.findViewById<TextView>(R.id.pwa_status)?.setTextColor(secondaryTextColor)
}
// Update app name
val nameView = view.findViewById<TextView>(R.id.pwa_name)
val appName = manifest.shortName ?: manifest.name
if (appName != null && nameView.text != appName) {
nameView.text = appName
}
// Load the PWA icon asynchronously and update
coroutineScope.launch(Dispatchers.IO) {
loadPwaIcon(manifest, browserIcons)?.let { bitmap ->
withContext(Dispatchers.Main) {
val iconView = view.findViewById<ImageView>(R.id.pwa_icon)
iconView.alpha = 1.0f
iconView.setImageBitmap(bitmap)
}
}
}
}
}
/**
* Enhances the Custom Tab loading screen with favicon once components are ready.
*/
fun enhanceCustomTabLoading(
url: String,
browserIcons: BrowserIcons,
coroutineScope: CoroutineScope
) {
currentLoadingView?.let { view ->
coroutineScope.launch(Dispatchers.IO) {
try {
val iconRequest = IconRequest(
url = url,
size = IconRequest.Size.DEFAULT
)
val iconResult = browserIcons.loadIcon(iconRequest).await()
iconResult?.bitmap?.let { bitmap ->
withContext(Dispatchers.Main) {
val iconView = view.findViewById<ImageView>(R.id.custom_tab_icon)
iconView.setImageBitmap(bitmap)
iconView.alpha = 1.0f
}
}
} catch (e: Exception) {
logger.debug("Failed to load favicon for $url")
}
}
}
}
/**
* Hides the loading screen with an optional fade-out animation.
*/
fun hideLoading(animate: Boolean = true) {
currentLoadingView?.let { view ->
if (animate) {
view.animate()
.alpha(0f)
.setDuration(200)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
cleanup()
}
})
.start()
} else {
cleanup()
}
}
}
/**
* Cleans up the loading view and animations.
*/
fun cleanup() {
pulseAnimator?.cancel()
pulseAnimator = null
currentLoadingView?.let { view ->
container.removeView(view)
}
currentLoadingView = null
}
private fun startPulseAnimation(view: View) {
val pulseView = view.findViewById<View>(R.id.pwa_icon_pulse)
?: return
pulseView.alpha = 0.0f
pulseAnimator = ObjectAnimator.ofFloat(pulseView, "alpha", 0.0f, 0.3f, 0.0f).apply {
duration = 1500
repeatCount = ObjectAnimator.INFINITE
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
private suspend fun loadPwaIcon(
manifest: WebAppManifest,
browserIcons: BrowserIcons
): Bitmap? {
return 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()
iconResource?.let { icon ->
val iconRequest = IconRequest(
url = manifest.startUrl,
size = IconRequest.Size.LAUNCHER,
resources = listOf(
IconRequest.Resource(
url = icon.src,
type = IconRequest.Resource.Type.MANIFEST_ICON,
sizes = icon.sizes?.map { size ->
mozilla.components.concept.engine.manifest.Size(size.width, size.height)
} ?: emptyList(),
mimeType = icon.type,
maskable = icon.purpose.contains(WebAppManifest.Icon.Purpose.MASKABLE)
)
)
)
val result = browserIcons.loadIcon(iconRequest).await()
result?.bitmap
}
} catch (e: Exception) {
logger.error("Failed to load PWA icon", e)
null
}
}
private fun extractDomain(url: String): String {
return try {
val uri = android.net.Uri.parse(url)
uri.host ?: url
} catch (e: Exception) {
url
}
}
}
@@ -0,0 +1,190 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.widget
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import com.google.android.material.card.MaterialCardView
import com.google.android.material.color.MaterialColors
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import eu.weblibre.flutter_mozilla_components.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.mapNotNull
import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.browser.state.state.CustomTabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
import mozilla.components.support.ktx.util.URLStringUtils
/**
* Custom tab toolbar with pill-shaped Material 3 design.
* Uses MDI (Pictogrammers) icons via the Iconics library.
*/
class CustomTabToolbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val toolbarCard: MaterialCardView
private val closeButton: ImageButton
private val securityIcon: ImageView
private val urlText: TextView
private val shareButton: ImageButton
private val openInBrowserButton: ImageButton
private val menuButton: ImageButton
private var sessionId: String? = null
private var store: BrowserStore? = null
private var urlScope: CoroutineScope? = null
private var securityScope: CoroutineScope? = null
var onCloseListener: (() -> Unit)? = null
var onShareListener: (() -> Unit)? = null
var onOpenInBrowserListener: (() -> Unit)? = null
var onMenuListener: (() -> Unit)? = null
init {
inflate(context, R.layout.custom_tab_toolbar, this)
toolbarCard = findViewById(R.id.toolbarCard)
closeButton = findViewById(R.id.closeButton)
securityIcon = findViewById(R.id.securityIcon)
urlText = findViewById(R.id.urlText)
shareButton = findViewById(R.id.shareButton)
openInBrowserButton = findViewById(R.id.openInBrowserButton)
menuButton = findViewById(R.id.menuButton)
closeButton.setOnClickListener { onCloseListener?.invoke() }
shareButton.setOnClickListener { onShareListener?.invoke() }
openInBrowserButton.setOnClickListener { onOpenInBrowserListener?.invoke() }
menuButton.setOnClickListener { onMenuListener?.invoke() }
applyMaterial3Colors()
applyIcons()
}
fun bind(sessionId: String, store: BrowserStore, toolbarColor: Int? = null) {
this.sessionId = sessionId
this.store = store
toolbarColor?.let { applyCustomColors(it) }
store.state.findCustomTab(sessionId)?.let { tab ->
updateUrl(tab)
updateSecurityIcon(tab)
}
observeUrlChanges()
observeSecurityChanges()
}
fun unbind() {
urlScope?.cancel()
urlScope = null
securityScope?.cancel()
securityScope = null
}
fun getMenuButton(): View = menuButton
fun getUrl(): String? = urlText.text?.toString()
private fun applyMaterial3Colors() {
val surfaceColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface)
val onSurfaceColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface)
toolbarCard.setCardBackgroundColor(surfaceColor)
urlText.setTextColor(onSurfaceColor)
}
private fun applyIcons() {
val iconColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant)
closeButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_close, 20, iconColor))
shareButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 18, iconColor))
openInBrowserButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 18, iconColor))
menuButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_dots_vertical, 18, iconColor))
}
private fun applyCustomColors(color: Int) {
toolbarCard.setCardBackgroundColor(color)
val textColor = if (isDarkColor(color)) {
android.graphics.Color.WHITE
} else {
android.graphics.Color.BLACK
}
urlText.setTextColor(textColor)
closeButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_close, 20, textColor))
shareButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_share_variant, 18, textColor))
openInBrowserButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_open_in_new, 18, textColor))
menuButton.setImageDrawable(mdiIcon(CommunityMaterial.Icon.cmd_dots_vertical, 18, textColor))
}
private fun isDarkColor(color: Int): Boolean {
val darkness = 1 - (0.299 * android.graphics.Color.red(color) +
0.587 * android.graphics.Color.green(color) +
0.114 * android.graphics.Color.blue(color)) / 255
return darkness >= 0.5
}
private fun observeUrlChanges() {
val sessionId = this.sessionId ?: return
val store = this.store ?: return
urlScope = store.flowScoped { flow ->
flow
.mapNotNull { state -> state.findCustomTab(sessionId) }
.ifAnyChanged { tab -> arrayOf(tab.content.url) }
.collect { tab -> updateUrl(tab) }
}
}
private fun observeSecurityChanges() {
val sessionId = this.sessionId ?: return
val store = this.store ?: return
securityScope = store.flowScoped { flow ->
flow
.mapNotNull { state -> state.findCustomTab(sessionId) }
.ifAnyChanged { tab -> arrayOf(tab.content.securityInfo.isSecure) }
.collect { tab -> updateSecurityIcon(tab) }
}
}
private fun updateUrl(tab: CustomTabSessionState) {
urlText.text = URLStringUtils.toDisplayUrl(tab.content.url)
}
private fun updateSecurityIcon(tab: CustomTabSessionState) {
if (tab.content.securityInfo.isSecure) {
val color = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant)
securityIcon.setImageDrawable(mdiIcon(CommunityMaterial.Icon2.cmd_lock, 16, color))
} else {
val color = MaterialColors.getColor(this, android.R.attr.colorError)
securityIcon.setImageDrawable(mdiIcon(CommunityMaterial.Icon3.cmd_web, 16, color))
}
}
private fun mdiIcon(icon: IIcon, sizeDp: Int, color: Int): IconicsDrawable {
return IconicsDrawable(context, icon).apply {
this.sizeDp = sizeDp
this.colorInt = color
}
}
}
@@ -0,0 +1,51 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.widget
import android.view.Window
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import mozilla.components.browser.state.selector.findCustomTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.base.feature.UserInteractionHandler
/**
* Feature that connects [CustomTabToolbar] to browser state.
* Handles lifecycle, state observation, and window color updates.
*/
class CustomTabToolbarFeature(
private val store: BrowserStore,
private val toolbar: CustomTabToolbar,
private val sessionId: String,
private val window: Window
) : LifecycleAwareFeature, DefaultLifecycleObserver, UserInteractionHandler {
override fun start() {
val tab = store.state.findCustomTab(sessionId) ?: return
val toolbarColor = tab.config.colorSchemes?.defaultColorSchemeParams?.toolbarColor
toolbar.bind(sessionId, store, toolbarColor)
toolbarColor?.let { color ->
window.statusBarColor = color
window.navigationBarColor = color
}
}
override fun stop() {
toolbar.unbind()
}
override fun onDestroy(owner: LifecycleOwner) {
stop()
super<DefaultLifecycleObserver>.onDestroy(owner)
}
override fun onBackPressed(): Boolean = false
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurfaceContainer" />
<corners android:radius="16dp" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<!-- Pulsing ripple effect for PWA loading animation -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="?attr/colorPrimary" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true" />
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<!-- Minimal loading screen for Custom Tabs - keeps focus on content -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/custom_tab_loading_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<!-- Small domain favicon or generic icon -->
<ImageView
android:id="@+id/custom_tab_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/website_icon_description"
android:alpha="0.6"
android:src="@drawable/ic_launcher_foreground" />
<!-- Domain/URL being loaded -->
<TextView
android:id="@+id/custom_tab_domain"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginBottom="16dp"
android:paddingHorizontal="32dp" />
<!-- Simple linear progress indicator -->
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/custom_tab_progress"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:indeterminate="true"
app:trackThickness="2dp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/custom_tab_menu_bg"
android:paddingVertical="4dp"
android:minWidth="192dp">
<!-- Navigation row: Back / Forward -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="40dp"
android:orientation="horizontal"
android:gravity="center"
android:paddingHorizontal="12dp">
<ImageButton
android:id="@+id/menuBack"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="center"
android:contentDescription="@string/custom_tab_navigate_back" />
<ImageButton
android:id="@+id/menuForward"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="center"
android:contentDescription="@string/custom_tab_navigate_forward" />
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="4dp"
android:background="?attr/colorOutlineVariant" />
<!-- Refresh -->
<LinearLayout
android:id="@+id/menuRefresh"
android:layout_width="match_parent"
android:layout_height="40dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/menuRefreshIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/custom_tab_refresh"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Share -->
<LinearLayout
android:id="@+id/menuShare"
android:layout_width="match_parent"
android:layout_height="40dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/menuShareIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/custom_tab_share"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<!-- Desktop site -->
<LinearLayout
android:id="@+id/menuDesktopSite"
android:layout_width="match_parent"
android:layout_height="40dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/menuDesktopIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="12dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/custom_tab_desktop_site"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/menuDesktopSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Open in browser -->
<LinearLayout
android:id="@+id/menuOpenInBrowser"
android:layout_width="match_parent"
android:layout_height="40dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="12dp"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/menuOpenInBrowserIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="12dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/custom_tab_open_in_browser"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/toolbarCard"
android:layout_width="match_parent"
android:layout_height="56dp"
app:cardCornerRadius="28dp"
app:cardElevation="3dp"
app:strokeWidth="0dp"
app:cardBackgroundColor="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- Close Button -->
<ImageButton
android:id="@+id/closeButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="center"
android:contentDescription="@string/mozac_feature_customtabs_exit_button" />
<!-- URL Section with Security Indicator -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="8dp">
<ImageView
android:id="@+id/securityIcon"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginEnd="6dp"
android:contentDescription="@string/mozac_feature_customtabs_security_indicator" />
<TextView
android:id="@+id/urlText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp" />
</LinearLayout>
<!-- Action Buttons -->
<ImageButton
android:id="@+id/shareButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="center"
android:contentDescription="@string/custom_tab_share" />
<ImageButton
android:id="@+id/openInBrowserButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="center"
android:contentDescription="@string/custom_tab_open_in_browser" />
<ImageButton
android:id="@+id/menuButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="center"
android:contentDescription="@string/mozac_feature_customtabs_menu_button" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</FrameLayout>
@@ -3,12 +3,22 @@
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<!-- Main browser fragment layout -->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/customTabAppBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<!-- Placeholder for custom tab toolbar - ExternalAppBrowserFragment provides its own -->
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/pwa_loading_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackground">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp">
<!-- App Icon Container with subtle shadow/elevation -->
<FrameLayout
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="24dp">
<!-- Icon background for maskable icons -->
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/pwa_icon_background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
app:shapeAppearanceOverlay="@style/RoundedIconShape" />
<!-- App Icon -->
<ImageView
android:id="@+id/pwa_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:contentDescription="@string/pwa_icon_description"
android:src="@drawable/ic_launcher_foreground" />
<!-- Pulsing animation overlay -->
<View
android:id="@+id/pwa_icon_pulse"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/pulse_ripple"
android:alpha="0.0" />
</FrameLayout>
<!-- App Name -->
<TextView
android:id="@+id/pwa_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?android:attr/textColorPrimary"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end"
android:layout_marginBottom="8dp" />
<!-- Subtitle/Status -->
<TextView
android:id="@+id/pwa_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:gravity="center"
android:text="@string/pwa_loading" />
<!-- Progress indicator at bottom -->
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/pwa_progress"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginTop="32dp"
android:indeterminate="true"
app:indicatorSize="32dp"
app:trackThickness="3dp" />
</LinearLayout>
</FrameLayout>
@@ -16,4 +16,19 @@
<string name="mozac_feature_addons_failed_to_uninstall">Failed to uninstall %1$s</string>
<!-- Error shown when something unexpected happened while trying to get the extension list from the server -->
<string name="mozac_feature_addons_failed_to_query_extensions">Failed to query extensions!</string>
<!-- Custom tab menu items -->
<string name="custom_tab_share">Share</string>
<string name="custom_tab_desktop_site">Desktop site</string>
<string name="custom_tab_open_in_browser">Open in browser</string>
<string name="custom_tab_navigate_back">Back</string>
<string name="custom_tab_navigate_forward">Forward</string>
<string name="custom_tab_refresh">Refresh</string>
<string name="mozac_feature_customtabs_exit_button">Close</string>
<string name="mozac_feature_customtabs_menu_button">Menu</string>
<string name="mozac_feature_customtabs_security_indicator">Security</string>
<!-- Loading screen strings -->
<string name="pwa_loading">Opening…</string>
<string name="pwa_icon_description">App icon</string>
<string name="website_icon_description">Website icon</string>
</resources>
@@ -7,4 +7,10 @@
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowAnimationStyle">@android:style/Animation</item>
</style>
<!-- Shape style for rounded icon backgrounds (PWA loading) -->
<style name="RoundedIconShape">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">28%</item>
</style>
</resources>
@@ -58,6 +58,7 @@ export 'src/pigeons/gecko.g.dart'
GeckoFetchResponse,
GeckoPref,
GeckoPublicSuffixListApi,
GeckoPwaApi,
GeckoSitePermissionsApi,
GeckoSuggestion,
GeckoSuggestionType,
@@ -75,6 +76,8 @@ export 'src/pigeons/gecko.g.dart'
MlProgressStatus,
MlProgressType,
PhoneHitResult,
PwaIcon,
PwaManifest,
QueryParameterStripping,
Resource,
ResourceSize,
@@ -202,8 +202,12 @@ class GeckoEventService extends GeckoStateEvents {
_mlProgressSubject.addWhenMoreRecent(sequence, null, progress);
}
void onMlProgress(int timestamp, MlProgressData progress) {
_mlProgressSubject.addWhenMoreRecent(timestamp, null, progress);
@override
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest) {
_manifestUpdateSubject.addWhenMoreRecent(sequence, tabId, (
tabId: tabId,
manifest: manifest,
));
}
GeckoEventService.setUp({
@@ -1422,7 +1422,7 @@ abstract class GeckoStateEvents {
void onMlProgress(int sequence, MlProgressData progress);
void onMlProgress(int timestamp, MlProgressData progress);
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest);
}
@FlutterApi()
@@ -2017,3 +2017,138 @@ abstract class GeckoAppLinksApi {
@async
bool openAppLink(String url);
}
// =============================================================================
// PWA API
// =============================================================================
/// Represents an icon from a PWA manifest.
class PwaIcon {
final String src;
final String? sizes;
final String? type;
const PwaIcon({required this.src, this.sizes, this.type});
}
/// Represents a file entry in share target params.
class ShareTargetFiles {
final String name;
final List<String?> accept;
const ShareTargetFiles({required this.name, required this.accept});
}
/// Represents share target params.
class ShareTargetParams {
final String? title;
final String? text;
final String? url;
final List<ShareTargetFiles?> files;
const ShareTargetParams({
this.title,
this.text,
this.url,
this.files = const [],
});
}
/// Represents a share target for PWA.
class ShareTarget {
final String action;
final String? method;
final String? encType;
final ShareTargetParams? params;
const ShareTarget({
required this.action,
this.method,
this.encType,
this.params,
});
}
/// Represents an external application resource.
class ExternalApplicationResource {
final String platform;
final String? url;
final String? id;
final String? minVersion;
const ExternalApplicationResource({
required this.platform,
this.url,
this.id,
this.minVersion,
});
}
/// Represents a PWA web app manifest.
///
/// Mirrors Mozilla Android Components' WebAppManifest structure.
/// https://firefox-source-docs.mozilla.org/mobile/android/geckoview/api/mozilla.components.concept.engine.manifest.WebAppManifest.html
class PwaManifest {
final String startUrl;
final String? name;
final String? shortName;
final String? display;
final String? themeColor;
final String? backgroundColor;
final String? scope;
final String? description;
final List<PwaIcon?> icons;
final String? dir;
final String? lang;
final String? orientation;
final List<ExternalApplicationResource?> relatedApplications;
final bool preferRelatedApplications;
final ShareTarget? shareTarget;
/// The URL of the page when the manifest was detected.
/// Used for HTTPS/installability checks.
final String currentUrl;
const PwaManifest({
required this.startUrl,
required this.currentUrl,
this.name,
this.shortName,
this.display,
this.themeColor,
this.backgroundColor,
this.scope,
this.description,
this.icons = const [],
this.dir,
this.lang,
this.orientation,
this.relatedApplications = const [],
this.preferRelatedApplications = false,
this.shareTarget,
});
}
/// API for PWA (Progressive Web App) installation and management.
///
/// Wraps Mozilla Android Components' WebAppUseCases and ManifestStorage
/// to provide PWA install and query functionality to Flutter.
@HostApi()
abstract class GeckoPwaApi {
/// Installs the current page as a PWA (adds to home screen).
///
/// Creates an Android shortcut with profile and container metadata embedded
/// in the intent extras. This ensures the PWA opens with the same profile
/// and container context that was active during installation.
///
/// 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).
/// Returns true if installation was successful.
@async
bool installWebApp(String? tabId, String profileUuid, String? contextId);
/// Returns a list of all installed PWA manifests.
@async
List<PwaManifest> getInstalledWebApps();
}