10 Commits
Author SHA1 Message Date
Codex 05e8639b67 feat(android): add Pixel 10 optimized build profile
Pixel 10 APK / build (push) Failing after 7m26s
2026-08-09 17:19:52 +02:00
Fabian Freund 7b942e9bc3 prepare new alpha 2026-08-09 05:53:32 +02:00
Fabian Freund cc27a6daa8 refactor and cleanup 2026-08-09 05:53:02 +02:00
Fabian Freund 54e277be72 improve app link banner handling 2026-08-09 05:19:35 +02:00
Fabian Freund 599fb997f1 fix auth flows 2026-08-09 04:31:22 +02:00
Fabian Freund d7055a6f44 fix gecko view initialization issues 2026-08-09 03:17:24 +02:00
Fabian Freund 00e8ae7ff0 make tab selection cross container borders 2026-08-09 02:42:56 +02:00
Fabian Freund 7bcc528064 fix tab navigation order 2026-08-09 02:41:58 +02:00
Fabian Freund d3b3d020aa prepare new alpha 2026-08-04 17:23:34 +02:00
Fabian Freund c969821343 popular sites autocomplete setting 2026-08-04 16:27:01 +02:00
52 changed files with 4796 additions and 4774 deletions
+99
View File
@@ -0,0 +1,99 @@
name: Pixel 10 APK
on:
workflow_dispatch:
push:
branches:
- pixel10
jobs:
build:
runs-on: ubuntu-latest
env:
KEY_JKS: ${{ secrets.PIXEL10_KEY_JKS }}
KEY_PATH: ${{ github.workspace }}/apps/weblibre/pixel10-release.jks
KEY_PASSWORD: ${{ secrets.PIXEL10_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.PIXEL10_KEY_ALIAS }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: actions/setup-go@v5
with:
go-version: 1.25.x
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
cache: true
- name: Install Android NDK
shell: bash
run: |
set -euo pipefail
ndk_version="$(sed -n 's/^weblibre\.ndkVersion[[:space:]]*=[[:space:]]*//p' apps/weblibre/android/gradle.properties)"
test -n "$ndk_version"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$ndk_version" || test "${PIPESTATUS[1]}" -eq 0
{
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$ndk_version"
echo "ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/$ndk_version"
echo "NDK_HOME=$ANDROID_HOME/ndk/$ndk_version"
} >> "$GITHUB_ENV"
- name: Install workspace dependencies
run: |
dart pub global activate melos 7.8.1
melos bootstrap
- name: Generate bundled assets
run: |
melos run update-assets --no-select
melos run build-components --no-select
- name: Checkout pinned native sources
shell: bash
run: |
set -euo pipefail
source native/go_mobile_runtime/pins.env
git clone https://github.com/SagerNet/sing-box.git "$RUNNER_TEMP/sing-box"
git -C "$RUNNER_TEMP/sing-box" checkout "$SING_BOX_TAG"
test "$(git -C "$RUNNER_TEMP/sing-box" rev-parse HEAD)" = "$SING_BOX_COMMIT"
git clone https://github.com/tladesignz/IPtProxy.git "$RUNNER_TEMP/IPtProxy"
git -C "$RUNNER_TEMP/IPtProxy" checkout "$IPTPROXY_TAG"
test "$(git -C "$RUNNER_TEMP/IPtProxy" rev-parse HEAD)" = "$IPTPROXY_COMMIT"
git -C "$RUNNER_TEMP/IPtProxy" submodule update --init dnstt
- name: Build native runtime
env:
SING_BOX_SOURCE: ${{ runner.temp }}/sing-box
IPTPROXY_SOURCE: ${{ runner.temp }}/IPtProxy
run: melos run build-go-runtime --no-select
- name: Create release key
shell: bash
run: |
set -euo pipefail
test -n "$KEY_JKS"
printf '%s' "$KEY_JKS" | base64 -d > "$KEY_PATH"
chmod 600 "$KEY_PATH"
- name: Build and verify Pixel 10 APK
run: melos run build-browser-pixel10 --no-select
- uses: actions/upload-artifact@v4
with:
name: mrbweblibre-pixel10
path: apps/weblibre/build/app/outputs/flutter-apk/app-pixel10-release.apk
- name: Remove release key
if: always()
run: rm -f "$KEY_PATH"
+57
View File
@@ -0,0 +1,57 @@
# MrbWebLibre for Google Pixel 10
This fork keeps WebLibre's upstream behavior and adds a focused build profile
for the Google Pixel 10 family.
## What is optimized
- **ARM64 only:** Pixel 10 uses a 64-bit Tensor G5 platform. The `pixel10`
flavor excludes unused 32-bit native libraries, reducing the APK size and
eliminating accidental 32-bit packaging.
- **Impeller enabled:** the Flutter renderer remains enabled for smooth GPU
rendering on the 60120 Hz Pixel display.
- **High refresh rate by default:** WebLibre's existing `RefreshRateMode.high`
default is retained. It can still be changed under General settings when
battery life matters more than maximum smoothness.
- **16 KB page-size gate:** every native ARM64 library in the final APK is
checked for 16 KB-compatible ELF LOAD-segment alignment. This catches an
incompatible Gecko, Flutter, Tor, or proxy binary before the APK is shipped.
- **Separate app identity:** `cc.mrblake.mrbweblibre` installs alongside the
upstream `eu.weblibre.gecko` app and cannot overwrite an upstream-signed APK.
The fork deliberately does not spoof the device model or change Gecko web
preferences only for Pixels. Those changes would increase fingerprinting and
make upstream security updates harder to merge.
## Build
Use the Flutter and Android versions pinned by the upstream CI workflow, build
the generated assets and native runtime, then run:
```bash
melos run build-browser-pixel10 --no-select
```
The command produces:
```text
apps/weblibre/build/app/outputs/flutter-apk/app-pixel10-release.apk
```
Release builds require the same signing environment variables as upstream:
`KEY_PATH`, `KEY_ALIAS`, and `KEY_PASSWORD`.
## Verify an existing APK
```bash
scripts/verify-pixel10-apk.sh path/to/app-pixel10-release.apk
```
The verifier requires `unzip` and GNU `readelf` (usually supplied by the
`binutils` package).
## Keeping the fork current
The upstream remote should point at `https://github.com/FaFre/WebLibre.git`.
Rebase the `pixel10` branch on upstream `main`, run the full test suite, build
the APK, and do a real-device smoke test before publishing an update.
+6
View File
@@ -4,6 +4,12 @@
# WebLibre
> [!NOTE]
> This repository contains **MrbWebLibre**, a Google Pixel 10-focused fork of
> WebLibre. See [the Pixel 10 build profile](PIXEL_10.md) for the ARM64-only
> flavor, 16 KB native-library verification, build instructions, and the
> differences from upstream.
<p align="center"><strong>A privacy-focused Android browser with powerful browsing separation, local-first tools, and deep customization.</strong></p>
<p align="center">
+13
View File
@@ -73,6 +73,19 @@ android {
versionNameSuffix "-alpha"
manifestPlaceholders = [appName: "WebLibre Alpha (Legacy)", enableImpeller: "false"]
}
// Pixel 10 devices are 64-bit only and ship a 120 Hz display. Keep a
// separate, side-by-side installable identity for the optimized fork,
// retain Impeller, and prevent transitive dependencies from packaging
// unused 32-bit native libraries.
pixel10 {
dimension "track"
applicationId "cc.mrblake.mrbweblibre"
versionNameSuffix "-pixel10"
manifestPlaceholders = [appName: "MrbWebLibre", enableImpeller: "true"]
ndk {
abiFilters "arm64-v8a"
}
}
}
sourceSets {
@@ -110,6 +110,7 @@ AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) {
key: _toNativeRule(value),
},
marketplaceFallbackEnabled: settings.appLinkMarketplaceFallback,
authExceptionsEnabled: settings.appLinkAuthExceptionsEnabled,
protectGeneralContext: protection.protectGeneralContext,
protectedContextIds: protection.protectedContextIds.toList(),
strictContextIds: protection.strictContextIds.toList(),
@@ -120,7 +120,7 @@ final class AppLinkPolicySnapshotProvider
}
String _$appLinkPolicySnapshotHash() =>
r'6fe2dca118d7162561fc7f6280d1a0411d50972a';
r'4d456a3cbf091e95ac35d913e8fa20d5f25978ab';
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
/// native profile-scoped store (§2.8), the sole policy source consulted by the
@@ -17,6 +17,7 @@
* 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/foundation.dart' show immutable;
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
@@ -42,6 +43,28 @@ class _AppLinkEventsReceiver extends GeckoAppLinkEvents {
}
}
/// A pending prompt paired with the absolute instant it lapses.
///
/// [AppLinkPromptRequest.expiresInMs] is a *snapshot* taken when native answered
/// the query — it does not tick down. Comparing that raw field to zero on a later
/// build would treat a long-lapsed request as live (switch tabs for three minutes
/// and come back, and a 90 s banner still reports 90 s), so the remaining time is
/// anchored to a wall-clock deadline the moment the answer arrives.
@immutable
class PendingAppLinkPrompt {
final AppLinkPromptRequest request;
final DateTime expiresAt;
const PendingAppLinkPrompt({required this.request, required this.expiresAt});
int get requestId => request.requestId;
String get tabId => request.tabId;
bool get isModal => request.isModal;
/// Whether native would still accept a resolution for this request.
bool isLive(DateTime now) => expiresAt.isAfter(now);
}
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
/// event handler, queries the native pending store on attach/resume/event, and
/// exposes resolution (including the remember-then-resolve flow). The presented
@@ -52,7 +75,7 @@ class AppLinksCoordinator extends _$AppLinksCoordinator {
final _service = GeckoAppLinksService();
@override
List<AppLinkPromptRequest> build() {
List<PendingAppLinkPrompt> build() {
final receiver = _AppLinkEventsReceiver((owner) {
if (owner == AppLinkPromptOwner.flutterBrowser) {
// ignore: discarded_futures
@@ -76,11 +99,22 @@ class AppLinksCoordinator extends _$AppLinksCoordinator {
final prompts = await _service.getPendingAppLinkPrompts(
AppLinkPromptOwner.flutterBrowser,
);
// Anchor the reported TTL immediately: every millisecond spent between the
// native read and here has already been consumed.
final queriedAt = DateTime.now();
logger.i(
'app-link refresh -> ${prompts.length} prompt(s): '
'${prompts.map((p) => '${p.requestId}@${p.tabId}(${p.isModal ? 'modal' : 'banner'})').toList()}',
);
state = prompts;
state = [
for (final prompt in prompts)
PendingAppLinkPrompt(
request: prompt,
expiresAt: queriedAt.add(
Duration(milliseconds: prompt.expiresInMs),
),
),
];
} catch (error, stackTrace) {
logger.w(
'Failed to query pending app-link prompts',
@@ -23,7 +23,7 @@ final appLinksCoordinatorProvider = AppLinksCoordinatorProvider._();
/// list is authoritative from the query and deduped by `requestId` — the event
/// is only a nudge to re-query.
final class AppLinksCoordinatorProvider
extends $NotifierProvider<AppLinksCoordinator, List<AppLinkPromptRequest>> {
extends $NotifierProvider<AppLinksCoordinator, List<PendingAppLinkPrompt>> {
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
/// event handler, queries the native pending store on attach/resume/event, and
/// exposes resolution (including the remember-then-resolve flow). The presented
@@ -48,16 +48,16 @@ final class AppLinksCoordinatorProvider
AppLinksCoordinator create() => AppLinksCoordinator();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<AppLinkPromptRequest> value) {
Override overrideWithValue(List<PendingAppLinkPrompt> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<AppLinkPromptRequest>>(value),
providerOverride: $SyncValueProvider<List<PendingAppLinkPrompt>>(value),
);
}
}
String _$appLinksCoordinatorHash() =>
r'183fc7ac1264a63c24b1d10f4a22cbfbf6046da7';
r'3dc91825b659add92d2f651306c30b9aec4e557b';
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
/// event handler, queries the native pending store on attach/resume/event, and
@@ -66,22 +66,22 @@ String _$appLinksCoordinatorHash() =>
/// is only a nudge to re-query.
abstract class _$AppLinksCoordinator
extends $Notifier<List<AppLinkPromptRequest>> {
List<AppLinkPromptRequest> build();
extends $Notifier<List<PendingAppLinkPrompt>> {
List<PendingAppLinkPrompt> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref
as $Ref<List<AppLinkPromptRequest>, List<AppLinkPromptRequest>>;
as $Ref<List<PendingAppLinkPrompt>, List<PendingAppLinkPrompt>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
List<AppLinkPromptRequest>,
List<AppLinkPromptRequest>
List<PendingAppLinkPrompt>,
List<PendingAppLinkPrompt>
>,
List<AppLinkPromptRequest>,
List<PendingAppLinkPrompt>,
Object?,
Object?
>;
@@ -54,30 +54,79 @@ class AppLinkPromptHost extends HookConsumerWidget {
}
});
final activeRequests = prompts
.where((request) => request.tabId == activeTabId)
// Native expiry is lazy — it only runs when the store is queried or consumed — and nothing
// pushes an expiry event. A prompt that outlives its deadline would keep rendering live
// buttons whose resolution is already a no-op, so drop anything already past due rather than
// offering an action that cannot happen. The deadline is absolute
// ([PendingAppLinkPrompt.expiresAt]); the raw `expiresInMs` is only valid at query time.
final now = DateTime.now();
final livePrompts = prompts.where((prompt) => prompt.isLive(now)).toList();
final activeRequests = livePrompts
.where((prompt) => prompt.tabId == activeTabId)
.toList();
// ...and re-query when the soonest deadline passes, so a prompt retires itself on time
// instead of waiting for the next event or resume. Keyed on the absolute deadline so a
// rebuild (tab switch, unrelated state change) never re-arms a full-length timer from a
// stale TTL. The lower clamp matters: a deadline already reached would otherwise reschedule
// instantly and spin.
final soonestDeadline = livePrompts.isEmpty
? null
: livePrompts
.map((prompt) => prompt.expiresAt)
.reduce((a, b) => a.isBefore(b) ? a : b);
useEffect(() {
if (soonestDeadline == null) return null;
final delay = soonestDeadline.difference(DateTime.now());
final timer = Timer(
Duration(milliseconds: delay.inMilliseconds.clamp(250, 10 * 60 * 1000)),
() =>
unawaited(ref.read(appLinksCoordinatorProvider.notifier).refresh()),
);
return timer.cancel;
}, [soonestDeadline]);
final modalRequest = activeRequests
.where((request) => request.isModal)
.where((prompt) => prompt.isModal)
.lastOrNull;
// At most one banner per tab; a newer banner-class request simply becomes the
// one the UI renders.
final bannerRequest = activeRequests
.where((request) => !request.isModal)
.where((prompt) => !prompt.isModal)
.lastOrNull;
// A modal is shown at most once per requestId. Rotation/teardown is not a
// dismissal — the request stays pending and is re-presented on the next query
// (a subsequent build re-runs this effect with the still-present id).
final shownModalId = useRef<int?>(null);
// The route the modal lives on, so it can be retired without popping whatever
// else happens to sit on top of it.
final shownModalRoute = useRef<ModalRoute<void>?>(null);
// Ids we closed ourselves. Their pop must not be mistaken for a user
// dismissal, which would consume a request that is merely off-screen.
final retiredModalIds = useRef<Set<int>>(<int>{});
// Take down a dialog nobody can act on any more: its request expired, was
// invalidated (tab closed), or belongs to a tab the user has left. Without
// this the route just stays on screen with buttons that resolve to `stale`.
// Mirrors the native `NativeAppLinkPromptFeature.dismissStaleDialog`.
void retireShownModal() {
final shownId = shownModalId.value;
final route = shownModalRoute.value;
shownModalId.value = null;
shownModalRoute.value = null;
if (shownId == null || route == null || !route.isActive) return;
retiredModalIds.value.add(shownId);
route.navigator?.removeRoute(route);
}
useEffect(() {
final request = modalRequest;
if (request == null) {
shownModalId.value = null;
return null;
final shownId = shownModalId.value;
if (shownId != null && shownId != request?.requestId) {
retireShownModal();
}
if (shownModalId.value == request.requestId) {
if (request == null || shownModalId.value == request.requestId) {
return null;
}
shownModalId.value = request.requestId;
@@ -86,8 +135,17 @@ class AppLinkPromptHost extends HookConsumerWidget {
unawaited(
showDialog<void>(
context: context,
builder: (_) => AppLinkPromptDialog(request: request),
builder: (dialogContext) {
shownModalRoute.value = ModalRoute.of<void>(dialogContext);
return AppLinkPromptDialog(request: request.request);
},
).then((_) {
if (retiredModalIds.value.remove(request.requestId)) {
// We closed it, not the user. The request is either already gone or
// still pending for a tab that is no longer in front — either way it
// must not be consumed here.
return;
}
// Catch-all for a passive dismissal (Android back / touch-outside):
// the dialog buttons resolve the request themselves, but a barrier
// dismiss closes it without resolving, leaving the native request
@@ -111,7 +169,7 @@ class AppLinkPromptHost extends HookConsumerWidget {
return AppLinkOpenBanner(
key: ValueKey(bannerRequest.requestId),
request: bannerRequest,
request: bannerRequest.request,
);
}
}
@@ -39,6 +39,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.da
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/controllers/home_target_controller.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
@@ -446,35 +447,85 @@ class TabRepository extends _$TabRepository {
String tabId, {
String? containerId,
bool skipContainerCheck = true,
}) async {
final previousTabId = await _adjacentVisibleTabByOrder(
tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
selectPrevious: true,
);
if (ref.mounted && previousTabId != null) {
return selectTab(previousTabId);
}
return false;
}
}) => _selectAdjacentTab(
tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
selectPrevious: true,
);
Future<bool> selectNextTab(
String tabId, {
String? containerId,
bool skipContainerCheck = true,
}) => _selectAdjacentTab(
tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
selectPrevious: false,
);
/// Moves the selection one step through the tab sequence.
///
/// Calls that cross containers ([skipContainerCheck] with no explicit
/// [containerId]) — the tab bar swipe and the next/previous tab gestures —
/// step through the *rendered* order
/// ([sequentialTabNavigationOrderProvider]) so navigation matches the tabs the
/// user sees, including the tray's sort type, grouping, filters and
/// pinned-first handling. That order spans every populated container, so this
/// keeps walking past a container boundary exactly like the storage-order walk
/// did. It is authoritative once it exists, and every outcome stays inside it:
///
/// - current tab in the order: step one row, stopping at either end;
/// - current tab outside it — hidden by the active filter, or folded into a
/// collapsed group — enter the visible sequence from the end the step comes
/// from, rather than jumping to a tab the filter excludes;
/// - nothing visible at all: do nothing.
///
/// The storage-order path is left for calls that scope navigation to a single
/// container (which the cross-container order cannot answer) and for the brief
/// window before the tree data has loaded.
Future<bool> _selectAdjacentTab(
String tabId, {
required String? containerId,
required bool skipContainerCheck,
required bool selectPrevious,
}) async {
final previousTabId = await _adjacentVisibleTabByOrder(
if (containerId == null && skipContainerCheck) {
final visibleOrder = ref.read(sequentialTabNavigationOrderProvider).value;
if (visibleOrder != null) {
if (visibleOrder.isEmpty) {
return false;
}
final index = visibleOrder.indexOf(tabId);
if (index < 0) {
return selectTab(
selectPrevious ? visibleOrder.last : visibleOrder.first,
);
}
final targetIndex = selectPrevious ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= visibleOrder.length) {
return false;
}
return selectTab(visibleOrder[targetIndex]);
}
}
final adjacentTabId = await _adjacentVisibleTabByOrder(
tabId,
containerId: containerId,
skipContainerCheck: skipContainerCheck,
selectPrevious: false,
selectPrevious: selectPrevious,
);
if (ref.mounted && previousTabId != null) {
return selectTab(previousTabId);
if (ref.mounted && adjacentTabId != null) {
return selectTab(adjacentTabId);
}
return false;
@@ -546,12 +597,14 @@ class TabRepository extends _$TabRepository {
required bool skipContainerCheck,
required bool selectPrevious,
}) {
// "Previous/next" here is interpreted relative to the *tab bar*
// direction, even when the user triggered the navigation from the tab
// tray (which has its own `tabListDirection`). If the two settings
// disagree, "next tab" while looking at the tray flows by tab-bar
// direction. Treat as intentional — keyboard / gesture navigation is
// anchored to the bar's mental model.
// Storage-order walk: neighbours by `order_key` only, so it sees neither
// the tray's sort and filters nor its grouping. User-facing sequential
// navigation goes through the rendered order in [_selectAdjacentTab] and
// reaches this only as a fallback; what remains here is picking a tab
// after a close and container-scoped stepping.
//
// "Previous/next" is interpreted relative to the *tab bar* direction,
// which is the only direction this path has to go by.
final newestFirst =
ref.read(generalSettingsWithDefaultsProvider).tabBarDirection ==
TabDirection.newestFirst;
@@ -946,6 +999,18 @@ class TabRepository extends _$TabRepository {
@override
void build() {
// Hold an active listener on the rendered navigation order: swipes and
// gestures read it synchronously, and Riverpod pauses a provider nothing is
// listening to — a one-off read would neither keep it current nor guarantee
// it has data when the first swipe arrives. Listened rather than watched
// because it changes with every tab update, which must not rebuild this
// repository; the callback is intentionally empty.
ref.listen(
sequentialTabNavigationOrderProvider,
(_, _) {},
fireImmediately: true,
);
final eventSerivce = ref.watch(eventServiceProvider);
final tabContentService = ref.watch(tabContentServiceProvider);
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'c94ccf85da3fee36ebac3521f51f265a5f8d34d3';
String _$tabRepositoryHash() => r'5824be32664bac24623cf8863f68b7afa254d025';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -1211,6 +1211,116 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
return EquatableValue(result);
}
/// The final row order the tab tray renders, i.e.
/// [groupedTabListItemsProvider] plus the flat-mode post-processing: with
/// hierarchy display turned off there are no groups to keep together, so
/// pinned tabs move ahead of unpinned ones across the whole list.
///
/// Shared by the list view, the grid view and sequential tab navigation so all
/// three agree on what "the tab after this one" means.
@Riverpod()
EquatableValue<List<TabListItemEntity>> visibleTabListItems(
Ref ref, {
required String? containerId,
}) {
final groupedItems = ref
.watch(groupedTabListItemsProvider(containerId: containerId))
.value;
final filterOptions = ref.watch(tabViewFilterControllerProvider);
if (filterOptions.showHierarchicalTabs || !filterOptions.sortPinnedFirst) {
return EquatableValue(groupedItems);
}
final pinnedTabIds = ref.watch(
watchPinnedTabIdsProvider.select(
(value) => value.value ?? const <String>{},
),
);
return EquatableValue([
...groupedItems.where((item) => pinnedTabIds.contains(item.tabId)),
...groupedItems.where((item) => !pinnedTabIds.contains(item.tabId)),
]);
}
/// Flat tab id order used by sequential tab navigation: the tab bar swipe
/// action and the next/previous tab gestures.
///
/// Navigation follows the rendered order instead of the raw storage
/// `order_key`, so it carries the active sort type, tree grouping, collapsed
/// groups, pinned-first handling and the tab-type/date filter — stepping to the
/// tab the user sees next to the current one rather than to an unrelated
/// `order_key` neighbour.
///
/// It spans **all** containers, keeping the boundary-crossing reach the
/// storage-order walk had: each container contributes the rows its tray would
/// render, and the containers follow one another in the order the quick tab
/// switcher lays them out — the unassigned bucket first, then containers by
/// pinned/`order_key`. Stepping off the end of one container therefore
/// continues into the next, and selecting that tab moves the selected container
/// along with it. Named containers holding no tabs are skipped so their tree
/// query never runs.
///
/// "Previous" is a step towards the top of that order and "next" a step
/// towards its end, so direction follows `tabListDirection` (baked into the
/// order) rather than `tabBarDirection`. The two only disagree when the user
/// sets them apart, and the rendered order is the one the sequence is built
/// from.
///
/// The tray's own search results are deliberately not part of this: the swipe
/// and the gestures are only reachable with the tray closed.
///
/// `null` means the underlying tree data has not arrived yet — the only state
/// in which the caller may fall back to storage order. An empty list is a real
/// answer ("the filter leaves nothing to move to") and must not be mistaken for
/// a missing one, or the filter the user set would be bypassed.
///
/// Kept alive and actively listened to by [TabRepository]: it is consumed by a
/// synchronous `ref.read` at the moment of the swipe/gesture, from outside the
/// widget tree. Without a listener Riverpod pauses the chain when nothing is on
/// screen watching it, so the order could go stale — or be created empty on the
/// read, with its tree stream still loading, and silently drop navigation back
/// to storage order. The selected container's chain is alive anyway whenever the
/// quick tab switcher or the tray is on screen; the price of crossing container
/// boundaries is that the other populated containers' tree queries are kept
/// alive too.
@Riverpod(keepAlive: true)
EquatableValue<List<String>?> sequentialTabNavigationOrder(Ref ref) {
final containers = ref.watch(
watchContainersWithCountProvider.select((value) => value.value),
);
if (containers == null) {
return EquatableValue(null);
}
final containerIds = <String?>[
null,
for (final container in containers)
if ((container.tabCount ?? 0) > 0) container.id,
];
final order = <String>[];
for (final containerId in containerIds) {
final hasTreeData = ref.watch(
watchTabsWithRootAndDepthProvider(
containerId,
).select((value) => value.hasValue),
);
if (!hasTreeData) {
return EquatableValue(null);
}
final visibleItems = ref
.watch(visibleTabListItemsProvider(containerId: containerId))
.value;
order.addAll(visibleItems.map((item) => item.tabId));
}
return EquatableValue(order);
}
String _nearestVisibleParentId(
TabsWithRootAndDepthResult row,
String rootId,
@@ -1253,3 +1253,307 @@ final class GroupedTabListItemsFamily extends $Family
@override
String toString() => r'groupedTabListItemsProvider';
}
/// The final row order the tab tray renders, i.e.
/// [groupedTabListItemsProvider] plus the flat-mode post-processing: with
/// hierarchy display turned off there are no groups to keep together, so
/// pinned tabs move ahead of unpinned ones across the whole list.
///
/// Shared by the list view, the grid view and sequential tab navigation so all
/// three agree on what "the tab after this one" means.
@ProviderFor(visibleTabListItems)
final visibleTabListItemsProvider = VisibleTabListItemsFamily._();
/// The final row order the tab tray renders, i.e.
/// [groupedTabListItemsProvider] plus the flat-mode post-processing: with
/// hierarchy display turned off there are no groups to keep together, so
/// pinned tabs move ahead of unpinned ones across the whole list.
///
/// Shared by the list view, the grid view and sequential tab navigation so all
/// three agree on what "the tab after this one" means.
final class VisibleTabListItemsProvider
extends
$FunctionalProvider<
EquatableValue<List<TabListItemEntity>>,
EquatableValue<List<TabListItemEntity>>,
EquatableValue<List<TabListItemEntity>>
>
with $Provider<EquatableValue<List<TabListItemEntity>>> {
/// The final row order the tab tray renders, i.e.
/// [groupedTabListItemsProvider] plus the flat-mode post-processing: with
/// hierarchy display turned off there are no groups to keep together, so
/// pinned tabs move ahead of unpinned ones across the whole list.
///
/// Shared by the list view, the grid view and sequential tab navigation so all
/// three agree on what "the tab after this one" means.
VisibleTabListItemsProvider._({
required VisibleTabListItemsFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'visibleTabListItemsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$visibleTabListItemsHash();
@override
String toString() {
return r'visibleTabListItemsProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<EquatableValue<List<TabListItemEntity>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<List<TabListItemEntity>> create(Ref ref) {
final argument = this.argument as String?;
return visibleTabListItems(ref, containerId: argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<List<TabListItemEntity>> value) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<EquatableValue<List<TabListItemEntity>>>(value),
);
}
@override
bool operator ==(Object other) {
return other is VisibleTabListItemsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$visibleTabListItemsHash() =>
r'5249a3e7e1f0b24e408987956856926d0958b0db';
/// The final row order the tab tray renders, i.e.
/// [groupedTabListItemsProvider] plus the flat-mode post-processing: with
/// hierarchy display turned off there are no groups to keep together, so
/// pinned tabs move ahead of unpinned ones across the whole list.
///
/// Shared by the list view, the grid view and sequential tab navigation so all
/// three agree on what "the tab after this one" means.
final class VisibleTabListItemsFamily extends $Family
with
$FunctionalFamilyOverride<
EquatableValue<List<TabListItemEntity>>,
String?
> {
VisibleTabListItemsFamily._()
: super(
retry: null,
name: r'visibleTabListItemsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// The final row order the tab tray renders, i.e.
/// [groupedTabListItemsProvider] plus the flat-mode post-processing: with
/// hierarchy display turned off there are no groups to keep together, so
/// pinned tabs move ahead of unpinned ones across the whole list.
///
/// Shared by the list view, the grid view and sequential tab navigation so all
/// three agree on what "the tab after this one" means.
VisibleTabListItemsProvider call({required String? containerId}) =>
VisibleTabListItemsProvider._(argument: containerId, from: this);
@override
String toString() => r'visibleTabListItemsProvider';
}
/// Flat tab id order used by sequential tab navigation: the tab bar swipe
/// action and the next/previous tab gestures.
///
/// Navigation follows the rendered order instead of the raw storage
/// `order_key`, so it carries the active sort type, tree grouping, collapsed
/// groups, pinned-first handling and the tab-type/date filter — stepping to the
/// tab the user sees next to the current one rather than to an unrelated
/// `order_key` neighbour.
///
/// It spans **all** containers, keeping the boundary-crossing reach the
/// storage-order walk had: each container contributes the rows its tray would
/// render, and the containers follow one another in the order the quick tab
/// switcher lays them out — the unassigned bucket first, then containers by
/// pinned/`order_key`. Stepping off the end of one container therefore
/// continues into the next, and selecting that tab moves the selected container
/// along with it. Named containers holding no tabs are skipped so their tree
/// query never runs.
///
/// "Previous" is a step towards the top of that order and "next" a step
/// towards its end, so direction follows `tabListDirection` (baked into the
/// order) rather than `tabBarDirection`. The two only disagree when the user
/// sets them apart, and the rendered order is the one the sequence is built
/// from.
///
/// The tray's own search results are deliberately not part of this: the swipe
/// and the gestures are only reachable with the tray closed.
///
/// `null` means the underlying tree data has not arrived yet — the only state
/// in which the caller may fall back to storage order. An empty list is a real
/// answer ("the filter leaves nothing to move to") and must not be mistaken for
/// a missing one, or the filter the user set would be bypassed.
///
/// Kept alive and actively listened to by [TabRepository]: it is consumed by a
/// synchronous `ref.read` at the moment of the swipe/gesture, from outside the
/// widget tree. Without a listener Riverpod pauses the chain when nothing is on
/// screen watching it, so the order could go stale — or be created empty on the
/// read, with its tree stream still loading, and silently drop navigation back
/// to storage order. The selected container's chain is alive anyway whenever the
/// quick tab switcher or the tray is on screen; the price of crossing container
/// boundaries is that the other populated containers' tree queries are kept
/// alive too.
@ProviderFor(sequentialTabNavigationOrder)
final sequentialTabNavigationOrderProvider =
SequentialTabNavigationOrderProvider._();
/// Flat tab id order used by sequential tab navigation: the tab bar swipe
/// action and the next/previous tab gestures.
///
/// Navigation follows the rendered order instead of the raw storage
/// `order_key`, so it carries the active sort type, tree grouping, collapsed
/// groups, pinned-first handling and the tab-type/date filter — stepping to the
/// tab the user sees next to the current one rather than to an unrelated
/// `order_key` neighbour.
///
/// It spans **all** containers, keeping the boundary-crossing reach the
/// storage-order walk had: each container contributes the rows its tray would
/// render, and the containers follow one another in the order the quick tab
/// switcher lays them out — the unassigned bucket first, then containers by
/// pinned/`order_key`. Stepping off the end of one container therefore
/// continues into the next, and selecting that tab moves the selected container
/// along with it. Named containers holding no tabs are skipped so their tree
/// query never runs.
///
/// "Previous" is a step towards the top of that order and "next" a step
/// towards its end, so direction follows `tabListDirection` (baked into the
/// order) rather than `tabBarDirection`. The two only disagree when the user
/// sets them apart, and the rendered order is the one the sequence is built
/// from.
///
/// The tray's own search results are deliberately not part of this: the swipe
/// and the gestures are only reachable with the tray closed.
///
/// `null` means the underlying tree data has not arrived yet — the only state
/// in which the caller may fall back to storage order. An empty list is a real
/// answer ("the filter leaves nothing to move to") and must not be mistaken for
/// a missing one, or the filter the user set would be bypassed.
///
/// Kept alive and actively listened to by [TabRepository]: it is consumed by a
/// synchronous `ref.read` at the moment of the swipe/gesture, from outside the
/// widget tree. Without a listener Riverpod pauses the chain when nothing is on
/// screen watching it, so the order could go stale — or be created empty on the
/// read, with its tree stream still loading, and silently drop navigation back
/// to storage order. The selected container's chain is alive anyway whenever the
/// quick tab switcher or the tray is on screen; the price of crossing container
/// boundaries is that the other populated containers' tree queries are kept
/// alive too.
final class SequentialTabNavigationOrderProvider
extends
$FunctionalProvider<
EquatableValue<List<String>?>,
EquatableValue<List<String>?>,
EquatableValue<List<String>?>
>
with $Provider<EquatableValue<List<String>?>> {
/// Flat tab id order used by sequential tab navigation: the tab bar swipe
/// action and the next/previous tab gestures.
///
/// Navigation follows the rendered order instead of the raw storage
/// `order_key`, so it carries the active sort type, tree grouping, collapsed
/// groups, pinned-first handling and the tab-type/date filter — stepping to the
/// tab the user sees next to the current one rather than to an unrelated
/// `order_key` neighbour.
///
/// It spans **all** containers, keeping the boundary-crossing reach the
/// storage-order walk had: each container contributes the rows its tray would
/// render, and the containers follow one another in the order the quick tab
/// switcher lays them out — the unassigned bucket first, then containers by
/// pinned/`order_key`. Stepping off the end of one container therefore
/// continues into the next, and selecting that tab moves the selected container
/// along with it. Named containers holding no tabs are skipped so their tree
/// query never runs.
///
/// "Previous" is a step towards the top of that order and "next" a step
/// towards its end, so direction follows `tabListDirection` (baked into the
/// order) rather than `tabBarDirection`. The two only disagree when the user
/// sets them apart, and the rendered order is the one the sequence is built
/// from.
///
/// The tray's own search results are deliberately not part of this: the swipe
/// and the gestures are only reachable with the tray closed.
///
/// `null` means the underlying tree data has not arrived yet — the only state
/// in which the caller may fall back to storage order. An empty list is a real
/// answer ("the filter leaves nothing to move to") and must not be mistaken for
/// a missing one, or the filter the user set would be bypassed.
///
/// Kept alive and actively listened to by [TabRepository]: it is consumed by a
/// synchronous `ref.read` at the moment of the swipe/gesture, from outside the
/// widget tree. Without a listener Riverpod pauses the chain when nothing is on
/// screen watching it, so the order could go stale — or be created empty on the
/// read, with its tree stream still loading, and silently drop navigation back
/// to storage order. The selected container's chain is alive anyway whenever the
/// quick tab switcher or the tray is on screen; the price of crossing container
/// boundaries is that the other populated containers' tree queries are kept
/// alive too.
SequentialTabNavigationOrderProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sequentialTabNavigationOrderProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sequentialTabNavigationOrderHash();
@$internal
@override
$ProviderElement<EquatableValue<List<String>?>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<List<String>?> create(Ref ref) {
return sequentialTabNavigationOrder(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<List<String>?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EquatableValue<List<String>?>>(
value,
),
);
}
}
String _$sequentialTabNavigationOrderHash() =>
r'cd8f2337012473028084f2efb1bff540e80abdf4';
@@ -380,7 +380,10 @@ class BrowserTabBar extends HookConsumerWidget {
final dragStartPosition = useRef(Offset.zero);
// Swipe along the primary switch axis moves between tabs. [delta] is
// (dragStart - dragEnd) along that axis; its sign chooses prev/next.
// (dragStart - dragEnd) along that axis, so a right-to-left (or upward)
// swipe is positive and moves *up* the visible tab order, a rightward (or
// downward) swipe moves down it — the swipe drags the list under the
// finger.
Future<void> switchTabsBy(double delta) async {
final selectedTab = ref.read(selectedTabProvider);
final setting = await ref
@@ -395,7 +398,7 @@ class BrowserTabBar extends HookConsumerWidget {
.read(tabRepositoryProvider.notifier)
.selectPreviouslyOpenedTab(selectedTab);
case TabBarSwipeAction.navigateOrderedTabs:
if (delta < 0) {
if (delta > 0) {
await ref
.read(tabRepositoryProvider.notifier)
.selectPreviousTab(selectedTab);
@@ -281,21 +281,15 @@ class _BrowserViewState extends ConsumerState<BrowserView>
child: Visibility(
visible: isGeckoViewVisible,
child: GeckoView(
preInitializationStep: () async {
await ref
.read(eventServiceProvider)
.viewReadyStateEvents
.firstWhere((state) => state == true)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
logger.e(
'Browser fragement not reported ready, trying to intitialize anyways',
);
return true;
},
);
},
// Reports when the native container enters the window, which
// under the [Offstage] above is not until the home surface is
// dismissed. [GeckoView] attaches the browser fragment on every
// such report, so an engine kept alive but unpainted for the
// whole of startup still gets its fragment the moment it is
// shown. See https://github.com/FaFre/WebLibre/issues/557.
viewReadyEvents: ref
.read(eventServiceProvider)
.viewReadyStateEvents,
postInitializationStep: () async {
await widget.postInitializationStep?.call();
@@ -222,11 +222,11 @@ class _TabGridView extends HookConsumerWidget {
),
];
} else {
final grouped = ref.watch(
groupedTabListItemsProvider(containerId: containerId),
final visibleItems = ref.watch(
visibleTabListItemsProvider(containerId: containerId),
);
primaryRows = [
for (final item in grouped.value)
for (final item in visibleItems.value)
switch (item) {
TabListStandaloneItem(:final tabId) => TabViewItem.standalone(
tabId: tabId,
@@ -241,15 +241,6 @@ class _TabGridView extends HookConsumerWidget {
: TabViewItem.standalone(tabId: c.tabId),
},
];
if (!showHierarchicalTabs && filterOptions.sortPinnedFirst) {
final pinned = primaryRows
.where((r) => pinnedTabIds.contains(r.tabId))
.toList();
final unpinned = primaryRows
.where((r) => !pinnedTabIds.contains(r.tabId))
.toList();
primaryRows = [...pinned, ...unpinned];
}
}
final tabSuggestionsEnabled = ref.watch(
@@ -283,11 +283,11 @@ class _TabListView extends HookConsumerWidget {
),
];
} else {
final grouped = ref.watch(
groupedTabListItemsProvider(containerId: containerId),
final visibleItems = ref.watch(
visibleTabListItemsProvider(containerId: containerId),
);
primaryRows = [
for (final item in grouped.value)
for (final item in visibleItems.value)
switch (item) {
TabListStandaloneItem(:final tabId) => TabViewItem.standalone(
tabId: tabId,
@@ -302,15 +302,6 @@ class _TabListView extends HookConsumerWidget {
: TabViewItem.standalone(tabId: c.tabId),
},
];
if (!showHierarchicalTabs && filterOptions.sortPinnedFirst) {
final pinned = primaryRows
.where((r) => pinnedTabIds.contains(r.tabId))
.toList();
final unpinned = primaryRows
.where((r) => !pinnedTabIds.contains(r.tabId))
.toList();
primaryRows = [...pinned, ...unpinned];
}
}
final tabSuggestionsEnabled = ref.watch(
@@ -34,7 +34,8 @@ class EngineSuggestions extends _$EngineSuggestions {
/// autocomplete (history/top-domains); when that yields nothing, falls back
/// to a popular-domain prefix match from the bundled Tranco-derived
/// `sites.db` so typing "git" still completes to "github.com" without any
/// local history.
/// local history. The fallback can be turned off via the
/// `popularSitesAutocompleteEnabled` setting.
Future<String?> getAutocompleteSuggestion(String query) async {
final engineResult = await ref
.read(engineSuggestionsServiceProvider)
@@ -47,6 +48,16 @@ class EngineSuggestions extends _$EngineSuggestions {
if (!ref.mounted) return null;
final popularSitesEnabled = ref.read(
generalSettingsWithDefaultsProvider.select(
(s) => s.popularSitesAutocompleteEnabled,
),
);
if (!popularSitesEnabled) {
return null;
}
final popularSites = await ref
.read(popularSitesRepositoryProvider.notifier)
.searchByPrefix(query, limit: 1);
@@ -33,7 +33,7 @@ final class EngineSuggestionsProvider
EngineSuggestions create() => EngineSuggestions();
}
String _$engineSuggestionsHash() => r'4918e80a1e7dfb59fe67d0895e62a39f2704851f';
String _$engineSuggestionsHash() => r'b2bc587d8df12b5614e2c00494a0d666e2c30746';
abstract class _$EngineSuggestions
extends $StreamNotifier<List<GeckoSuggestion>> {
@@ -817,6 +817,11 @@ class _AppLinksModeSection extends HookConsumerWidget {
(s) => s.appLinkMarketplaceFallback,
),
);
final authExceptionsEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.appLinkAuthExceptionsEnabled,
),
);
final rules = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.appLinkRules),
);
@@ -887,6 +892,23 @@ class _AppLinksModeSection extends HookConsumerWidget {
);
},
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Allow login app callbacks'),
subtitle: const Text(
'Let apps that opened a Custom Tab receive their login callback, '
'even when links are set to never open in apps',
),
value: authExceptionsEnabled,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(current) =>
current.copyWith.appLinkAuthExceptionsEnabled(value),
);
},
),
_AppLinkRulesSubsection(rules: rules),
],
),
@@ -90,6 +90,12 @@ const List<SettingsSectionDefinition> searchSettingsSections = [
keywords: ['submit', 'keyboard', 'suggestions'],
child: _AcceptSuggestionOnSubmitTile(),
),
SettingsEntryDefinition(
title: 'Popular site suggestions',
subtitle: 'Complete typed text with well-known domains',
keywords: ['popular sites', 'domains', 'ghost text', 'autocomplete'],
child: _PopularSitesAutocompleteTile(),
),
],
),
SettingsSectionDefinition(
@@ -387,6 +393,36 @@ class _AcceptSuggestionOnSubmitTile extends HookConsumerWidget {
}
}
class _PopularSitesAutocompleteTile extends HookConsumerWidget {
const _PopularSitesAutocompleteTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final popularSitesAutocompleteEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.popularSitesAutocompleteEnabled,
),
);
return SwitchListTile.adaptive(
title: const Text('Popular site suggestions'),
subtitle: const Text(
'Complete typed text with well-known domains when your history has no match',
),
secondary: const Icon(MdiIcons.web),
value: popularSitesAutocompleteEnabled,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.popularSitesAutocompleteEnabled(value),
);
},
);
}
}
class _LocalIndexEnabledTile extends HookConsumerWidget {
const _LocalIndexEnabledTile();
@@ -252,6 +252,12 @@ class GeneralSettings with FastEquatable {
/// Defaults to false — the wrong default for a de-Googled browser.
final bool appLinkMarketplaceFallback;
/// Whether app-link "never" rules allow a same-caller Custom Tab / ActionView
/// login callback to return to the app that opened the browser. Defaults to
/// true to keep OAuth-style sign-in flows working while normal app links still
/// obey [appLinksMode].
final bool appLinkAuthExceptionsEnabled;
/// Whether the local search index (`history` table populated via tab→
/// history triggers) is active. When false, the SQL trigger guard returns
/// without writing; existing rows stay until the user clears them.
@@ -264,6 +270,11 @@ class GeneralSettings with FastEquatable {
/// accept and complete an inline search suggestion. Defaults to false.
final bool acceptSuggestionOnSubmit;
/// Whether the bundled Tranco-derived popular-domain list may supply the
/// omnibar's inline ghost-text completion when the engine's own autocomplete
/// (history/top domains) has no match. Defaults to true.
final bool popularSitesAutocompleteEnabled;
/// Whether dark mode should use pure-black ("OLED"/high-contrast) surfaces.
/// Only takes effect when the effective brightness is dark. Defaults to false.
final bool pureBlack;
@@ -354,9 +365,11 @@ class GeneralSettings with FastEquatable {
required this.appLinkRules,
required this.appLinkContextOverrides,
required this.appLinkMarketplaceFallback,
required this.appLinkAuthExceptionsEnabled,
required this.enableLocalSearchIndex,
required this.indexPrivateTabs,
required this.acceptSuggestionOnSubmit,
required this.popularSitesAutocompleteEnabled,
required this.pureBlack,
required this.globalDesktopMode,
required this.desktopModeSites,
@@ -430,9 +443,11 @@ class GeneralSettings with FastEquatable {
Map<String, PersistedAppLinkRule>? appLinkRules,
Map<String, ContextAppLinkPolicy>? appLinkContextOverrides,
bool? appLinkMarketplaceFallback,
bool? appLinkAuthExceptionsEnabled,
bool? enableLocalSearchIndex,
bool? indexPrivateTabs,
bool? acceptSuggestionOnSubmit,
bool? popularSitesAutocompleteEnabled,
bool? pureBlack,
bool? globalDesktopMode,
List<String>? desktopModeSites,
@@ -517,9 +532,12 @@ class GeneralSettings with FastEquatable {
appLinkRules = appLinkRules ?? const {},
appLinkContextOverrides = appLinkContextOverrides ?? const {},
appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false,
appLinkAuthExceptionsEnabled = appLinkAuthExceptionsEnabled ?? true,
enableLocalSearchIndex = enableLocalSearchIndex ?? true,
indexPrivateTabs = indexPrivateTabs ?? false,
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
popularSitesAutocompleteEnabled =
popularSitesAutocompleteEnabled ?? true,
pureBlack = pureBlack ?? false,
globalDesktopMode = globalDesktopMode ?? false,
desktopModeSites = desktopModeSites ?? const [],
@@ -684,9 +702,11 @@ class GeneralSettings with FastEquatable {
appLinkRules,
appLinkContextOverrides,
appLinkMarketplaceFallback,
appLinkAuthExceptionsEnabled,
enableLocalSearchIndex,
indexPrivateTabs,
acceptSuggestionOnSubmit,
popularSitesAutocompleteEnabled,
pureBlack,
globalDesktopMode,
desktopModeSites,
@@ -161,12 +161,20 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback);
GeneralSettings appLinkAuthExceptionsEnabled(
bool appLinkAuthExceptionsEnabled,
);
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit);
GeneralSettings popularSitesAutocompleteEnabled(
bool popularSitesAutocompleteEnabled,
);
GeneralSettings pureBlack(bool pureBlack);
GeneralSettings globalDesktopMode(bool globalDesktopMode);
@@ -249,9 +257,11 @@ abstract class _$GeneralSettingsCWProxy {
Map<String, PersistedAppLinkRule> appLinkRules,
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
bool appLinkMarketplaceFallback,
bool appLinkAuthExceptionsEnabled,
bool enableLocalSearchIndex,
bool indexPrivateTabs,
bool acceptSuggestionOnSubmit,
bool popularSitesAutocompleteEnabled,
bool pureBlack,
bool globalDesktopMode,
List<String> desktopModeSites,
@@ -551,6 +561,11 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) =>
call(appLinkMarketplaceFallback: appLinkMarketplaceFallback);
@override
GeneralSettings appLinkAuthExceptionsEnabled(
bool appLinkAuthExceptionsEnabled,
) => call(appLinkAuthExceptionsEnabled: appLinkAuthExceptionsEnabled);
@override
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
call(enableLocalSearchIndex: enableLocalSearchIndex);
@@ -563,6 +578,11 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit) =>
call(acceptSuggestionOnSubmit: acceptSuggestionOnSubmit);
@override
GeneralSettings popularSitesAutocompleteEnabled(
bool popularSitesAutocompleteEnabled,
) => call(popularSitesAutocompleteEnabled: popularSitesAutocompleteEnabled);
@override
GeneralSettings pureBlack(bool pureBlack) => call(pureBlack: pureBlack);
@@ -655,9 +675,11 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? appLinkRules = const $CopyWithPlaceholder(),
Object? appLinkContextOverrides = const $CopyWithPlaceholder(),
Object? appLinkMarketplaceFallback = const $CopyWithPlaceholder(),
Object? appLinkAuthExceptionsEnabled = const $CopyWithPlaceholder(),
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
Object? indexPrivateTabs = const $CopyWithPlaceholder(),
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
Object? popularSitesAutocompleteEnabled = const $CopyWithPlaceholder(),
Object? pureBlack = const $CopyWithPlaceholder(),
Object? globalDesktopMode = const $CopyWithPlaceholder(),
Object? desktopModeSites = const $CopyWithPlaceholder(),
@@ -1050,6 +1072,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.appLinkMarketplaceFallback
// ignore: cast_nullable_to_non_nullable
: appLinkMarketplaceFallback as bool,
appLinkAuthExceptionsEnabled:
appLinkAuthExceptionsEnabled == const $CopyWithPlaceholder() ||
appLinkAuthExceptionsEnabled == null
? _value.appLinkAuthExceptionsEnabled
// ignore: cast_nullable_to_non_nullable
: appLinkAuthExceptionsEnabled as bool,
enableLocalSearchIndex:
enableLocalSearchIndex == const $CopyWithPlaceholder() ||
enableLocalSearchIndex == null
@@ -1068,6 +1096,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.acceptSuggestionOnSubmit
// ignore: cast_nullable_to_non_nullable
: acceptSuggestionOnSubmit as bool,
popularSitesAutocompleteEnabled:
popularSitesAutocompleteEnabled == const $CopyWithPlaceholder() ||
popularSitesAutocompleteEnabled == null
? _value.popularSitesAutocompleteEnabled
// ignore: cast_nullable_to_non_nullable
: popularSitesAutocompleteEnabled as bool,
pureBlack: pureBlack == const $CopyWithPlaceholder() || pureBlack == null
? _value.pureBlack
// ignore: cast_nullable_to_non_nullable
@@ -1240,9 +1274,12 @@ GeneralSettings _$GeneralSettingsFromJson(
json['appLinkContextOverrides'] as Map<String, dynamic>?,
),
appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?,
appLinkAuthExceptionsEnabled: json['appLinkAuthExceptionsEnabled'] as bool?,
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
indexPrivateTabs: json['indexPrivateTabs'] as bool?,
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
popularSitesAutocompleteEnabled:
json['popularSitesAutocompleteEnabled'] as bool?,
pureBlack: json['pureBlack'] as bool?,
globalDesktopMode: json['globalDesktopMode'] as bool?,
desktopModeSites: (json['desktopModeSites'] as List<dynamic>?)
@@ -1337,9 +1374,11 @@ Map<String, dynamic> _$GeneralSettingsToJson(
(k, e) => MapEntry(k, e.toJson()),
),
'appLinkMarketplaceFallback': instance.appLinkMarketplaceFallback,
'appLinkAuthExceptionsEnabled': instance.appLinkAuthExceptionsEnabled,
'enableLocalSearchIndex': instance.enableLocalSearchIndex,
'indexPrivateTabs': instance.indexPrivateTabs,
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
'popularSitesAutocompleteEnabled': instance.popularSitesAutocompleteEnabled,
'pureBlack': instance.pureBlack,
'globalDesktopMode': instance.globalDesktopMode,
'desktopModeSites': instance.desktopModeSites,
@@ -109,9 +109,11 @@ const generalSettingColumnTypes = <String, DriftSqlType>{
'customTabsEnabled': DriftSqlType.bool,
'appLinksMode': DriftSqlType.string,
'appLinkMarketplaceFallback': DriftSqlType.bool,
'appLinkAuthExceptionsEnabled': DriftSqlType.bool,
'enableLocalSearchIndex': DriftSqlType.bool,
'indexPrivateTabs': DriftSqlType.bool,
'acceptSuggestionOnSubmit': DriftSqlType.bool,
'popularSitesAutocompleteEnabled': DriftSqlType.bool,
'pureBlack': DriftSqlType.bool,
'showSearchCloseButton': DriftSqlType.bool,
'homeTarget': DriftSqlType.string,
+1 -1
View File
@@ -2,7 +2,7 @@ name: weblibre
description: "The Privacy-Focused & AI-Powered Research Browser"
publish_to: 'none'
resolution: workspace
version: 0.30.0-alpha-1+40
version: 0.30.0-alpha-3+40
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -42,6 +42,32 @@ private class NativeFragmentView(
private val container: View
/**
* Reports whether the container is reachable through [Activity.findViewById], which is what
* `GeckoBrowserApiImpl.showFragmentCallback` needs before it can attach the browser fragment.
*
* Hybrid composition (and HC++) only insert the platform view into the Flutter view hierarchy
* the first time its layer is composited `PlatformViewsController#onDisplayPlatformView` ->
* `initializePlatformViewIfNeeded` -> `flutterView.addView(parentView)`. A widget that lays the
* view out but does not paint it (an `Offstage` ancestor, for instance) therefore keeps the
* container out of the hierarchy indefinitely, and every attach attempt made in the meantime
* fails.
*
* [onFlutterViewAttached] is no signal for this: Flutter calls it while constructing the
* platform view, so reporting readiness from there claims the container is usable long before
* it is. The container's own attach state is the fact that matters, so it is what gets
* reported. See https://github.com/FaFre/WebLibre/issues/557.
*/
private val attachStateListener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
flutterEvents.onViewReadyStateChange(EventSequence.next(), true) { _ -> }
}
override fun onViewDetachedFromWindow(v: View) {
flutterEvents.onViewReadyStateChange(EventSequence.next(), false) { _ -> }
}
}
init {
val vParams: ViewGroup.LayoutParams =
FrameLayout.LayoutParams(
@@ -56,13 +82,13 @@ private class NativeFragmentView(
container = BackGestureFilterFrameLayout(activity, activity)
container.layoutParams = vParams
container.id = containerId
container.addOnAttachStateChangeListener(attachStateListener)
}
override fun onFlutterViewAttached(flutterView: View) {
super.onFlutterViewAttached(flutterView)
components.engineReportedInitialized = false
flutterEvents.onViewReadyStateChange(EventSequence.next(), true) { _ -> }
}
override fun getView(): View {
@@ -70,6 +96,11 @@ private class NativeFragmentView(
}
override fun dispose() {
// Clean up if needed
container.removeOnAttachStateChangeListener(attachStateListener)
// Removing the listener suppresses the detach callback that tearing the view down would
// otherwise deliver, so report the container gone explicitly. Dart must not keep believing
// an attach is possible against a container that no longer exists.
flutterEvents.onViewReadyStateChange(EventSequence.next(), false) { _ -> }
}
}
@@ -19,6 +19,10 @@ class AuthIntentReceiverActivity : Activity() {
val sourceIntent = intent?.let { Intent(it) } ?: Intent()
// Stamp the caller before CustomTabIntentProcessor builds the session source, so the
// app-links authentication carve-out can recognise a sign-in callback for this tab.
addExternalCallerInformation(sourceIntent)
if (GlobalComponents.components == null && !GlobalComponents.ensureExternalComponents(applicationContext)) {
finish()
return
@@ -0,0 +1,83 @@
/*
* 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.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import mozilla.components.support.utils.EXTRA_ACTIVITY_REFERRER_CATEGORY
import mozilla.components.support.utils.EXTRA_ACTIVITY_REFERRER_PACKAGE
import mozilla.components.support.utils.ext.packageManagerCompatHelper
/**
* Records which app sent [intent] so the session created from it carries a `caller`.
*
* AC's `CustomTabIntentProcessor` reads the caller through `SafeIntent.externalPackage()`, which
* only looks at the [EXTRA_ACTIVITY_REFERRER_PACKAGE] extra nothing populates it for us, so a
* receiver has to stamp it before handing the intent to the processors or every custom tab ends up
* with `Source.External.CustomTab(null)`. Mirrors Fenix's `IntentReceiverActivity`
* `addReferrerInformation`.
*
* The app-links authentication carve-out
* ([eu.weblibre.flutter_mozilla_components.applinks.WebLibreAppLinksInterceptor]) is the consumer:
* it lets a sign-in callback return to the app that opened the tab.
*/
fun Activity.addExternalCallerInformation(intent: Intent) {
val caller = resolveExternalCallerPackage(intent) ?: return
intent.putExtra(EXTRA_ACTIVITY_REFERRER_PACKAGE, caller)
// ApplicationInfo.category is API 26+; this module builds against minSdk 24.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val category = packageManagerCompatHelper.getApplicationInfoCompat(caller, 0).category
intent.putExtra(EXTRA_ACTIVITY_REFERRER_CATEGORY, category)
} catch (e: PackageManager.NameNotFoundException) {
// The caller is not resolvable — the package id alone is enough for our purposes.
}
}
}
/**
* Best-effort identity of the app that sent [intent].
*
* [Activity.getCallingPackage] is supplied by the system and cannot be forged, so it wins when
* present (only set for `startActivityForResult` callers). The referrer chain below is
* caller-controlled and therefore spoofable an app can claim to be another package. Consumers
* must not grant anything on it that the caller could not already do itself.
*/
@Suppress("TooGenericExceptionCaught")
fun Activity.resolveExternalCallerPackage(intent: Intent): String? {
callingPackage?.let { return it }
// Android can throw when the referrer carries data it cannot deserialise.
val activityReferrer = try {
referrer
} catch (e: RuntimeException) {
null
}
activityReferrer?.let { uri ->
if (uri.scheme == ANDROID_APP_SCHEME) {
uri.host?.let { return it }
}
}
@Suppress("DEPRECATION")
val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER)
if (referrerUri?.scheme == ANDROID_APP_SCHEME) {
referrerUri.host?.let { return it }
}
intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name ->
Uri.parse(name).takeIf { it.scheme == ANDROID_APP_SCHEME }?.host?.let { return it }
}
return null
}
private const val ANDROID_APP_SCHEME = "android-app"
@@ -93,7 +93,7 @@ class IntentReceiverActivity : Activity() {
}
}
val caller = resolveCallerPackage(intent) ?: return false
val caller = resolveExternalCallerPackage(intent) ?: return false
if (caller == packageName) return false
if (!IntentGatekeeperPreferences.isBlocked(applicationContext, caller)) return false
@@ -102,32 +102,17 @@ class IntentReceiverActivity : Activity() {
return true
}
private fun resolveCallerPackage(intent: Intent): String? {
referrer?.let { uri ->
if (uri.scheme == "android-app") {
uri.host?.let { return it }
}
}
@Suppress("DEPRECATION")
val referrerUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_REFERRER)
if (referrerUri?.scheme == "android-app") {
referrerUri.host?.let { return it }
}
intent.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { name ->
Uri.parse(name).takeIf { it.scheme == "android-app" }?.host?.let { return it }
}
return callingPackage
}
override fun onDestroy() {
super.onDestroy()
coroutineScope.cancel()
}
private fun processIntent(intent: Intent) {
// Must run before any intent processor: CustomTabIntentProcessor reads the caller off the
// intent when it builds the session source, and the app-links authentication carve-out
// needs that caller to recognise a sign-in callback.
addExternalCallerInformation(intent)
if (GlobalComponents.components == null) {
if (GlobalComponents.ensureExternalComponents(applicationContext)) {
routeIntent(intent)
@@ -134,7 +134,12 @@ class GeckoAppLinksApiImpl(
try {
val components = GlobalComponents.components
val list = components
?.let { pendingStoreFor(it).getPending(owner).map(PendingAppLinkRequest::toPigeon) }
?.let {
val store = pendingStoreFor(it)
store.getPending(owner).map { request ->
request.toPigeon(store.expiresInMs(request))
}
}
?: emptyList()
callback(Result.success(list))
} catch (e: Exception) {
@@ -57,6 +57,7 @@ data class AppLinkPolicy(
val protectedContextIds: Set<String>,
val strictContextIds: Set<String>,
val protectedTargetPatterns: List<ProtectedTargetPattern>,
val authExceptionsEnabled: Boolean,
/**
* Per-container overrides keyed by contextId; only isolated containers appear. A navigation whose
* source contextId is a key uses the entry's mode + rules instead of the global ones (replace).
@@ -72,6 +73,7 @@ data class AppLinkPolicy(
protectedContextIds = emptySet(),
strictContextIds = emptySet(),
protectedTargetPatterns = emptyList(),
authExceptionsEnabled = true,
contextOverrides = emptyMap(),
)
}
@@ -16,11 +16,15 @@ import mozilla.components.support.base.log.logger.Logger
* behaviour so the app opens in its own recents entry.
* - [AUTOMATIC]: global-`always` or a remembered `alwaysOpen` rule `NEW_TASK`, subject to the
* 2 s same-package cooldown loop-breaker (§2.4).
* - [AUTHENTICATION]: same-caller Custom Tab / ActionView callback `NEW_TASK | CLEAR_TOP` so the
* originating app can resume its existing task. Not a user gesture, so it takes the same 2 s
* cooldown as [AUTOMATIC] (AC applies its loop-breaker to authentication flows too).
* - [MARKETPLACE]: install-app fallback `NEW_TASK | CLEAR_TASK`.
*/
enum class AppLinkLaunchMode {
MANUAL,
AUTOMATIC,
AUTHENTICATION,
MARKETPLACE,
}
@@ -34,9 +38,9 @@ enum class AppLinkLaunchResult {
/**
* Launches external apps. Every launch re-resolves immediately first (no cache) and verifies the
* expected package before `startActivity` (§2.7). Automatic launches honour a 2 s same-package
* cooldown to break appbrowserapp ping-pong loops (§2.4); manual and prompt-resolved opens are
* user gestures that bypass the check but still record it.
* expected package before `startActivity` (§2.7). Automatic and authentication launches honour a 2 s
* same-package cooldown to break appbrowserapp ping-pong loops (§2.4); manual and prompt-resolved
* opens are user gestures that bypass the check but still record it.
*/
class AppLinkLauncher(
private val resolver: ExternalAppResolver,
@@ -81,7 +85,7 @@ class AppLinkLauncher(
else -> resolved.packageName
}
if (mode == AppLinkLaunchMode.AUTOMATIC) {
if (mode == AppLinkLaunchMode.AUTOMATIC || mode == AppLinkLaunchMode.AUTHENTICATION) {
val (lastPackage, lastTs) = lastLaunch
if (lastPackage != null && lastPackage == targetPackage &&
clock.elapsedRealtime() < lastTs + cooldownMs
@@ -117,6 +121,8 @@ class AppLinkLauncher(
Intent.FLAG_ACTIVITY_NEW_TASK
AppLinkLaunchMode.AUTOMATIC ->
Intent.FLAG_ACTIVITY_NEW_TASK
AppLinkLaunchMode.AUTHENTICATION ->
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
AppLinkLaunchMode.MARKETPLACE ->
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
@@ -28,6 +28,7 @@ fun AppLinkPolicySnapshot.toAppLinkPolicy(): AppLinkPolicy {
port = pattern.port?.toInt(),
)
},
authExceptionsEnabled = authExceptionsEnabled,
contextOverrides = contextOverrides.mapValues { (_, override) ->
ContextAppLinkPolicy(
globalMode = override.mode.toAppLinkMode(),
@@ -93,6 +93,7 @@ class AppLinkPolicyStore internal constructor(
root.put(FIELD_MIGRATED, migrated)
root.put(FIELD_GLOBAL_MODE, policy.globalMode.name)
root.put(FIELD_MARKETPLACE, policy.marketplaceFallbackEnabled)
root.put(FIELD_AUTH_EXCEPTIONS, policy.authExceptionsEnabled)
root.put(FIELD_PROTECT_GENERAL, policy.protectGeneralContext)
root.put(FIELD_PROTECTED_CONTEXTS, JSONArray(policy.protectedContextIds.toList()))
root.put(FIELD_STRICT_CONTEXTS, JSONArray(policy.strictContextIds.toList()))
@@ -177,6 +178,7 @@ class AppLinkPolicyStore internal constructor(
globalMode = AppLinkMode.valueOf(root.getString(FIELD_GLOBAL_MODE)),
rules = rules,
marketplaceFallbackEnabled = root.optBoolean(FIELD_MARKETPLACE, false),
authExceptionsEnabled = root.optBoolean(FIELD_AUTH_EXCEPTIONS, true),
protectGeneralContext = root.optBoolean(FIELD_PROTECT_GENERAL, false),
protectedContextIds = root.optJSONArray(FIELD_PROTECTED_CONTEXTS).toStringSet(),
strictContextIds = root.optJSONArray(FIELD_STRICT_CONTEXTS).toStringSet(),
@@ -218,6 +220,7 @@ class AppLinkPolicyStore internal constructor(
private const val FIELD_MIGRATED = "migrated"
private const val FIELD_GLOBAL_MODE = "globalMode"
private const val FIELD_MARKETPLACE = "marketplaceFallbackEnabled"
private const val FIELD_AUTH_EXCEPTIONS = "authExceptionsEnabled"
private const val FIELD_PROTECT_GENERAL = "protectGeneralContext"
private const val FIELD_PROTECTED_CONTEXTS = "protectedContextIds"
private const val FIELD_STRICT_CONTEXTS = "strictContextIds"
@@ -56,8 +56,25 @@ class NativeAppLinkPromptFeature(
private val sessionUseCases: SessionUseCases,
) : LifecycleAwareFeature {
private var dialog: AlertDialog? = null
private var shownRequest: PendingAppLinkRequest? = null
private val mainHandler = Handler(Looper.getMainLooper())
/**
* The lapse tick for the dialog currently on screen. Held as a single instance so it can be
* cancelled: the delay is up to [PendingAppLinkStore.REQUEST_EXPIRY_MS] (10 minutes) and the
* runnable retains this feature and through it the Activity-derived [context] for its whole
* duration, so it must never outlive [stop].
*/
private val expiryTick = Runnable {
dismissStaleDialog()
// The store sweeps on a strict `>`, so a tick can land a millisecond before the request is
// actually droppable and dismiss nothing. Re-arm in that case rather than leave the dialog
// with no deadline at all; [MIN_EXPIRY_TICK_MS] keeps that from spinning.
shownRequest?.let(::scheduleExpiryTick)
// A dialog retired by its own deadline still has to make way for whatever else pends.
showNext()
}
override fun start() {
NativeAppLinkPromptNotifier.register(tabId, this)
showNext()
@@ -65,20 +82,52 @@ class NativeAppLinkPromptFeature(
override fun stop() {
NativeAppLinkPromptNotifier.unregister(tabId, this)
mainHandler.removeCallbacksAndMessages(null)
// Dismissing on stop is not a user dismissal: the request stays pending and
// is re-presented on the next start().
dialog?.setOnDismissListener(null)
dialog?.dismiss()
dialog = null
shownRequest = null
}
/**
* A new pending request may have been created for this tab (interceptor, engine thread) after
* [start] already queried. Re-check on the main thread; [showNext] is idempotent (a no-op while a
* dialog is up or when nothing pends).
* The tab's pending requests changed (interceptor created one on an engine thread after [start]
* already queried, or the navigation middleware invalidated one). Re-check on the main thread;
* [showNext] is idempotent (a no-op while a live dialog is up or when nothing pends).
*/
fun onPromptAvailable() {
mainHandler.post { showNext() }
mainHandler.post {
dismissStaleDialog()
showNext()
}
}
/**
* Drop a dialog whose request has since been invalidated otherwise it stays on screen as a
* dud whose Open button consumes nothing. Not a user dismissal: nothing is suppressed.
*/
private fun dismissStaleDialog() {
val shown = shownRequest ?: return
if (store.peek(shown.requestId) != null) return
mainHandler.removeCallbacks(expiryTick)
dialog?.setOnCancelListener(null)
dialog?.setOnDismissListener(null)
dialog?.dismiss()
dialog = null
shownRequest = null
}
/**
* Expiry in the store is lazy, so nothing would take a dialog down when its request lapses
* its buttons would consume nothing. Retire it on its own deadline instead.
*/
private fun scheduleExpiryTick(request: PendingAppLinkRequest) {
mainHandler.removeCallbacks(expiryTick)
mainHandler.postDelayed(
expiryTick,
store.expiresInMs(request).coerceAtLeast(MIN_EXPIRY_TICK_MS),
)
}
private fun showNext() {
@@ -107,6 +156,8 @@ class NativeAppLinkPromptFeature(
}
.setOnDismissListener { dialog = null }
.show()
shownRequest = request
scheduleExpiryTick(request)
}
private fun resolveOpen(request: PendingAppLinkRequest) {
@@ -141,7 +192,14 @@ class NativeAppLinkPromptFeature(
}
private fun afterResolve() {
mainHandler.removeCallbacks(expiryTick)
dialog = null
shownRequest = null
showNext()
}
private companion object {
/** Never schedule a zero-delay expiry tick; a lapsed request would reschedule in a spin. */
const val MIN_EXPIRY_TICK_MS = 250L
}
}
@@ -51,7 +51,7 @@ data class PendingAppLinkRequest(
val scopeKey: String,
val createdAt: Long,
) {
fun toPigeon(): AppLinkPromptRequest = AppLinkPromptRequest(
fun toPigeon(expiresInMs: Long): AppLinkPromptRequest = AppLinkPromptRequest(
requestId = requestId,
owner = owner,
tabId = tabId,
@@ -72,6 +72,7 @@ data class PendingAppLinkRequest(
engineSupportsScheme = engineSupportsScheme,
scopeKey = scopeKey,
),
expiresInMs = expiresInMs,
)
}
@@ -120,10 +121,32 @@ object PendingAppLinkStores {
* Query + consume: requests stay until resolved, invalidated, or expired. The store
* never holds its lock across a side effect [consume] returns the request and the
* caller performs launch/fallback after the lock is released.
*
* **A request's lifetime is deliberately not derived from navigation.** Three attempts to infer
* "the user has left the page this prompt belongs to" from the [mozilla.components.browser.state.store.BrowserStore]
* action stream all failed the same way, because every available signal is *per document* while a
* single user-visible navigation spans several:
* - comparing the committed URL's host to the request's anchor killed a banner on its own redirect
* chain (`youtu.be` `youtube.com`, shortener destination) and killed a modal on the commit of
* the very load it had interrupted leaving a denied navigation with no dialog to un-stall it;
* - counting load starts ([mozilla.components.browser.state.action.ContentAction.UpdateLoadingStateAction])
* dismissed banners seconds in, because each redirected document starts its own load;
* - settling on idle only moved that to the first `onPageStop`, which multi-document pages reach
* long before the user is done with them.
*
* So navigation is not consulted at all. A request ends when the user answers it, when its tab
* closes, when a newer banner for the same tab replaces it, or when it expires ([BANNER_EXPIRY_MS]
* for the passive banner, [REQUEST_EXPIRY_MS] for a modal that is holding a navigation). The
* residual risk a banner outliving the page it was raised on is bounded by that expiry and is
* strictly safer than the alternatives: a lingering banner still names its target and still opens
* exactly that link, whereas the invalidation heuristics produced prompts that silently did
* nothing. Do not reintroduce URL- or load-state-derived invalidation without a signal that is
* per *navigation* rather than per document.
*/
class PendingAppLinkStore(
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
private val requestExpiryMs: Long = REQUEST_EXPIRY_MS,
private val bannerExpiryMs: Long = BANNER_EXPIRY_MS,
private val suppressionExpiryMs: Long = SUPPRESSION_EXPIRY_MS,
private val dedupeWindowMs: Long = DEDUPE_WINDOW_MS,
private val fallbackReentryMs: Long = FALLBACK_REENTRY_MS,
@@ -180,11 +203,26 @@ class PendingAppLinkStore(
scopeKey = input.scopeKey,
createdAt = clock.elapsedRealtime(),
)
// At most one live banner per tab: the surface renders one anyway, and a second
// app-link site visited in the same tab should replace the offer, not stack behind it.
if (request.urlClass == AppLinkUrlClass.BANNER) {
requests.values.removeAll {
it.tabId == request.tabId && it.urlClass == AppLinkUrlClass.BANNER
}
}
requests[request.requestId] = request
return request
}
}
/**
* How long [request] has left before [sweepExpiredLocked] drops it. Handed to the surface so it
* can retire the prompt on time expiry is lazy (it only runs on query/consume), so a prompt
* left on screen past its deadline would still render buttons that resolve to `stale`.
*/
fun expiresInMs(request: PendingAppLinkRequest): Long =
(expiryFor(request) - (clock.elapsedRealtime() - request.createdAt)).coerceAtLeast(0L)
/** Non-consuming query of live requests for [owner]. */
fun getPending(owner: AppLinkPromptOwner): List<PendingAppLinkRequest> {
synchronized(lock) {
@@ -212,80 +250,23 @@ class PendingAppLinkStore(
synchronized(lock) { requests.remove(requestId) }
}
/** Invalidate every pending request for a tab (tab close / replacement). */
fun invalidateTab(tabId: String) {
/**
* Invalidate every pending request for a tab (tab close / replacement).
*
* @return the owners that had a request removed, so the caller can tell those surfaces to
* re-query instead of leaving a dead prompt on screen.
*/
fun invalidateTab(tabId: String): Set<AppLinkPromptOwner> {
synchronized(lock) {
val owners = requests.values
.filter { it.tabId == tabId }
.mapTo(mutableSetOf()) { it.owner }
requests.values.removeAll { it.tabId == tabId }
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
return owners
}
}
/**
* A committed top-level navigation in [tabId]. A request whose own page committed
* stays alive (that commit is the page the prompt sits on); a commit to a
* *different site* invalidates the tab's pending requests (§2.6).
*
* Matching is by **normalised host**, not exact URL: the initial load a banner
* rides on almost always commits at a redirected/normalised URL (`www`, trailing
* slash, tracking params) that never equals the intercepted URL, so an exact-URL
* check would invalidate every banner on its own page load. The anchor is the
* target host for a banner (the page it loads) and the source host for a modal
* (the page it is shown over, since the modal's own navigation was denied). When
* no host can be derived, the request is kept and left to expiry/tab-close.
*/
fun onCommittedNavigation(tabId: String, committedUrl: String) {
val committedHost = siteKey(committedUrl)
synchronized(lock) {
val removed = mutableListOf<Long>()
requests.values.removeAll { request ->
if (request.tabId != tabId) return@removeAll false
val anchorHost = siteKey(if (request.isModal) request.sourceUrl else request.url)
val invalidate = anchorHost != null && committedHost != null && anchorHost != committedHost
if (invalidate) removed.add(request.requestId)
invalidate
}
if (removed.isNotEmpty()) {
logger.info(
"onCommittedNavigation tab=$tabId committedHost=$committedHost invalidated=$removed",
)
}
}
}
/** Normalised, subdomain-stripped host for same-site comparison; null if underivable. */
private fun siteKey(url: String?): String? {
val rawHost = extractHost(url) ?: return null
val normalized = AppLinkHostNormalizer.normalizeHost(rawHost) ?: return null
return stripCommonSubDomains(normalized)
}
private fun extractHost(url: String?): String? {
if (url.isNullOrEmpty()) return null
val schemeSep = url.indexOf("://")
if (schemeSep < 0) return null
val afterScheme = url.substring(schemeSep + 3)
val end = afterScheme.indexOfFirst { it == '/' || it == '?' || it == '#' }
var authority = if (end >= 0) afterScheme.substring(0, end) else afterScheme
val at = authority.lastIndexOf('@')
if (at >= 0) authority = authority.substring(at + 1)
// Preserve a bracketed IPv6 literal; AppLinkHostNormalizer canonicalises it.
if (authority.startsWith("[")) {
val close = authority.indexOf(']')
return if (close >= 0) authority.substring(0, close + 1) else null
}
val colon = authority.lastIndexOf(':')
if (colon >= 0) authority = authority.substring(0, colon)
return authority.ifEmpty { null }
}
private fun stripCommonSubDomains(host: String): String = when {
host.startsWith("www.") -> host.removePrefix("www.")
host.startsWith("m.") -> host.removePrefix("m.")
host.startsWith("mobile.") -> host.removePrefix("mobile.")
host.startsWith("maps.") -> host.removePrefix("maps.")
else -> host
}
// ---- Suppression (§2.6) ----
fun recordSuppression(tabId: String, fingerprint: String) {
@@ -326,15 +307,24 @@ class PendingAppLinkStore(
}
}
/**
* A banner is a passive offer sitting over a page the user keeps reading, so it is bounded by
* time rather than by navigation (see the class KDoc). A modal blocks a navigation until it is
* answered, so it keeps the long window.
*/
private fun expiryFor(request: PendingAppLinkRequest): Long =
if (request.urlClass == AppLinkUrlClass.BANNER) bannerExpiryMs else requestExpiryMs
private fun sweepExpiredLocked() {
val now = clock.elapsedRealtime()
requests.values.removeAll { now > it.createdAt + requestExpiryMs }
requests.values.removeAll { now > it.createdAt + expiryFor(it) }
suppression.values.removeAll { now > it }
fallbackReentry.values.removeAll { now > it }
}
companion object {
const val REQUEST_EXPIRY_MS = 10 * 60 * 1000L
const val BANNER_EXPIRY_MS = 90 * 1000L
const val SUPPRESSION_EXPIRY_MS = 10 * 60 * 1000L
const val DEDUPE_WINDOW_MS = 2000L
const val FALLBACK_REENTRY_MS = 10 * 1000L
@@ -58,9 +58,27 @@ class WebLibreAppLinksInterceptor(
val uriScheme = runCatching { uri.toUri().scheme }.getOrNull()
val engineSupportsScheme = AppLinkSchemes.isEngineSupported(uriScheme)
val session = components.core.store.state.findTabOrCustomTab(engineSession)
val policy = AppLinkPolicyStores.forProfile(components.profileApplicationContext).policy
// A tab an external app opened for us (Custom Tab / ActionView) may be hosting a sign-in
// round trip. Gated on the policy so turning the carve-out off restores the plain §2.4
// eligibility rules rather than only skipping the launch below.
val authExceptionsAllowed = policy.authExceptionsEnabled && isPossibleAuthentication(session)
val isSameDomainNavigation = isSameDomain(lastUri, uri)
// Step 2 — navigation eligibility. Any hit lets the engine proceed normally.
if (!isEligible(uri, lastUri, uriScheme, engineSupportsScheme, hasUserGesture, isRedirect, isDirectNavigation, isSubframeRequest)) {
if (!isEligible(
uriScheme,
engineSupportsScheme,
hasUserGesture,
isRedirect,
isDirectNavigation,
isSubframeRequest,
isSameDomainNavigation,
authExceptionsAllowed,
)
) {
return null
}
@@ -74,10 +92,6 @@ class WebLibreAppLinksInterceptor(
val resolved = runtime.resolver.resolve(uri, includeHttpAppLinks = true, useCache = true)
val policy = AppLinkPolicyStores.forProfile(components.profileApplicationContext).policy
val session = components.core.store.state.findTabOrCustomTab(engineSession)
// Container isolation (replace semantics): a container with "isolated app link settings"
// enabled contributes an entry keyed by its contextId. When the source tab's contextId has
// one, its mode + rules fully replace the global ones for this navigation.
@@ -85,16 +99,70 @@ class WebLibreAppLinksInterceptor(
val effectiveMode = override?.globalMode ?: policy.globalMode
val effectiveRules = override?.rules ?: policy.rules
val isProtectedNavigation = isProtected(policy, session, uri)
val isPrivateNavigation = session?.content?.private ?: false
val isWalletNavigation = AppLinkSchemes.isWallet(resolved.originalScheme) ||
AppLinkSchemes.isWallet(resolved.intentDataScheme)
// An ambiguous resolution is never treated as a callback: with several handlers we cannot
// show the caller *is* the target, and the launch would raise a chooser rather than return
// to the app. AC declines here too — its package comes from the bound component, which is
// only set for an unambiguous handler.
val authTargetPackage = if (resolved.isAmbiguous) null else resolved.packageName
val isAuthCallback = isAuthenticationCallback(session, authTargetPackage)
// Re-apply the same-domain guard now that the target is known (AC parity: `AppLinksInterceptor`
// re-checks after resolution for exactly this reason). Eligibility waived it on the mere
// possibility of a sign-in round trip — the tab was opened by *some* app — which would
// otherwise re-classify every ordinary in-site navigation for the whole life of a Custom Tab
// and, under the default `ask` mode, prompt on each one. Only a navigation that really does
// target the calling app keeps the waiver.
if (engineSupportsScheme && isSameDomainNavigation && authExceptionsAllowed && !isAuthCallback) {
return null
}
val matchingRule = effectiveRules[resolved.scopeKey]
val fingerprint = targetFingerprint(uri, resolved)
val suppressionHit = session != null && pendingStore.isSuppressed(session.id, fingerprint)
// §2.4 authentication carve-out (AC parity): a tab opened *by* the app the navigation
// targets is a sign-in round trip rather than a general app link, so it returns to its
// caller even under `never`. The forced-prompt contexts still win — a protected container,
// a private tab or a wallet scheme must not leak out silently, so those fall through to the
// classifier, which prompts for them regardless of mode (§2.4 step 4). An explicit
// `neverOpen` rule for this scope and a live suppression are the user having answered this
// exact question already (classifier steps 56); the carve-out is about a mode the user set
// for links in general, not a licence to override a specific "no".
if (authExceptionsAllowed &&
isAuthCallback &&
!isProtectedNavigation && !isPrivateNavigation && !isWalletNavigation &&
matchingRule?.decision != AppLinkRuleDecision.NEVER_OPEN &&
!suppressionHit
) {
val result = runtime.launcher.launch(
uri,
AppLinkLaunchMode.AUTHENTICATION,
expectedPackage = authTargetPackage,
)
logger.info(
"auth app-link callback uri=$uri tab=${session?.id} " +
"caller=${callerPackage(session)} package=$authTargetPackage -> $result",
)
return if (result == AppLinkLaunchResult.LAUNCHED) {
RequestInterceptor.InterceptionResponse.Deny
} else {
safeNonLaunchResponse(pendingStore, resolved)
}
}
val input = ClassifierInput(
resolved = resolved,
isProtected = isProtected(policy, session, uri),
isPrivate = session?.content?.private ?: false,
isWallet = AppLinkSchemes.isWallet(resolved.originalScheme) ||
AppLinkSchemes.isWallet(resolved.intentDataScheme),
isProtected = isProtectedNavigation,
isPrivate = isPrivateNavigation,
isWallet = isWalletNavigation,
missingSession = session == null,
suppressionHit = session != null &&
pendingStore.isSuppressed(session.id, targetFingerprint(uri, resolved)),
matchingRule = effectiveRules[resolved.scopeKey],
suppressionHit = suppressionHit,
matchingRule = matchingRule,
globalMode = effectiveMode,
marketplaceFallbackEnabled = policy.marketplaceFallbackEnabled,
)
@@ -262,17 +330,49 @@ class WebLibreAppLinksInterceptor(
}
}
/**
* True when [targetPackage] is the very app that opened this tab the shape of a sign-in
* callback. [targetPackage] must be an unambiguous resolution; pass `null` otherwise.
*/
private fun isAuthenticationCallback(session: SessionState?, targetPackage: String?): Boolean {
if (targetPackage.isNullOrEmpty()) return false
return callerPackage(session) == targetPackage
}
/**
* The package that launched this session, as recorded by
* [eu.weblibre.flutter_mozilla_components.activities.addExternalCallerInformation]. Note the
* underlying referrer is caller-supplied and can be spoofed, so this may only gate actions the
* caller could already perform itself (here: launching its own intent).
*/
private fun callerPackage(session: SessionState?): String? {
return when (val source = session?.source) {
is SessionState.Source.External.CustomTab -> source.caller?.packageId
is SessionState.Source.External.ActionView -> source.caller?.packageId
else -> null
}
}
private fun isPossibleAuthentication(session: SessionState?): Boolean {
return when (session?.source) {
is SessionState.Source.External.CustomTab,
is SessionState.Source.External.ActionView,
-> true
else -> false
}
}
// ---- Eligibility (§2.4 step 2) ----
private fun isEligible(
uri: String,
lastUri: String?,
uriScheme: String?,
engineSupportsScheme: Boolean,
hasUserGesture: Boolean,
isRedirect: Boolean,
isDirectNavigation: Boolean,
isSubframeRequest: Boolean,
isSameDomainNavigation: Boolean,
authExceptionsAllowed: Boolean,
): Boolean {
if (uriScheme == null) return false
// A subframe request not triggered by the user and outside the allowlist stays in-page.
@@ -282,8 +382,12 @@ class WebLibreAppLinksInterceptor(
val isIntentionalNavigation = hasUserGesture || isAllowedRedirect || isDirectNavigation
// Unintentional engine-supported navigation continues in the browser.
if (engineSupportsScheme && !isIntentionalNavigation) return false
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping).
if (engineSupportsScheme && isSameDomain(lastUri, uri)) return false
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping),
// unless this tab could be hosting an authentication round trip whose callback is an http
// app link on the same site. That "could be" is provisional — it only knows the tab was
// opened by *some* app, not that this navigation targets it — so the guard is re-applied in
// [onLoadRequest] once resolution reveals the actual target package.
if (engineSupportsScheme && isSameDomainNavigation && !authExceptionsAllowed) return false
// Always-denied schemes never resolve or launch externally.
if (AppLinkSchemes.isAlwaysDenied(uriScheme)) return false
return true
@@ -6,9 +6,12 @@
package eu.weblibre.flutter_mozilla_components.middleware
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.applinks.NativeAppLinkPromptNotifier
import eu.weblibre.flutter_mozilla_components.applinks.PendingAppLinkStore
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinkPromptOwner
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.CustomTabListAction
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.TabListAction
@@ -17,17 +20,18 @@ import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.Store
/**
* Observes the [BrowserStore] and drives [PendingAppLinkStore] invalidation and
* suppression clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6):
* Observes the [BrowserStore] and drives [PendingAppLinkStore] tab teardown and suppression
* clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6):
*
* - a committed top-level navigation whose URL is not a request's own target
* invalidates that request (a banner-class request's target committing keeps it
* alive that commit is the page the banner sits on);
* - tab close / Custom Tab removal invalidates the tab's pending requests and
* suppression;
* - a new user-initiated/direct navigation (omnibar, bookmark, typed URL which
* dispatch a `LoadUrlAction`) clears the tab's suppression. In-page redirects
* do not dispatch these actions, so the redirect-loop defence stays intact.
* - tab close / Custom Tab removal invalidates the tab's pending requests and suppression, and
* tells the owning surface to re-query so no dead prompt is left on screen;
* - a new user-initiated/direct navigation (omnibar, bookmark, typed URL which dispatch a
* `LoadUrlAction`) clears the tab's suppression. In-page redirects do not dispatch these
* actions, so the redirect-loop defence stays intact.
*
* It deliberately does **not** invalidate prompts on navigation: see the [PendingAppLinkStore]
* KDoc for the three per-document signals that were tried and why none of them can express
* "the user left this page".
*/
class AppLinkNavigationMiddleware(
private val store: PendingAppLinkStore,
@@ -38,13 +42,7 @@ class AppLinkNavigationMiddleware(
action: BrowserAction,
) {
when (action) {
is ContentAction.UpdateUrlAction -> {
// A committed top-level navigation.
this.store.onCommittedNavigation(action.sessionId, action.url)
}
is EngineAction.LoadUrlAction -> {
// App-initiated (direct) navigation — clears suppression.
this.store.clearSuppressionForTab(action.tabId)
}
@@ -53,15 +51,17 @@ class AppLinkNavigationMiddleware(
}
is TabListAction.RemoveTabAction -> {
this.store.invalidateTab(action.tabId)
notifyInvalidated(action.tabId, this.store.invalidateTab(action.tabId))
}
is TabListAction.RemoveTabsAction -> {
action.tabIds.forEach(this.store::invalidateTab)
action.tabIds.forEach { tabId ->
notifyInvalidated(tabId, this.store.invalidateTab(tabId))
}
}
is CustomTabListAction.RemoveCustomTabAction -> {
this.store.invalidateTab(action.tabId)
notifyInvalidated(action.tabId, this.store.invalidateTab(action.tabId))
}
else -> {}
@@ -69,4 +69,22 @@ class AppLinkNavigationMiddleware(
next(action)
}
/**
* Nudge each affected surface to re-query the pending store. The event is the same
* "prompts changed" signal the interceptor sends on creation the query is authoritative, so
* a lost event only delays the cleanup to the next resume.
*/
private fun notifyInvalidated(tabId: String, owners: Set<AppLinkPromptOwner>) {
for (owner in owners) {
when (owner) {
AppLinkPromptOwner.NATIVE_EXTERNAL ->
NativeAppLinkPromptNotifier.notifyPromptAvailable(tabId)
AppLinkPromptOwner.FLUTTER_BROWSER ->
GlobalComponents.appLinkEvents
?.onAppLinkPromptAvailable(EventSequence.next(), owner) { _ -> }
}
}
}
}
@@ -5830,6 +5830,11 @@ data class AppLinkPolicySnapshot (
/** Remembered rules keyed by canonical scope. */
val rules: Map<String, NativeAppLinkRule>,
val marketplaceFallbackEnabled: Boolean,
/**
* Allows same-caller Custom Tab / ActionView authentication callbacks to
* return to their app even when the general app-link mode is `never`.
*/
val authExceptionsEnabled: Boolean,
/** Regular / no-contextId tabs are proxied via the `general` scope. */
val protectGeneralContext: Boolean,
/** contextIds that resolve to a proxy after inherit/bypass/alias. */
@@ -5850,12 +5855,13 @@ data class AppLinkPolicySnapshot (
val globalMode = pigeonVar_list[0] as AppLinksMode
val rules = pigeonVar_list[1] as Map<String, NativeAppLinkRule>
val marketplaceFallbackEnabled = pigeonVar_list[2] as Boolean
val protectGeneralContext = pigeonVar_list[3] as Boolean
val protectedContextIds = pigeonVar_list[4] as List<String>
val strictContextIds = pigeonVar_list[5] as List<String>
val protectedTargetPatterns = pigeonVar_list[6] as List<ProtectedTargetPattern>
val contextOverrides = pigeonVar_list[7] as Map<String, NativeContextAppLinkPolicy>
return AppLinkPolicySnapshot(globalMode, rules, marketplaceFallbackEnabled, protectGeneralContext, protectedContextIds, strictContextIds, protectedTargetPatterns, contextOverrides)
val authExceptionsEnabled = pigeonVar_list[3] as Boolean
val protectGeneralContext = pigeonVar_list[4] as Boolean
val protectedContextIds = pigeonVar_list[5] as List<String>
val strictContextIds = pigeonVar_list[6] as List<String>
val protectedTargetPatterns = pigeonVar_list[7] as List<ProtectedTargetPattern>
val contextOverrides = pigeonVar_list[8] as Map<String, NativeContextAppLinkPolicy>
return AppLinkPolicySnapshot(globalMode, rules, marketplaceFallbackEnabled, authExceptionsEnabled, protectGeneralContext, protectedContextIds, strictContextIds, protectedTargetPatterns, contextOverrides)
}
}
fun toList(): List<Any?> {
@@ -5863,6 +5869,7 @@ data class AppLinkPolicySnapshot (
globalMode,
rules,
marketplaceFallbackEnabled,
authExceptionsEnabled,
protectGeneralContext,
protectedContextIds,
strictContextIds,
@@ -5878,7 +5885,7 @@ data class AppLinkPolicySnapshot (
return true
}
val other = other as AppLinkPolicySnapshot
return GeckoPigeonUtils.deepEquals(this.globalMode, other.globalMode) && GeckoPigeonUtils.deepEquals(this.rules, other.rules) && GeckoPigeonUtils.deepEquals(this.marketplaceFallbackEnabled, other.marketplaceFallbackEnabled) && GeckoPigeonUtils.deepEquals(this.protectGeneralContext, other.protectGeneralContext) && GeckoPigeonUtils.deepEquals(this.protectedContextIds, other.protectedContextIds) && GeckoPigeonUtils.deepEquals(this.strictContextIds, other.strictContextIds) && GeckoPigeonUtils.deepEquals(this.protectedTargetPatterns, other.protectedTargetPatterns) && GeckoPigeonUtils.deepEquals(this.contextOverrides, other.contextOverrides)
return GeckoPigeonUtils.deepEquals(this.globalMode, other.globalMode) && GeckoPigeonUtils.deepEquals(this.rules, other.rules) && GeckoPigeonUtils.deepEquals(this.marketplaceFallbackEnabled, other.marketplaceFallbackEnabled) && GeckoPigeonUtils.deepEquals(this.authExceptionsEnabled, other.authExceptionsEnabled) && GeckoPigeonUtils.deepEquals(this.protectGeneralContext, other.protectGeneralContext) && GeckoPigeonUtils.deepEquals(this.protectedContextIds, other.protectedContextIds) && GeckoPigeonUtils.deepEquals(this.strictContextIds, other.strictContextIds) && GeckoPigeonUtils.deepEquals(this.protectedTargetPatterns, other.protectedTargetPatterns) && GeckoPigeonUtils.deepEquals(this.contextOverrides, other.contextOverrides)
}
override fun hashCode(): Int {
@@ -5886,6 +5893,7 @@ data class AppLinkPolicySnapshot (
result = 31 * result + GeckoPigeonUtils.deepHash(this.globalMode)
result = 31 * result + GeckoPigeonUtils.deepHash(this.rules)
result = 31 * result + GeckoPigeonUtils.deepHash(this.marketplaceFallbackEnabled)
result = 31 * result + GeckoPigeonUtils.deepHash(this.authExceptionsEnabled)
result = 31 * result + GeckoPigeonUtils.deepHash(this.protectGeneralContext)
result = 31 * result + GeckoPigeonUtils.deepHash(this.protectedContextIds)
result = 31 * result + GeckoPigeonUtils.deepHash(this.strictContextIds)
@@ -5894,7 +5902,7 @@ data class AppLinkPolicySnapshot (
return result
}
override fun toString(): String {
return "AppLinkPolicySnapshot(globalMode=$globalMode, rules=$rules, marketplaceFallbackEnabled=$marketplaceFallbackEnabled, protectGeneralContext=$protectGeneralContext, protectedContextIds=$protectedContextIds, strictContextIds=$strictContextIds, protectedTargetPatterns=$protectedTargetPatterns, contextOverrides=$contextOverrides)"
return "AppLinkPolicySnapshot(globalMode=$globalMode, rules=$rules, marketplaceFallbackEnabled=$marketplaceFallbackEnabled, authExceptionsEnabled=$authExceptionsEnabled, protectGeneralContext=$protectGeneralContext, protectedContextIds=$protectedContextIds, strictContextIds=$strictContextIds, protectedTargetPatterns=$protectedTargetPatterns, contextOverrides=$contextOverrides)"
}
}
@@ -5921,7 +5929,14 @@ data class AppLinkPromptRequest (
* unsupported-scheme prompt.
*/
val isModal: Boolean,
val target: AppLinkTarget
val target: AppLinkTarget,
/**
* Milliseconds until the native store drops this request, measured at query
* time. The surface showing it must stop offering it by then: resolving an
* expired request is a no-op, so a prompt left on screen past this becomes a
* button that silently does nothing.
*/
val expiresInMs: Long
)
{
companion object {
@@ -5937,7 +5952,8 @@ data class AppLinkPromptRequest (
val canRemember = pigeonVar_list[8] as Boolean
val isModal = pigeonVar_list[9] as Boolean
val target = pigeonVar_list[10] as AppLinkTarget
return AppLinkPromptRequest(requestId, owner, tabId, contextId, sourceUrl, isPrivate, isWallet, isProtectedContext, canRemember, isModal, target)
val expiresInMs = pigeonVar_list[11] as Long
return AppLinkPromptRequest(requestId, owner, tabId, contextId, sourceUrl, isPrivate, isWallet, isProtectedContext, canRemember, isModal, target, expiresInMs)
}
}
fun toList(): List<Any?> {
@@ -5953,6 +5969,7 @@ data class AppLinkPromptRequest (
canRemember,
isModal,
target,
expiresInMs,
)
}
override fun equals(other: Any?): Boolean {
@@ -5963,7 +5980,7 @@ data class AppLinkPromptRequest (
return true
}
val other = other as AppLinkPromptRequest
return GeckoPigeonUtils.deepEquals(this.requestId, other.requestId) && GeckoPigeonUtils.deepEquals(this.owner, other.owner) && GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.sourceUrl, other.sourceUrl) && GeckoPigeonUtils.deepEquals(this.isPrivate, other.isPrivate) && GeckoPigeonUtils.deepEquals(this.isWallet, other.isWallet) && GeckoPigeonUtils.deepEquals(this.isProtectedContext, other.isProtectedContext) && GeckoPigeonUtils.deepEquals(this.canRemember, other.canRemember) && GeckoPigeonUtils.deepEquals(this.isModal, other.isModal) && GeckoPigeonUtils.deepEquals(this.target, other.target)
return GeckoPigeonUtils.deepEquals(this.requestId, other.requestId) && GeckoPigeonUtils.deepEquals(this.owner, other.owner) && GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.sourceUrl, other.sourceUrl) && GeckoPigeonUtils.deepEquals(this.isPrivate, other.isPrivate) && GeckoPigeonUtils.deepEquals(this.isWallet, other.isWallet) && GeckoPigeonUtils.deepEquals(this.isProtectedContext, other.isProtectedContext) && GeckoPigeonUtils.deepEquals(this.canRemember, other.canRemember) && GeckoPigeonUtils.deepEquals(this.isModal, other.isModal) && GeckoPigeonUtils.deepEquals(this.target, other.target) && GeckoPigeonUtils.deepEquals(this.expiresInMs, other.expiresInMs)
}
override fun hashCode(): Int {
@@ -5979,10 +5996,11 @@ data class AppLinkPromptRequest (
result = 31 * result + GeckoPigeonUtils.deepHash(this.canRemember)
result = 31 * result + GeckoPigeonUtils.deepHash(this.isModal)
result = 31 * result + GeckoPigeonUtils.deepHash(this.target)
result = 31 * result + GeckoPigeonUtils.deepHash(this.expiresInMs)
return result
}
override fun toString(): String {
return "AppLinkPromptRequest(requestId=$requestId, owner=$owner, tabId=$tabId, contextId=$contextId, sourceUrl=$sourceUrl, isPrivate=$isPrivate, isWallet=$isWallet, isProtectedContext=$isProtectedContext, canRemember=$canRemember, isModal=$isModal, target=$target)"
return "AppLinkPromptRequest(requestId=$requestId, owner=$owner, tabId=$tabId, contextId=$contextId, sourceUrl=$sourceUrl, isPrivate=$isPrivate, isWallet=$isWallet, isProtectedContext=$isProtectedContext, canRemember=$canRemember, isModal=$isModal, target=$target, expiresInMs=$expiresInMs)"
}
}
@@ -58,6 +58,11 @@ class AppLinkClassifierTest {
// ---- §2.2 table: engine-supported (http) scheme, app resolves ----
@Test
fun safeDefaultAllowsAuthExceptions() {
assertEquals(true, AppLinkPolicy.SAFE_DEFAULT.authExceptionsEnabled)
}
@Test
fun engineSupportedAlwaysAutoLaunches() {
val d = AppLinkClassifier.classify(
@@ -13,6 +13,7 @@ import kotlin.test.assertEquals
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
class AppLinkLauncherTest {
@@ -69,6 +70,33 @@ class AppLinkLauncherTest {
assertEquals(1, started)
}
@Test
fun authenticationLaunchUsesClearTopFlags() {
val intent = mock(Intent::class.java)
val resolved = ResolvedAppLink(
hasExternalApp = true,
appIntent = intent,
packageName = "com.example.app",
appName = "App",
fallbackUrl = null,
marketplaceIntent = null,
isAmbiguous = false,
engineSupportsScheme = false,
scopeKey = "pkg:com.example.app",
originalScheme = "example",
intentDataScheme = "example",
)
var startedIntent: Intent? = null
val l = launcher(resolved, FakeClock()) { startedIntent = it }
assertEquals(
AppLinkLaunchResult.LAUNCHED,
l.launch("example://callback", AppLinkLaunchMode.AUTHENTICATION),
)
verify(intent).flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
assertEquals(intent, startedIntent)
}
@Test
fun automaticLaunchWithinCooldownIsRefused() {
val clock = FakeClock(1000L)
@@ -78,6 +106,22 @@ class AppLinkLauncherTest {
assertEquals(AppLinkLaunchResult.COOLDOWN, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
}
@Test
fun authenticationLaunchWithinCooldownIsRefused() {
val clock = FakeClock(1000L)
val l = launcher(resolvedFor("com.example.app"), clock)
assertEquals(
AppLinkLaunchResult.LAUNCHED,
l.launch("zoommtg://x", AppLinkLaunchMode.AUTHENTICATION),
)
// An app that re-opens its Custom Tab on receiving the callback would otherwise ping-pong.
clock.now = 1500L // < 2000 ms later
assertEquals(
AppLinkLaunchResult.COOLDOWN,
l.launch("zoommtg://x", AppLinkLaunchMode.AUTHENTICATION),
)
}
@Test
fun automaticLaunchAfterCooldownSucceeds() {
val clock = FakeClock(1000L)
@@ -103,33 +103,82 @@ class PendingAppLinkStoreTest {
}
@Test
fun bannerTargetCommitKeepsRequestButUnrelatedCommitInvalidates() {
val store = PendingAppLinkStore(FakeClock())
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://youtu.be/x"),
fun aNewerBannerForTheTabReplacesTheOlderOne() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock)
val first = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
)
// The banner's own target committing keeps it alive.
store.onCommittedNavigation("tab1", "https://youtu.be/x")
assertNotNull(store.peek(banner.requestId))
// An unrelated commit invalidates it.
store.onCommittedNavigation("tab1", "https://example.com/other")
assertNull(store.peek(banner.requestId))
clock.now = 5000L // past the dedupe window, so this is a genuinely new offer
val second = store.createRequest(
newRequest(
urlClass = AppLinkUrlClass.BANNER,
url = "https://b.example/y",
fingerprint = "fp2",
),
)
// One live banner per tab: visiting a second app-link site replaces the offer rather than
// stacking behind it.
assertNull(store.peek(first.requestId))
assertNotNull(store.peek(second.requestId))
assertEquals(1, store.getPending(AppLinkPromptOwner.FLUTTER_BROWSER).size)
}
@Test
fun bannerSurvivesSameSiteRedirectAndNormalisation() {
val store = PendingAppLinkStore(FakeClock())
// The intercepted URL is rarely byte-identical to the committed one: the initial
// load redirects/normalises (www stripped, tracking params added, trailing slash).
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://www.reddit.com/r/foo"),
fun aBannerForAnotherTabIsUntouched() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock)
val other = store.createRequest(
newRequest(tabId = "tab2", urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
)
store.onCommittedNavigation("tab1", "https://reddit.com/r/foo/?utm_source=share")
assertNotNull(store.peek(banner.requestId))
clock.now = 5000L
store.createRequest(
newRequest(tabId = "tab1", urlClass = AppLinkUrlClass.BANNER, url = "https://b.example/y"),
)
assertNotNull(store.peek(other.requestId))
}
// A commit to a genuinely different site still invalidates it.
store.onCommittedNavigation("tab1", "https://twitter.com/reddit")
@Test
fun bannersExpireSoonerThanModals() {
val clock = FakeClock()
val store = PendingAppLinkStore(
clock,
requestExpiryMs = 10_000L,
bannerExpiryMs = 1_000L,
)
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
)
val modal = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.MODAL, fingerprint = "fp-modal"),
)
// The banner is a passive offer bounded by time; the modal is holding a navigation open
// and keeps the long window.
clock.now = 1001L
assertNull(store.peek(banner.requestId))
assertNotNull(store.peek(modal.requestId))
clock.now = 10_001L
assertNull(store.peek(modal.requestId))
}
@Test
fun remainingTtlIsReportedSoTheSurfaceCanRetireThePromptOnTime() {
val clock = FakeClock()
val store = PendingAppLinkStore(clock, bannerExpiryMs = 1_000L)
val banner = store.createRequest(
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
)
assertEquals(1_000L, store.expiresInMs(banner))
clock.now = 400L
assertEquals(600L, store.expiresInMs(banner))
// Never negative: a surface schedules on this value, and expiry itself is lazy.
clock.now = 5_000L
assertEquals(0L, store.expiresInMs(banner))
}
@Test
@@ -137,7 +186,7 @@ class PendingAppLinkStoreTest {
val store = PendingAppLinkStore(FakeClock())
val request = store.createRequest(newRequest())
store.recordSuppression("tab1", "fp1")
store.invalidateTab("tab1")
assertEquals(setOf(AppLinkPromptOwner.FLUTTER_BROWSER), store.invalidateTab("tab1"))
assertNull(store.peek(request.requestId))
assertFalse(store.isSuppressed("tab1", "fp1"))
}
@@ -148,8 +197,7 @@ class PendingAppLinkStoreTest {
val store = PendingAppLinkStore(clock, suppressionExpiryMs = 1000L)
store.recordSuppression("tab1", "fp1")
assertTrue(store.isSuppressed("tab1", "fp1"))
// Ordinary committed navigation does not clear it.
store.onCommittedNavigation("tab1", "https://redirect.example")
// A redirect within the current load does not clear it (no load start is dispatched).
assertTrue(store.isSuppressed("tab1", "fp1"))
// Direct navigation clears it.
store.clearSuppressionForTab("tab1")
@@ -16,12 +16,24 @@ import 'package:flutter/services.dart';
import 'package:flutter_mozilla_components/src/domain/services/gecko_browser.dart';
class GeckoView extends StatefulWidget {
final Future<void> Function()? preInitializationStep;
/// Whether the native container backing this platform view is attached to the
/// window, as reported by `NativeFragmentView`.
///
/// The browser fragment can only be attached while this holds `true`, and the
/// container is only inserted into the Flutter view hierarchy once the
/// platform-view layer is first composited — which an ancestor that lays the
/// view out without painting it (`Offstage`) defers for as long as it stays
/// offstage. Every attach attempt is therefore driven by this stream rather
/// than by a single burst of retries after creation, which would otherwise
/// expire while the container is still unreachable and never run again.
/// See https://github.com/FaFre/WebLibre/issues/557.
final Stream<bool> viewReadyEvents;
final Future<void> Function()? postInitializationStep;
const GeckoView({
super.key,
this.preInitializationStep,
required this.viewReadyEvents,
this.postInitializationStep,
});
@@ -36,17 +48,58 @@ class _GeckoViewState extends State<GeckoView> {
final browserService = GeckoBrowserService();
late final AppLifecycleListener _listener;
StreamSubscription<bool>? _viewReadySubscription;
/// Serialises attach attempts.
///
/// The container can be reported attached while an earlier attempt is still
/// retrying, and two concurrent attempts would both find no usable fragment
/// and race to replace each other's.
Future<void> _attachQueue = Future<void>.value();
@override
void initState() {
super.initState();
_setupMethodCallHandler();
_listener = AppLifecycleListener(
onResume: () async {
//Make sure fragment visible after rsuming the app in case native resources have been disposed
await _showNativeFragment();
onResume: () {
//Make sure fragment visible after resuming the app in case native resources have been disposed
unawaited(_enqueueShowNativeFragment());
},
);
_viewReadySubscription = widget.viewReadyEvents
.where((ready) => ready)
.listen((_) => unawaited(_enqueueShowNativeFragment()));
}
/// Queues an attach attempt behind any that is still running.
///
/// Returns when this attempt is done, so callers that need to sequence work
/// after it can await it; failures are contained so one bad attempt cannot
/// poison the queue for the ones the ready stream triggers later.
Future<void> _enqueueShowNativeFragment() {
final attempt = _attachQueue.then((_) async {
if (!mounted) {
return;
}
try {
await _showNativeFragment();
} catch (error, stackTrace) {
developer.log(
'Fragment attach attempt failed',
name: 'GeckoView',
level: 900,
error: error,
stackTrace: stackTrace,
);
}
});
_attachQueue = attempt;
return attempt;
}
void _setupMethodCallHandler() {
@@ -57,8 +110,15 @@ class _GeckoViewState extends State<GeckoView> {
});
}
/// Attaches the browser fragment to the native container.
///
/// The retries cover the transient reasons an attach can fail once the
/// container is reachable — a saved fragment-manager state, a frame in which
/// the fragment's view has no size yet. They deliberately do *not* cover
/// waiting for the container to appear in the first place: that wait is
/// unbounded, and [viewReadyEvents] reports it instead.
Future<bool> _showNativeFragment({
int maxRetries = 100,
int maxRetries = 10,
/// Default ist about one frame
Duration retryDelay = const Duration(milliseconds: 1000 ~/ 60),
@@ -90,6 +150,7 @@ class _GeckoViewState extends State<GeckoView> {
@override
void dispose() {
platform.setMethodCallHandler(null);
unawaited(_viewReadySubscription?.cancel());
_listener.dispose();
super.dispose();
@@ -118,9 +179,11 @@ class _GeckoViewState extends State<GeckoView> {
params.onPlatformViewCreated(value);
SchedulerBinding.instance.addPostFrameCallback((_) async {
await widget.preInitializationStep?.call();
await _showNativeFragment();
// A first attempt for the common case where the view is painted
// from the frame it is created in, so the container is already
// attached by now. When it is not, this attempt is cheap and the
// ready subscription takes over as soon as it becomes attached.
await _enqueueShowNativeFragment();
await widget.postInitializationStep?.call();
});
})
File diff suppressed because it is too large Load Diff
@@ -2994,6 +2994,10 @@ class AppLinkPolicySnapshot {
final bool marketplaceFallbackEnabled;
/// Allows same-caller Custom Tab / ActionView authentication callbacks to
/// return to their app even when the general app-link mode is `never`.
final bool authExceptionsEnabled;
/// Regular / no-contextId tabs are proxied via the `general` scope.
final bool protectGeneralContext;
@@ -3014,6 +3018,7 @@ class AppLinkPolicySnapshot {
required this.globalMode,
required this.rules,
required this.marketplaceFallbackEnabled,
required this.authExceptionsEnabled,
required this.protectGeneralContext,
required this.protectedContextIds,
required this.strictContextIds,
@@ -3045,6 +3050,12 @@ class AppLinkPromptRequest {
final bool isModal;
final AppLinkTarget target;
/// Milliseconds until the native store drops this request, measured at query
/// time. The surface showing it must stop offering it by then: resolving an
/// expired request is a no-op, so a prompt left on screen past this becomes a
/// button that silently does nothing.
final int expiresInMs;
const AppLinkPromptRequest({
required this.requestId,
required this.owner,
@@ -3057,6 +3068,7 @@ class AppLinkPromptRequest {
required this.canRemember,
required this.isModal,
required this.target,
required this.expiresInMs,
});
}
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -34,11 +34,8 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull;
}
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
if (empty) {
return <Object?>[];
}
@@ -47,7 +44,6 @@ List<Object?> wrapResponse({
}
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
@@ -60,9 +56,8 @@ bool _deepEquals(Object? a, Object? b) {
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
if (a.length != b.length) {
@@ -111,6 +106,7 @@ int _deepHash(Object? value) {
return value.hashCode;
}
enum SingboxProxyProfileType {
socks,
http,
@@ -129,7 +125,13 @@ enum SingboxProxyProfileType {
customOutbound,
}
enum SingboxProxyRuntimeStatus { stopped, starting, running, stopping, error }
enum SingboxProxyRuntimeStatus {
stopped,
starting,
running,
stopping,
error,
}
class SingboxProxyProfile {
SingboxProxyProfile({
@@ -155,12 +157,17 @@ class SingboxProxyProfile {
String? secretJson;
List<Object?> _toList() {
return <Object?>[id, name, type, configJson, secretJson];
return <Object?>[
id,
name,
type,
configJson,
secretJson,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static SingboxProxyProfile decode(Object result) {
result as List<Object?>;
@@ -182,11 +189,7 @@ class SingboxProxyProfile {
if (identical(this, other)) {
return true;
}
return _deepEquals(id, other.id) &&
_deepEquals(name, other.name) &&
_deepEquals(type, other.type) &&
_deepEquals(configJson, other.configJson) &&
_deepEquals(secretJson, other.secretJson);
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(type, other.type) && _deepEquals(configJson, other.configJson) && _deepEquals(secretJson, other.secretJson);
}
@override
@@ -236,8 +239,7 @@ class SingboxProxyRuntimeOptions {
}
Object encode() {
return _toList();
}
return _toList(); }
static SingboxProxyRuntimeOptions decode(Object result) {
result as List<Object?>;
@@ -252,17 +254,13 @@ class SingboxProxyRuntimeOptions {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyRuntimeOptions ||
other.runtimeType != runtimeType) {
if (other is! SingboxProxyRuntimeOptions || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(preferredBasePort, other.preferredBasePort) &&
_deepEquals(blockUnmatchedTraffic, other.blockUnmatchedTraffic) &&
_deepEquals(dnsConfig, other.dnsConfig) &&
_deepEquals(bootstrapDohUrl, other.bootstrapDohUrl);
return _deepEquals(preferredBasePort, other.preferredBasePort) && _deepEquals(blockUnmatchedTraffic, other.blockUnmatchedTraffic) && _deepEquals(dnsConfig, other.dnsConfig) && _deepEquals(bootstrapDohUrl, other.bootstrapDohUrl);
}
@override
@@ -333,8 +331,7 @@ class SingboxProxyDnsServerConfig {
}
Object encode() {
return _toList();
}
return _toList(); }
static SingboxProxyDnsServerConfig decode(Object result) {
result as List<Object?>;
@@ -352,20 +349,13 @@ class SingboxProxyDnsServerConfig {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyDnsServerConfig ||
other.runtimeType != runtimeType) {
if (other is! SingboxProxyDnsServerConfig || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(tag, other.tag) &&
_deepEquals(address, other.address) &&
_deepEquals(detourTag, other.detourTag) &&
_deepEquals(matchDomainSuffixes, other.matchDomainSuffixes) &&
_deepEquals(matchGeosites, other.matchGeosites) &&
_deepEquals(matchOutbounds, other.matchOutbounds) &&
_deepEquals(matchInbounds, other.matchInbounds);
return _deepEquals(tag, other.tag) && _deepEquals(address, other.address) && _deepEquals(detourTag, other.detourTag) && _deepEquals(matchDomainSuffixes, other.matchDomainSuffixes) && _deepEquals(matchGeosites, other.matchGeosites) && _deepEquals(matchOutbounds, other.matchOutbounds) && _deepEquals(matchInbounds, other.matchInbounds);
}
@override
@@ -395,18 +385,20 @@ class SingboxProxyDnsConfig {
String domainStrategy;
List<Object?> _toList() {
return <Object?>[servers, finalServerTag, domainStrategy];
return <Object?>[
servers,
finalServerTag,
domainStrategy,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static SingboxProxyDnsConfig decode(Object result) {
result as List<Object?>;
return SingboxProxyDnsConfig(
servers: (result[0]! as List<Object?>)
.cast<SingboxProxyDnsServerConfig>(),
servers: (result[0]! as List<Object?>).cast<SingboxProxyDnsServerConfig>(),
finalServerTag: result[1] as String?,
domainStrategy: result[2]! as String,
);
@@ -421,9 +413,7 @@ class SingboxProxyDnsConfig {
if (identical(this, other)) {
return true;
}
return _deepEquals(servers, other.servers) &&
_deepEquals(finalServerTag, other.finalServerTag) &&
_deepEquals(domainStrategy, other.domainStrategy);
return _deepEquals(servers, other.servers) && _deepEquals(finalServerTag, other.finalServerTag) && _deepEquals(domainStrategy, other.domainStrategy);
}
@override
@@ -456,12 +446,17 @@ class SingboxProxyRuntimeEndpoint {
String password;
List<Object?> _toList() {
return <Object?>[profileId, host, port, username, password];
return <Object?>[
profileId,
host,
port,
username,
password,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static SingboxProxyRuntimeEndpoint decode(Object result) {
result as List<Object?>;
@@ -477,18 +472,13 @@ class SingboxProxyRuntimeEndpoint {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyRuntimeEndpoint ||
other.runtimeType != runtimeType) {
if (other is! SingboxProxyRuntimeEndpoint || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(profileId, other.profileId) &&
_deepEquals(host, other.host) &&
_deepEquals(port, other.port) &&
_deepEquals(username, other.username) &&
_deepEquals(password, other.password);
return _deepEquals(profileId, other.profileId) && _deepEquals(host, other.host) && _deepEquals(port, other.port) && _deepEquals(username, other.username) && _deepEquals(password, other.password);
}
@override
@@ -515,19 +505,21 @@ class SingboxProxyRuntimeState {
String? message;
List<Object?> _toList() {
return <Object?>[status, endpoints, message];
return <Object?>[
status,
endpoints,
message,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static SingboxProxyRuntimeState decode(Object result) {
result as List<Object?>;
return SingboxProxyRuntimeState(
status: result[0]! as SingboxProxyRuntimeStatus,
endpoints: (result[1]! as List<Object?>)
.cast<SingboxProxyRuntimeEndpoint>(),
endpoints: (result[1]! as List<Object?>).cast<SingboxProxyRuntimeEndpoint>(),
message: result[2] as String?,
);
}
@@ -535,16 +527,13 @@ class SingboxProxyRuntimeState {
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyRuntimeState ||
other.runtimeType != runtimeType) {
if (other is! SingboxProxyRuntimeState || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(status, other.status) &&
_deepEquals(endpoints, other.endpoints) &&
_deepEquals(message, other.message);
return _deepEquals(status, other.status) && _deepEquals(endpoints, other.endpoints) && _deepEquals(message, other.message);
}
@override
@@ -558,41 +547,43 @@ class SingboxProxyRuntimeState {
}
class SingboxProxyConfigResult {
SingboxProxyConfigResult({required this.configJson, required this.endpoints});
SingboxProxyConfigResult({
required this.configJson,
required this.endpoints,
});
String configJson;
List<SingboxProxyRuntimeEndpoint> endpoints;
List<Object?> _toList() {
return <Object?>[configJson, endpoints];
return <Object?>[
configJson,
endpoints,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static SingboxProxyConfigResult decode(Object result) {
result as List<Object?>;
return SingboxProxyConfigResult(
configJson: result[0]! as String,
endpoints: (result[1]! as List<Object?>)
.cast<SingboxProxyRuntimeEndpoint>(),
endpoints: (result[1]! as List<Object?>).cast<SingboxProxyRuntimeEndpoint>(),
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyConfigResult ||
other.runtimeType != runtimeType) {
if (other is! SingboxProxyConfigResult || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(configJson, other.configJson) &&
_deepEquals(endpoints, other.endpoints);
return _deepEquals(configJson, other.configJson) && _deepEquals(endpoints, other.endpoints);
}
@override
@@ -622,12 +613,16 @@ class SingboxProxyLogMessage {
String? profileId;
List<Object?> _toList() {
return <Object?>[level, message, timestamp, profileId];
return <Object?>[
level,
message,
timestamp,
profileId,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static SingboxProxyLogMessage decode(Object result) {
result as List<Object?>;
@@ -648,10 +643,7 @@ class SingboxProxyLogMessage {
if (identical(this, other)) {
return true;
}
return _deepEquals(level, other.level) &&
_deepEquals(message, other.message) &&
_deepEquals(timestamp, other.timestamp) &&
_deepEquals(profileId, other.profileId);
return _deepEquals(level, other.level) && _deepEquals(message, other.message) && _deepEquals(timestamp, other.timestamp) && _deepEquals(profileId, other.profileId);
}
@override
@@ -664,6 +656,7 @@ class SingboxProxyLogMessage {
}
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -671,34 +664,34 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is SingboxProxyProfileType) {
} else if (value is SingboxProxyProfileType) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is SingboxProxyRuntimeStatus) {
} else if (value is SingboxProxyRuntimeStatus) {
buffer.putUint8(130);
writeValue(buffer, value.index);
} else if (value is SingboxProxyProfile) {
} else if (value is SingboxProxyProfile) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyRuntimeOptions) {
} else if (value is SingboxProxyRuntimeOptions) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyDnsServerConfig) {
} else if (value is SingboxProxyDnsServerConfig) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyDnsConfig) {
} else if (value is SingboxProxyDnsConfig) {
buffer.putUint8(134);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyRuntimeEndpoint) {
} else if (value is SingboxProxyRuntimeEndpoint) {
buffer.putUint8(135);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyRuntimeState) {
} else if (value is SingboxProxyRuntimeState) {
buffer.putUint8(136);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyConfigResult) {
} else if (value is SingboxProxyConfigResult) {
buffer.putUint8(137);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyLogMessage) {
} else if (value is SingboxProxyLogMessage) {
buffer.putUint8(138);
writeValue(buffer, value.encode());
} else {
@@ -741,13 +734,9 @@ class SingboxProxyApi {
/// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
SingboxProxyApi({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
SingboxProxyApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -755,97 +744,82 @@ class SingboxProxyApi {
final String pigeonVar_messageChannelSuffix;
Future<String?> validateProfile(SingboxProxyProfile profile) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[profile],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[profile]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
return pigeonVar_replyValue as String?;
}
Future<SingboxProxyConfigResult> buildConfig(
List<SingboxProxyProfile> profiles,
SingboxProxyRuntimeOptions options,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$pigeonVar_messageChannelSuffix';
Future<SingboxProxyConfigResult> buildConfig(List<SingboxProxyProfile> profiles, SingboxProxyRuntimeOptions options) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[profiles, options],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[profiles, options]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as SingboxProxyConfigResult;
}
Future<SingboxProxyRuntimeState> start(
List<SingboxProxyProfile> profiles,
SingboxProxyRuntimeOptions options,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$pigeonVar_messageChannelSuffix';
Future<SingboxProxyRuntimeState> start(List<SingboxProxyProfile> profiles, SingboxProxyRuntimeOptions options) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[profiles, options],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[profiles, options]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
}
Future<void> stop(List<String> profileIds) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[profileIds],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[profileIds]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
Future<void> stopAll() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -855,15 +829,15 @@ class SingboxProxyApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
Future<SingboxProxyRuntimeState> getState() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -873,10 +847,11 @@ class SingboxProxyApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
}
}
@@ -888,62 +863,46 @@ abstract class SingboxProxyEventsApi {
void onLogMessage(SingboxProxyLogMessage message);
static void setUp(
SingboxProxyEventsApi? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
static void setUp(SingboxProxyEventsApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
final List<Object?> args = message! as List<Object?>;
final SingboxProxyRuntimeState arg_state =
args[0]! as SingboxProxyRuntimeState;
final SingboxProxyRuntimeState arg_state = args[0]! as SingboxProxyRuntimeState;
try {
api.onStateChanged(arg_state);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
}
});
}
}
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
final List<Object?> args = message! as List<Object?>;
final SingboxProxyLogMessage arg_message =
args[0]! as SingboxProxyLogMessage;
final SingboxProxyLogMessage arg_message = args[0]! as SingboxProxyLogMessage;
try {
api.onLogMessage(arg_message);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
}
});
}
+78 -123
View File
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -34,11 +34,8 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull;
}
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
if (empty) {
return <Object?>[];
}
@@ -47,7 +44,6 @@ List<Object?> wrapResponse({
}
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
@@ -60,9 +56,8 @@ bool _deepEquals(Object? a, Object? b) {
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
if (a.length != b.length) {
@@ -111,29 +106,23 @@ int _deepHash(Object? value) {
return value.hashCode;
}
/// Transport types for Tor connections
enum TransportType {
/// Direct Tor connection (no bridges)
none,
/// obfs4 pluggable transport
obfs4,
/// Snowflake pluggable transport (default broker)
snowflake,
/// Snowflake via AMP cache
snowflakeAmp,
/// Meek pluggable transport
meek,
/// Meek via Azure CDN
meekAzure,
/// WebTunnel pluggable transport
webtunnel,
/// Custom bridge lines (passthrough)
custom,
}
@@ -174,8 +163,7 @@ class TorConfiguration {
}
Object encode() {
return _toList();
}
return _toList(); }
static TorConfiguration decode(Object result) {
result as List<Object?>;
@@ -197,11 +185,7 @@ class TorConfiguration {
if (identical(this, other)) {
return true;
}
return _deepEquals(transport, other.transport) &&
_deepEquals(bridgeLines, other.bridgeLines) &&
_deepEquals(entryNodeCountries, other.entryNodeCountries) &&
_deepEquals(exitNodeCountries, other.exitNodeCountries) &&
_deepEquals(strictNodes, other.strictNodes);
return _deepEquals(transport, other.transport) && _deepEquals(bridgeLines, other.bridgeLines) && _deepEquals(entryNodeCountries, other.entryNodeCountries) && _deepEquals(exitNodeCountries, other.exitNodeCountries) && _deepEquals(strictNodes, other.strictNodes);
}
@override
@@ -250,8 +234,7 @@ class TorStatus {
}
Object encode() {
return _toList();
}
return _toList(); }
static TorStatus decode(Object result) {
result as List<Object?>;
@@ -273,11 +256,7 @@ class TorStatus {
if (identical(this, other)) {
return true;
}
return _deepEquals(isRunning, other.isRunning) &&
_deepEquals(socksPort, other.socksPort) &&
_deepEquals(bootstrapProgress, other.bootstrapProgress) &&
_deepEquals(currentCircuit, other.currentCircuit) &&
_deepEquals(exitNodeCountry, other.exitNodeCountry);
return _deepEquals(isRunning, other.isRunning) && _deepEquals(socksPort, other.socksPort) && _deepEquals(bootstrapProgress, other.bootstrapProgress) && _deepEquals(currentCircuit, other.currentCircuit) && _deepEquals(exitNodeCountry, other.exitNodeCountry);
}
@override
@@ -308,12 +287,15 @@ class TorLogMessage {
int timestamp;
List<Object?> _toList() {
return <Object?>[severity, message, timestamp];
return <Object?>[
severity,
message,
timestamp,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static TorLogMessage decode(Object result) {
result as List<Object?>;
@@ -333,9 +315,7 @@ class TorLogMessage {
if (identical(this, other)) {
return true;
}
return _deepEquals(severity, other.severity) &&
_deepEquals(message, other.message) &&
_deepEquals(timestamp, other.timestamp);
return _deepEquals(severity, other.severity) && _deepEquals(message, other.message) && _deepEquals(timestamp, other.timestamp);
}
@override
@@ -348,6 +328,7 @@ class TorLogMessage {
}
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -355,16 +336,16 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is TransportType) {
} else if (value is TransportType) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is TorConfiguration) {
} else if (value is TorConfiguration) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is TorStatus) {
} else if (value is TorStatus) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is TorLogMessage) {
} else if (value is TorLogMessage) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else {
@@ -396,10 +377,8 @@ class TorApi {
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -409,30 +388,27 @@ class TorApi {
/// Start Tor with the given configuration
/// Returns a Future to avoid blocking the main thread
Future<int> startTor(TorConfiguration config) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[config],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[config]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as int;
}
/// Stop Tor
Future<void> stopTor() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -442,16 +418,16 @@ class TorApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
/// Get current status
Future<TorStatus> getStatus() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -461,17 +437,17 @@ class TorApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as TorStatus;
}
/// Request a new Tor identity (new circuit)
Future<void> requestNewIdentity() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -481,10 +457,11 @@ class TorApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
}
@@ -498,20 +475,12 @@ abstract class TorLogApi {
/// Called when status changes
void onStatusChanged(TorStatus status);
static void setUp(
TorLogApi? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
static void setUp(TorLogApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
@@ -523,20 +492,16 @@ abstract class TorLogApi {
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
}
});
}
}
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
@@ -548,10 +513,8 @@ abstract class TorLogApi {
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
}
});
}
@@ -563,13 +526,9 @@ class IPtProxyController {
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
IPtProxyController({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -577,43 +536,39 @@ class IPtProxyController {
final String pigeonVar_messageChannelSuffix;
Future<int> start(TransportType proxyType, String proxy) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[proxyType, proxy],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType, proxy]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as int;
}
Future<void> stop(TransportType proxyType) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[proxyType],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
}
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -46,9 +46,8 @@ bool _deepEquals(Object? a, Object? b) {
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
if (a.length != b.length) {
@@ -97,20 +96,26 @@ int _deepHash(Object? value) {
return value.hashCode;
}
class LocalizedResult {
LocalizedResult({required this.languageName, this.countryName});
LocalizedResult({
required this.languageName,
this.countryName,
});
String languageName;
String? countryName;
List<Object?> _toList() {
return <Object?>[languageName, countryName];
return <Object?>[
languageName,
countryName,
];
}
Object encode() {
return _toList();
}
return _toList(); }
static LocalizedResult decode(Object result) {
result as List<Object?>;
@@ -129,8 +134,7 @@ class LocalizedResult {
if (identical(this, other)) {
return true;
}
return _deepEquals(languageName, other.languageName) &&
_deepEquals(countryName, other.countryName);
return _deepEquals(languageName, other.languageName) && _deepEquals(countryName, other.countryName);
}
@override
@@ -143,6 +147,7 @@ class LocalizedResult {
}
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -150,7 +155,7 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is LocalizedResult) {
} else if (value is LocalizedResult) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
@@ -173,40 +178,31 @@ class LocaleResolver {
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
LocaleResolver({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<LocalizedResult> resolve(
String languageTag,
String targetLangouageTag,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
Future<LocalizedResult> resolve(String languageTag, String targetLangouageTag) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[languageTag, targetLangouageTag],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[languageTag, targetLangouageTag]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as LocalizedResult;
}
}
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -34,11 +34,8 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull;
}
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
if (empty) {
return <Object?>[];
}
@@ -47,7 +44,6 @@ List<Object?> wrapResponse({
}
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
@@ -60,9 +56,8 @@ bool _deepEquals(Object? a, Object? b) {
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
if (a.length != b.length) {
@@ -111,6 +106,7 @@ int _deepHash(Object? value) {
return value.hashCode;
}
class Intent {
Intent({
this.fromPackageName,
@@ -145,8 +141,7 @@ class Intent {
}
Object encode() {
return _toList();
}
return _toList(); }
static Intent decode(Object result) {
result as List<Object?>;
@@ -169,12 +164,7 @@ class Intent {
if (identical(this, other)) {
return true;
}
return _deepEquals(fromPackageName, other.fromPackageName) &&
_deepEquals(action, other.action) &&
_deepEquals(data, other.data) &&
_deepEquals(categories, other.categories) &&
_deepEquals(mimeType, other.mimeType) &&
_deepEquals(extra, other.extra);
return _deepEquals(fromPackageName, other.fromPackageName) && _deepEquals(action, other.action) && _deepEquals(data, other.data) && _deepEquals(categories, other.categories) && _deepEquals(mimeType, other.mimeType) && _deepEquals(extra, other.extra);
}
@override
@@ -187,6 +177,7 @@ class Intent {
}
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -194,7 +185,7 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is Intent) {
} else if (value is Intent) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
@@ -217,13 +208,9 @@ class IntentHost {
/// Constructor for [IntentHost]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
IntentHost({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -235,8 +222,7 @@ class IntentHost {
/// IntentEvents.setUp() was called (cold-start deep links).
/// Returns null if no launch intent is pending.
Future<Intent?> getInitialIntent() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -246,10 +232,11 @@ class IntentHost {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
return pigeonVar_replyValue as Intent?;
}
}
@@ -259,20 +246,12 @@ abstract class IntentEvents {
void onIntentReceived(int sequence, Intent intent);
static void setUp(
IntentEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
static void setUp(IntentEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
@@ -285,10 +264,8 @@ abstract class IntentEvents {
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
}
});
}
@@ -300,13 +277,9 @@ class IntentGatekeeperHostApi {
/// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
IntentGatekeeperHostApi({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -316,23 +289,21 @@ class IntentGatekeeperHostApi {
/// Replicates the blocked-packages policy to the native side so the
/// [IntentReceiverActivity] can reject intents without launching Flutter.
Future<void> setConfig(bool enabled, List<String> blockedPackages) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[enabled, blockedPackages],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled, blockedPackages]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
/// Replicates whether the Custom Tabs feature is enabled to the native side.
@@ -340,46 +311,42 @@ class IntentGatekeeperHostApi {
/// share-with-URL intents to the main browser instead of launching the
/// stripped-down custom-tab activity. Defaults to enabled on the native side.
Future<void> setCustomTabsEnabled(bool enabled) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setCustomTabsEnabled$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setCustomTabsEnabled$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[enabled],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
/// Resolves a package name to its user-visible application label via
/// [PackageManager]. Returns `null` if the package is not installed or the
/// label cannot be resolved.
Future<String?> resolvePackageLabel(String packageName) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[packageName],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[packageName]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
return pigeonVar_replyValue as String?;
}
@@ -389,8 +356,7 @@ class IntentGatekeeperHostApi {
/// [ackPendingAlwaysAllows] after Flutter settings were updated
/// successfully.
Future<List<String>> getPendingAlwaysAllows() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -400,32 +366,31 @@ class IntentGatekeeperHostApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
}
/// Removes the given packages from the pending "Always allow" set after
/// Flutter has successfully persisted them into its own policy store.
Future<void> ackPendingAlwaysAllows(List<String> packageNames) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.ackPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.ackPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[packageNames],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[packageNames]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
}
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -34,11 +34,8 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull;
}
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
if (empty) {
return <Object?>[];
}
@@ -48,6 +45,7 @@ List<Object?> wrapResponse({
return <Object?>[error.code, error.message, error.details];
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -74,13 +72,9 @@ class SpeechToTextApi {
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
SpeechToTextApi({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -95,23 +89,21 @@ class SpeechToTextApi {
/// The [locale] parameter specifies the language locale for recognition
/// (e.g., 'en-US', 'de-DE'). If null, uses the device default.
Future<bool> showDialog({String? locale}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName = 'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[locale],
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[locale]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
return pigeonVar_replyValue! as bool;
}
}
@@ -126,20 +118,12 @@ abstract class SpeechToTextEvents {
/// recognition failed or was cancelled.
void onTextReceived(String text);
static void setUp(
SpeechToTextEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
static void setUp(SpeechToTextEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
@@ -151,10 +135,8 @@ abstract class SpeechToTextEvents {
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
}
});
}
+7
View File
@@ -106,3 +106,10 @@ melos:
set -e
cd apps/weblibre
flutter build apk --release --flavor alphaLegacy --target-platform android-arm,android-arm64 --split-per-abi --no-tree-shake-icons
build-browser-pixel10:
description: Build and verify the ARM64-only MrbWebLibre release for Google Pixel 10
run: |
set -e
cd apps/weblibre
flutter build apk --release --flavor pixel10 --target-platform android-arm64 --no-tree-shake-icons
../../scripts/verify-pixel10-apk.sh build/app/outputs/flutter-apk/app-pixel10-release.apk
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
readonly REQUIRED_PAGE_SIZE=$((16 * 1024))
usage() {
echo "Usage: $0 path/to/app-pixel10-release.apk" >&2
}
if [[ $# -ne 1 ]]; then
usage
exit 2
fi
apk="$1"
if [[ ! -f "$apk" ]]; then
echo "APK not found: $apk" >&2
exit 1
fi
for command_name in unzip readelf; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Required command not found: $command_name" >&2
exit 1
fi
done
native_entries="$(unzip -Z1 "$apk" | sed -n '/^lib\/.*\.so$/p')"
if [[ -z "$native_entries" ]]; then
echo "No native libraries found in $apk" >&2
exit 1
fi
unexpected_abis=()
while IFS= read -r entry; do
if [[ "$entry" != lib/arm64-v8a/* ]]; then
unexpected_abis+=("$entry")
fi
done <<< "$native_entries"
if [[ ${#unexpected_abis[@]} -gt 0 ]]; then
echo "Pixel 10 APK contains non-ARM64 native libraries:" >&2
printf ' %s\n' "${unexpected_abis[@]}" >&2
exit 1
fi
temporary_root="${TMPDIR:-/tmp}"
if [[ ! -d "$temporary_root" || ! -w "$temporary_root" ]]; then
temporary_root="$PWD"
fi
temporary_directory="$(mktemp -d "$temporary_root/mrbweblibre-verify.XXXXXX")"
trap 'rm -rf -- "$temporary_directory"' EXIT
unzip -qq "$apk" 'lib/arm64-v8a/*.so' -d "$temporary_directory"
failures=0
checked=0
native_library_list="$temporary_directory/native-libraries.list"
find "$temporary_directory/lib/arm64-v8a" -type f -name '*.so' -print0 > "$native_library_list"
while IFS= read -r -d '' library; do
checked=$((checked + 1))
machine="$(readelf -hW "$library" | sed -n 's/^[[:space:]]*Machine:[[:space:]]*//p')"
if [[ "$machine" != "AArch64" ]]; then
echo "Architecture failure: ${library#"$temporary_directory"/} is $machine, expected AArch64" >&2
failures=$((failures + 1))
continue
fi
load_alignments="$(readelf -lW "$library" | awk '$1 == "LOAD" { print $NF }')"
if [[ -z "$load_alignments" ]]; then
echo "No ELF LOAD segments found: ${library#"$temporary_directory"/}" >&2
failures=$((failures + 1))
continue
fi
while IFS= read -r alignment; do
if (( alignment < REQUIRED_PAGE_SIZE )); then
echo "16 KB alignment failure: ${library#"$temporary_directory"/} has LOAD alignment $alignment" >&2
failures=$((failures + 1))
break
fi
done <<< "$load_alignments"
done < "$native_library_list"
if (( failures > 0 )); then
echo "Pixel 10 compatibility verification failed ($failures issue(s))." >&2
exit 1
fi
echo "Pixel 10 APK verified: ARM64-only; $checked native libraries use 16 KB-compatible ELF alignment."