initial pwa + custom tabs
This commit is contained in:
@@ -100,12 +100,35 @@
|
|||||||
<data android:mimeType="text/*" />
|
<data android:mimeType="text/*" />
|
||||||
</intent-filter>
|
</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 -->
|
<!-- Required exactly like this to appear in both default app choosers and when explicitly browsing -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<category android:name="mozilla.components.pwa.category.SHORTCUT" />
|
||||||
|
|
||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
@@ -123,7 +146,7 @@
|
|||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
<data android:mimeType="application/xhtml+xml" />
|
<data android:mimeType="application/xhtml+xml" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
@@ -133,19 +156,29 @@
|
|||||||
<data android:mimeType="application/pdf" />
|
<data android:mimeType="application/pdf" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- PWA launch from home screen shortcut -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
<action android:name="mozilla.components.feature.pwa.VIEW_PWA" />
|
||||||
<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" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:scheme="https" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<activity
|
||||||
android:theme="@style/AddonsActivityTheme"
|
android:theme="@style/AddonsActivityTheme"
|
||||||
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
android:name="eu.weblibre.flutter_mozilla_components.addons.AddonsActivity"
|
||||||
|
|||||||
@@ -30,6 +30,11 @@
|
|||||||
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
|
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
|
||||||
</style>
|
</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">
|
<style name="AddonsActivityTheme" parent="AppTheme">
|
||||||
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
|
<item name="mozac_primary_text_color">@color/photonDarkGrey90</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ class _Filesystem {
|
|||||||
return fs.clearMozillaProfileCache(profileId);
|
return fs.clearMozillaProfileCache(profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> getMozillaProfileIds(UuidValue uuid) {
|
||||||
|
return fs.getMozillaProfileIds(getProfileDir(uuid));
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> checkForDuplicateMozillaProfile(UuidValue profile) async {
|
Future<String?> checkForDuplicateMozillaProfile(UuidValue profile) async {
|
||||||
final duplicates = await fs
|
final duplicates = await fs
|
||||||
.getProfilesWithDuplicateMozillaProfiles(profilesDir)
|
.getProfilesWithDuplicateMozillaProfiles(profilesDir)
|
||||||
|
|||||||
+15
@@ -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/browser/domain/services/proxy_settings_replication.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.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/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/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/share_intent/domain/entities/shared_content.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
|
@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/menu_item_buttons.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_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/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/controllers/readerable.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.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';
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
@@ -195,6 +197,27 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
).push(context);
|
).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)
|
if (enableCloneTab)
|
||||||
SubmenuButton(
|
SubmenuButton(
|
||||||
menuChildren: [
|
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;
|
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);
|
await filesystem.getProfileDir(uuid).delete(recursive: true);
|
||||||
|
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ final class ProfileRepositoryProvider
|
|||||||
ProfileRepository create() => ProfileRepository();
|
ProfileRepository create() => ProfileRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$profileRepositoryHash() => r'249240ce0e775d4fd9ee3558e6b05326d09d79de';
|
String _$profileRepositoryHash() => r'e925dba74b0f15244fea8fff8391b5b67509be09';
|
||||||
|
|
||||||
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
abstract class _$ProfileRepository extends $AsyncNotifier<List<Profile>> {
|
||||||
FutureOr<List<Profile>> build();
|
FutureOr<List<Profile>> build();
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class UserBackupService extends _$UserBackupService {
|
|||||||
Stream<File> getBackupListStream() async* {
|
Stream<File> getBackupListStream() async* {
|
||||||
final backupDirectory = await getBackupDirectory();
|
final backupDirectory = await getBackupDirectory();
|
||||||
|
|
||||||
|
if (!await backupDirectory.exists()) return;
|
||||||
|
|
||||||
await for (final entity in backupDirectory.list(recursive: true)) {
|
await for (final entity in backupDirectory.list(recursive: true)) {
|
||||||
if (entity is File) {
|
if (entity is File) {
|
||||||
yield entity;
|
yield entity;
|
||||||
@@ -144,32 +146,49 @@ class UserBackupService extends _$UserBackupService {
|
|||||||
final existingProfile = await filesystem.readProfileMetadata(
|
final existingProfile = await filesystem.readProfileMetadata(
|
||||||
outputDirectory,
|
outputDirectory,
|
||||||
);
|
);
|
||||||
if (existingProfile != null) {
|
if (existingProfile == null) {
|
||||||
if (existingProfile.uuidValue == filesystem.selectedProfile) {
|
throw Exception(
|
||||||
throw Exception(
|
'Backup does not contain valid profile metadata',
|
||||||
'Unable to override active User, please switch to another User and try again',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final profileDir = filesystem.getProfileDir(
|
|
||||||
existingProfile.uuidValue,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (await profileDir.exists()) {
|
if (existingProfile.uuidValue == filesystem.selectedProfile) {
|
||||||
final result = await confirmOverrideCallback();
|
throw Exception(
|
||||||
|
'Unable to override active User, please switch to another User and try again',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (result == true) {
|
final profileDir = filesystem.getProfileDir(
|
||||||
await profileDir.delete(recursive: true);
|
existingProfile.uuidValue,
|
||||||
await outputDirectory.rename(profileDir.path);
|
);
|
||||||
}
|
|
||||||
|
if (await profileDir.exists()) {
|
||||||
|
final result = await confirmOverrideCallback();
|
||||||
|
|
||||||
|
if (result == true) {
|
||||||
|
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);
|
ref.invalidate(profileRepositoryProvider);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
await outputDirectory.delete(recursive: true);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class UserBackupServiceProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$userBackupServiceHash() => r'cbeffa04bb52d3c7d50a2eb38678750f2a0362cc';
|
String _$userBackupServiceHash() => r'a0bcf458e6b0a982f1fc90fce99e60cc3418f4d9';
|
||||||
|
|
||||||
abstract class _$UserBackupService extends $Notifier<void> {
|
abstract class _$UserBackupService extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -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(
|
Future<List<Directory>> getProfilesWithDuplicateMozillaProfiles(
|
||||||
Directory profilesDir,
|
Directory profilesDir,
|
||||||
) async {
|
) async {
|
||||||
@@ -122,6 +136,16 @@ Future<UuidValue?> selectStartupProfile(Directory profilesDir) async {
|
|||||||
var startupProfile = await readStartupProfile(profilesDir);
|
var startupProfile = await readStartupProfile(profilesDir);
|
||||||
final availableProfiles = await getAvailableProfileDirectories(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) {
|
if (startupProfile == null) {
|
||||||
final sortedDirs = await sortByAccessTime(availableProfiles);
|
final sortedDirs = await sortByAccessTime(availableProfiles);
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ dependencies {
|
|||||||
implementation "org.mozilla.components:feature-webcompat:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:feature-webcompat:$mozillaComponentsVersion"
|
||||||
implementation "org.mozilla.components:feature-webnotifications:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:feature-webnotifications:$mozillaComponentsVersion"
|
||||||
implementation "org.mozilla.components:feature-webauthn:$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:ui-widgets:$mozillaComponentsVersion"
|
||||||
implementation "org.mozilla.components:lib-publicsuffixlist:$mozillaComponentsVersion"
|
implementation "org.mozilla.components:lib-publicsuffixlist:$mozillaComponentsVersion"
|
||||||
|
|
||||||
@@ -134,6 +136,8 @@ dependencies {
|
|||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||||
implementation 'com.google.android.material:material:1.13.0'
|
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
|
//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.activity:activity-ktx:1.12.3'
|
||||||
implementation 'androidx.paging:paging-runtime-ktx:3.4.0'
|
implementation 'androidx.paging:paging-runtime-ktx:3.4.0'
|
||||||
|
|||||||
+30
-8
@@ -73,7 +73,7 @@ import mozilla.components.support.webextensions.WebExtensionPopupObserver
|
|||||||
*/
|
*/
|
||||||
@SuppressWarnings("LargeClass")
|
@SuppressWarnings("LargeClass")
|
||||||
abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler {
|
abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, ActivityResultHandler {
|
||||||
private val sessionFeature = ViewBoundFeatureWrapper<SessionFeature>()
|
protected val sessionFeature = ViewBoundFeatureWrapper<SessionFeature>()
|
||||||
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
|
private val shareResourceFeature = ViewBoundFeatureWrapper<ShareResourceFeature>()
|
||||||
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
|
private val downloadsFeature = ViewBoundFeatureWrapper<DownloadsFeature>()
|
||||||
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
|
private val appLinksFeature = ViewBoundFeatureWrapper<AppLinksFeature>()
|
||||||
@@ -136,6 +136,9 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
|
|
||||||
protected abstract fun createEngine(components: Components): EngineView
|
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 requestDownloadPermissionsLauncher: ActivityResultLauncher<Array<String>>
|
||||||
private lateinit var requestSitePermissionsLauncher: ActivityResultLauncher<Array<String>>
|
private lateinit var requestSitePermissionsLauncher: ActivityResultLauncher<Array<String>>
|
||||||
private lateinit var requestPromptsPermissionsLauncher: 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)
|
ProfileContext(requireContext(), components.profileApplicationContext.relativePath)
|
||||||
|
|
||||||
val engineView = createEngine(components)
|
val engineView = createEngine(components)
|
||||||
|
fragmentEngineView = engineView // Track for lifecycle management
|
||||||
val originalContext = ActivityContextWrapper.getOriginalContext(requireActivity())
|
val originalContext = ActivityContextWrapper.getOriginalContext(requireActivity())
|
||||||
?.let { ProfileContext(it, components.profileApplicationContext.relativePath) }
|
?.let { ProfileContext(it, components.profileApplicationContext.relativePath) }
|
||||||
val engineNativeView = engineView.asView()
|
val engineNativeView = engineView.asView()
|
||||||
@@ -228,17 +232,14 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
|
|
||||||
binding.swipeToRefresh.addView(engineNativeView)
|
binding.swipeToRefresh.addView(engineNativeView)
|
||||||
|
|
||||||
components.engineView = engineView
|
components.activeEngineView = engineView
|
||||||
|
|
||||||
// Apply any pending viewport settings that were set before engineView was ready
|
|
||||||
GlobalComponents.viewportApi?.applyPendingSettings()
|
|
||||||
|
|
||||||
sessionFeature.set(
|
sessionFeature.set(
|
||||||
feature = SessionFeature(
|
feature = SessionFeature(
|
||||||
components.core.store,
|
components.core.store,
|
||||||
components.useCases.sessionUseCases.goBack,
|
components.useCases.sessionUseCases.goBack,
|
||||||
components.useCases.sessionUseCases.goForward,
|
components.useCases.sessionUseCases.goForward,
|
||||||
components.engineView!!,
|
engineView,
|
||||||
sessionId,
|
sessionId,
|
||||||
),
|
),
|
||||||
owner = this,
|
owner = this,
|
||||||
@@ -468,7 +469,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
thumbnailsFeature.set(
|
thumbnailsFeature.set(
|
||||||
feature = BrowserThumbnails(
|
feature = BrowserThumbnails(
|
||||||
profileContext,
|
profileContext,
|
||||||
components.engineView!!,
|
engineView,
|
||||||
components.core.store
|
components.core.store
|
||||||
),
|
),
|
||||||
owner = this,
|
owner = this,
|
||||||
@@ -496,12 +497,20 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEngineSetupComplete()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("EngineCreation", "Failed to create engine: ${e.message}", e)
|
Log.e("EngineCreation", "Failed to create engine: ${e.message}", e)
|
||||||
context?.let { restartApp(it) }
|
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) {
|
private fun openPopup(webExtensionState: WebExtensionState) {
|
||||||
val store = components.core.store
|
val store = components.core.store
|
||||||
val popupSession = store.state.extensions[webExtensionState.id]?.popupSession ?: return
|
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() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
|
||||||
@@ -595,7 +612,12 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
|
|||||||
keyboardVisibilityFeature = null
|
keyboardVisibilityFeature = null
|
||||||
|
|
||||||
GlobalComponents.onPullToRefreshEnabledChanged = null
|
GlobalComponents.onPullToRefreshEnabledChanged = null
|
||||||
components.engineView?.setActivityContext(null)
|
val engineView = fragmentEngineView
|
||||||
|
engineView?.setActivityContext(null)
|
||||||
|
if (components.activeEngineView == engineView) {
|
||||||
|
components.activeEngineView = null
|
||||||
|
}
|
||||||
_binding = null
|
_binding = null
|
||||||
|
fragmentEngineView = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -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
|
//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 {
|
return components.core.engine.createView(requireContext()).apply {
|
||||||
selectionActionDelegate = components.selectionAction
|
selectionActionDelegate = components.selectionAction
|
||||||
|
}.also { engineView ->
|
||||||
|
components.mainBrowserEngineView = engineView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +37,18 @@ class BrowserFragment() : BaseBrowserFragment(), UserInteractionHandler {
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onEngineSetupComplete() {
|
||||||
|
GlobalComponents.viewportApi?.applyPendingToolbarHeight()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBackPressed(): Boolean =
|
override fun onBackPressed(): Boolean =
|
||||||
super.readerViewFeature.onBackPressed() || super.onBackPressed()
|
super.readerViewFeature.onBackPressed() || super.onBackPressed()
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
components.mainBrowserEngineView = null
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun create(sessionId: String? = null) = BrowserFragment().apply {
|
fun create(sessionId: String? = null) = BrowserFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
|
|||||||
+6
-2
@@ -44,12 +44,16 @@ class Components(val profileApplicationContext: ProfileContext,
|
|||||||
) {
|
) {
|
||||||
val core by lazy { Core(profileApplicationContext, this, flutterEvents, extensionEvents) }
|
val core by lazy { Core(profileApplicationContext, this, flutterEvents, extensionEvents) }
|
||||||
val events by lazy { Events(flutterEvents) }
|
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 services by lazy { Services(profileApplicationContext, core.store, useCases.tabsUseCases) }
|
||||||
val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) }
|
val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) }
|
||||||
val search by lazy { Search(profileApplicationContext, core, useCases) }
|
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
|
var engineReportedInitialized = false
|
||||||
|
|
||||||
private val notificationManagerCompat = NotificationManagerCompat.from(profileApplicationContext)
|
private val notificationManagerCompat = NotificationManagerCompat.from(profileApplicationContext)
|
||||||
|
|||||||
+371
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -2,6 +2,7 @@ package eu.weblibre.flutter_mozilla_components
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
@@ -13,6 +14,8 @@ class ProfileContext(private val base: Context, val relativePath: String) :
|
|||||||
private val subfolderRoot =
|
private val subfolderRoot =
|
||||||
File(base.filesDir, relativePath) // /data/user/0/com.app/profiles/default
|
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 customFilesDir: File = File(subfolderRoot, "files")
|
||||||
private var customNoBackupFilesDir: File = File(subfolderRoot, "no_backup")
|
private var customNoBackupFilesDir: File = File(subfolderRoot, "no_backup")
|
||||||
private var customObbDir: File = File(subfolderRoot, "obb")
|
private var customObbDir: File = File(subfolderRoot, "obb")
|
||||||
@@ -124,4 +127,8 @@ class ProfileContext(private val base: Context, val relativePath: String) :
|
|||||||
parentFile?.mkdirs()
|
parentFile?.mkdirs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getSharedPreferences(name: String, mode: Int): SharedPreferences {
|
||||||
|
return base.getSharedPreferences("${profilePrefix}_$name", mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -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
|
||||||
|
}
|
||||||
+252
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+385
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -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.GeckoLogging
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoMlApi
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoPrefApi
|
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.GeckoSelectionActionController
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
|
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
|
||||||
@@ -275,6 +276,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
|
|||||||
GeckoTrackingProtectionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTrackingProtectionApiImpl())
|
GeckoTrackingProtectionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTrackingProtectionApiImpl())
|
||||||
GeckoAppLinksApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAppLinksApiImpl(profileApplicationContext))
|
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
|
// Viewport API for dynamic toolbar and keyboard handling
|
||||||
val viewportEvents = GeckoViewportEvents(_flutterPluginBinding.binaryMessenger)
|
val viewportEvents = GeckoViewportEvents(_flutterPluginBinding.binaryMessenger)
|
||||||
val viewportApi = GeckoViewportApiImpl()
|
val viewportApi = GeckoViewportApiImpl()
|
||||||
|
|||||||
+324
@@ -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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -254,7 +254,7 @@ class GeckoSessionApiImpl : GeckoSessionApi {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
components.engineView?.captureThumbnail { bitmap ->
|
components.mainBrowserEngineView?.captureThumbnail { bitmap ->
|
||||||
try {
|
try {
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
components.core.store.dispatch(ContentAction.UpdateThumbnailAction(tab.id, bitmap))
|
components.core.store.dispatch(ContentAction.UpdateThumbnailAction(tab.id, bitmap))
|
||||||
|
|||||||
+29
-34
@@ -14,8 +14,12 @@ import mozilla.components.support.base.log.logger.Logger
|
|||||||
* Implementation of GeckoViewportApi that controls GeckoView's viewport behavior
|
* Implementation of GeckoViewportApi that controls GeckoView's viewport behavior
|
||||||
* for dynamic toolbar and keyboard handling.
|
* for dynamic toolbar and keyboard handling.
|
||||||
*
|
*
|
||||||
* This allows Flutter to control how GeckoView adjusts its internal viewport
|
* Toolbar height and vertical clipping target the main browser's EngineView specifically,
|
||||||
* without resizing the platform view itself, avoiding visual flickering.
|
* 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 {
|
class GeckoViewportApiImpl : GeckoViewportApi {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -28,66 +32,57 @@ class GeckoViewportApiImpl : GeckoViewportApi {
|
|||||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the current dynamic toolbar max height
|
private var pendingToolbarHeight: Int? = null
|
||||||
private var currentDynamicToolbarMaxHeight: Int = 0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the maximum height that dynamic toolbars (top + bottom) can occupy.
|
* Sets the maximum height that dynamic toolbars (top + bottom) can occupy.
|
||||||
*
|
*
|
||||||
* GeckoView will adjust its internal viewport calculations to account for
|
* Targets the main browser EngineView specifically. If the main browser
|
||||||
* this space. The website will receive proper viewport dimensions through
|
* EngineView is not yet available, the height is stored and applied when
|
||||||
* standard web APIs (CSS viewport units, window.innerHeight).
|
* it becomes available via [applyPendingToolbarHeight].
|
||||||
*/
|
*/
|
||||||
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
|
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
|
||||||
val height = heightPx.toInt()
|
val height = heightPx.toInt()
|
||||||
currentDynamicToolbarMaxHeight = height
|
|
||||||
|
|
||||||
val engineView = components.engineView
|
val engineView = components.mainBrowserEngineView
|
||||||
if (engineView == null) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingToolbarHeight = null
|
||||||
logger.debug("$TAG: setDynamicToolbarMaxHeight($height)")
|
logger.debug("$TAG: setDynamicToolbarMaxHeight($height)")
|
||||||
engineView.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.
|
* Sets the vertical clipping offset for the GeckoView content.
|
||||||
*
|
*
|
||||||
* Use this as the toolbar animates to clip content at the bottom.
|
* Targets the main browser EngineView specifically.
|
||||||
* Negative values clip from the bottom (for bottom toolbar sliding up).
|
|
||||||
* Positive values clip from the top (for top toolbar sliding down).
|
|
||||||
*/
|
*/
|
||||||
override fun setVerticalClipping(clippingPx: Long) {
|
override fun setVerticalClipping(clippingPx: Long) {
|
||||||
val clipping = clippingPx.toInt()
|
val clipping = clippingPx.toInt()
|
||||||
|
|
||||||
val engineView = components.engineView
|
val engineView = components.mainBrowserEngineView
|
||||||
if (engineView == null) {
|
if (engineView == null) {
|
||||||
logger.warn("$TAG: setVerticalClipping called but engineView is null")
|
logger.warn("$TAG: setVerticalClipping called but mainBrowserEngineView is null")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("$TAG: setVerticalClipping($clipping)")
|
logger.debug("$TAG: setVerticalClipping($clipping)")
|
||||||
engineView.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -47,6 +47,8 @@ import mozilla.components.feature.addons.amo.AMOAddonsProvider
|
|||||||
import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
|
import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
|
||||||
import mozilla.components.feature.addons.update.DefaultAddonUpdater
|
import mozilla.components.feature.addons.update.DefaultAddonUpdater
|
||||||
import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
|
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.downloads.DownloadMiddleware
|
||||||
import mozilla.components.feature.media.MediaSessionFeature
|
import mozilla.components.feature.media.MediaSessionFeature
|
||||||
import mozilla.components.feature.media.middleware.LastMediaAccessMiddleware
|
import mozilla.components.feature.media.middleware.LastMediaAccessMiddleware
|
||||||
@@ -230,6 +232,14 @@ class Core(
|
|||||||
*/
|
*/
|
||||||
val customTabsStore by lazy { CustomTabsServiceStore() }
|
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.
|
* The storage component for persisting browser tab sessions.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+109
-3
@@ -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.api.ReaderViewEventsImpl
|
||||||
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
|
||||||
import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
|
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.FindResultState
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
|
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
|
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.HistoryItem
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryState
|
import eu.weblibre.flutter_mozilla_components.pigeons.HistoryState
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderableState
|
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderableState
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.SecurityInfoState
|
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 eu.weblibre.flutter_mozilla_components.pigeons.TabContentState
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -25,10 +30,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import mozilla.components.browser.state.action.BrowserAction
|
import mozilla.components.browser.state.action.BrowserAction
|
||||||
|
import mozilla.components.browser.state.selector.selectedTab
|
||||||
import mozilla.components.browser.state.state.BrowserState
|
import mozilla.components.browser.state.state.BrowserState
|
||||||
import mozilla.components.feature.addons.logger
|
import mozilla.components.feature.addons.logger
|
||||||
import mozilla.components.lib.state.Store
|
import mozilla.components.lib.state.Store
|
||||||
import mozilla.components.lib.state.ext.flowScoped
|
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.filterChanged
|
||||||
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
|
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
|
||||||
|
|
||||||
@@ -53,12 +60,27 @@ class Events(
|
|||||||
}
|
}
|
||||||
|
|
||||||
stateFlow.flowScoped { flow ->
|
stateFlow.flowScoped { flow ->
|
||||||
|
var previousTabs = emptySet<String>()
|
||||||
flow.mapNotNull { state -> state.tabs.map { tab -> tab.id } }
|
flow.mapNotNull { state -> state.tabs.map { tab -> tab.id } }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
// Make sure this is sent after tabadded action
|
// Make sure this is sent after tabadded action
|
||||||
.debounce { 25 }
|
.debounce { 25 }
|
||||||
.collect { tabs ->
|
.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
|
||||||
|
) { _ -> }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -12,6 +12,8 @@ import mozilla.components.feature.downloads.DownloadsUseCases
|
|||||||
import mozilla.components.feature.session.SessionUseCases
|
import mozilla.components.feature.session.SessionUseCases
|
||||||
import mozilla.components.feature.session.SettingsUseCases
|
import mozilla.components.feature.session.SettingsUseCases
|
||||||
import mozilla.components.feature.session.TrackingProtectionUseCases
|
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.CustomTabsUseCases
|
||||||
import mozilla.components.feature.tabs.TabsUseCases
|
import mozilla.components.feature.tabs.TabsUseCases
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ class UseCases(
|
|||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val engine: Engine,
|
private val engine: Engine,
|
||||||
private val store: BrowserStore,
|
private val store: BrowserStore,
|
||||||
|
private val shortcutManager: WebAppShortcutManager? = null,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Use cases that provide engine interactions for a given browser session.
|
* Use cases that provide engine interactions for a given browser session.
|
||||||
@@ -52,4 +55,8 @@ class UseCases(
|
|||||||
val appLinksUseCases by lazy { AppLinksUseCases(context) }
|
val appLinksUseCases by lazy { AppLinksUseCases(context) }
|
||||||
|
|
||||||
val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) }
|
val trackingProtectionUseCases by lazy { TrackingProtectionUseCases(store, engine) }
|
||||||
|
|
||||||
|
val webAppUseCases by lazy {
|
||||||
|
WebAppUseCases(context, store, shortcutManager ?: throw IllegalStateException("WebAppShortcutManager not provided"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+479
-51
@@ -2972,6 +2972,286 @@ data class TrackingProtectionException (
|
|||||||
|
|
||||||
override fun hashCode(): Int = toList().hashCode()
|
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() {
|
private open class GeckoPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
@@ -3400,6 +3680,36 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
|
|||||||
TrackingProtectionException.fromList(it)
|
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)
|
else -> super.readValueOfType(type, buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3745,6 +4055,30 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
|
|||||||
stream.write(213)
|
stream.write(213)
|
||||||
writeValue(stream, value.toList())
|
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)
|
else -> super.writeValue(stream, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5320,12 +5654,12 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
|
|||||||
GeckoPigeonCodec()
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onViewReadyStateChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onViewReadyStateChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, stateArg)) {
|
channel.send(listOf(sequenceArg, stateArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onEngineReadyStateChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onEngineReadyStateChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, stateArg)) {
|
channel.send(listOf(sequenceArg, stateArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconUpdate$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabAdded$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabAdded$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, tabIdArg)) {
|
channel.send(listOf(sequenceArg, tabIdArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabListChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabListChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, tabIdsArg)) {
|
channel.send(listOf(sequenceArg, tabIdsArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSelectedTabChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSelectedTabChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, idArg)) {
|
channel.send(listOf(sequenceArg, idArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabContentStateChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onTabContentStateChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, stateArg)) {
|
channel.send(listOf(sequenceArg, stateArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onHistoryStateChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onHistoryStateChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onReaderableStateChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onReaderableStateChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSecurityInfoStateChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onSecurityInfoStateChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onIconChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onThumbnailChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onThumbnailChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onFindResults$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onFindResults$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onLongPress$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onLongPress$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onPreferenceChange$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onPreferenceChange$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, valueArg)) {
|
channel.send(listOf(sequenceArg, valueArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onContainerSiteAssignment$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onContainerSiteAssignment$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, detailsArg)) {
|
channel.send(listOf(sequenceArg, detailsArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onMlProgress$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onMlProgress$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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()
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.ReaderViewController.appearanceButtonVisibility$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.ReaderViewController.appearanceButtonVisibility$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, visibleArg)) {
|
channel.send(listOf(sequenceArg, visibleArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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()
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpsertWebExtensionAction$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpsertWebExtensionAction$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onRemoveWebExtensionAction$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onRemoveWebExtensionAction$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpdateWebExtensionIcon$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonEvents.onUpdateWebExtensionIcon$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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()
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionEvents.onSuggestionResult$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionEvents.onSuggestionResult$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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()
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabContentEvents.onContentUpdate$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoTabContentEvents.onContentUpdate$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, contentArg)) {
|
channel.send(listOf(sequenceArg, contentArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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()
|
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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.BrowserExtensionEvents.onFeedRequested$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
||||||
channel.send(listOf(timestampArg, urlArg)) {
|
channel.send(listOf(sequenceArg, urlArg)) {
|
||||||
if (it is List<*>) {
|
if (it is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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
|
* This is detected natively using WindowInsets API and provides
|
||||||
* accurate keyboard height information.
|
* accurate keyboard height information.
|
||||||
*
|
*
|
||||||
* [timestamp] Event timestamp for ordering.
|
* [sequence] Event sequence number for ordering.
|
||||||
* [heightPx] Keyboard height in pixels (0 when hidden).
|
* [heightPx] Keyboard height in pixels (0 when hidden).
|
||||||
* [isVisible] Whether the keyboard is currently visible.
|
* [isVisible] Whether the keyboard is currently visible.
|
||||||
* [isAnimating] Whether the keyboard is currently animating.
|
* [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 separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onKeyboardVisibilityChanged$separatedMessageChannelSuffix"
|
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoViewportEvents.onKeyboardVisibilityChanged$separatedMessageChannelSuffix"
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
|
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 is List<*>) {
|
||||||
if (it.size > 1) {
|
if (it.size > 1) {
|
||||||
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+297
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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
|
||||||
|
}
|
||||||
+10
@@ -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>
|
||||||
+10
@@ -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" />
|
||||||
+54
@@ -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>
|
||||||
+94
@@ -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>
|
||||||
+11
-1
@@ -3,12 +3,22 @@
|
|||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
- 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/. -->
|
- 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"
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="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
|
<FrameLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
@@ -32,4 +42,4 @@
|
|||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|||||||
+88
@@ -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>
|
<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 -->
|
<!-- 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>
|
<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>
|
</resources>
|
||||||
@@ -7,4 +7,10 @@
|
|||||||
<item name="android:windowIsTranslucent">true</item>
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
<item name="android:windowAnimationStyle">@android:style/Animation</item>
|
<item name="android:windowAnimationStyle">@android:style/Animation</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Shape style for rounded icon backgrounds (PWA loading) -->
|
||||||
|
<style name="RoundedIconShape">
|
||||||
|
<item name="cornerFamily">rounded</item>
|
||||||
|
<item name="cornerSize">28%</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -58,6 +58,7 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
GeckoFetchResponse,
|
GeckoFetchResponse,
|
||||||
GeckoPref,
|
GeckoPref,
|
||||||
GeckoPublicSuffixListApi,
|
GeckoPublicSuffixListApi,
|
||||||
|
GeckoPwaApi,
|
||||||
GeckoSitePermissionsApi,
|
GeckoSitePermissionsApi,
|
||||||
GeckoSuggestion,
|
GeckoSuggestion,
|
||||||
GeckoSuggestionType,
|
GeckoSuggestionType,
|
||||||
@@ -75,6 +76,8 @@ export 'src/pigeons/gecko.g.dart'
|
|||||||
MlProgressStatus,
|
MlProgressStatus,
|
||||||
MlProgressType,
|
MlProgressType,
|
||||||
PhoneHitResult,
|
PhoneHitResult,
|
||||||
|
PwaIcon,
|
||||||
|
PwaManifest,
|
||||||
QueryParameterStripping,
|
QueryParameterStripping,
|
||||||
Resource,
|
Resource,
|
||||||
ResourceSize,
|
ResourceSize,
|
||||||
|
|||||||
@@ -202,8 +202,12 @@ class GeckoEventService extends GeckoStateEvents {
|
|||||||
_mlProgressSubject.addWhenMoreRecent(sequence, null, progress);
|
_mlProgressSubject.addWhenMoreRecent(sequence, null, progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onMlProgress(int timestamp, MlProgressData progress) {
|
@override
|
||||||
_mlProgressSubject.addWhenMoreRecent(timestamp, null, progress);
|
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest) {
|
||||||
|
_manifestUpdateSubject.addWhenMoreRecent(sequence, tabId, (
|
||||||
|
tabId: tabId,
|
||||||
|
manifest: manifest,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
GeckoEventService.setUp({
|
GeckoEventService.setUp({
|
||||||
|
|||||||
@@ -1422,7 +1422,7 @@ abstract class GeckoStateEvents {
|
|||||||
|
|
||||||
void onMlProgress(int sequence, MlProgressData progress);
|
void onMlProgress(int sequence, MlProgressData progress);
|
||||||
|
|
||||||
void onMlProgress(int timestamp, MlProgressData progress);
|
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@FlutterApi()
|
@FlutterApi()
|
||||||
@@ -2017,3 +2017,138 @@ abstract class GeckoAppLinksApi {
|
|||||||
@async
|
@async
|
||||||
bool openAppLink(String url);
|
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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user