Author SHA1 Message Date
Codex aab2bc0cee perf(tabs): release stale image and tab state
Pixel 10 APK / build (push) Successful in 37m12s
2026-08-10 17:59:50 +02:00
Codex 03227320f4 perf(android): move thumbnail encoding off UI path 2026-08-10 17:59:42 +02:00
Codex 4a5a01dc97 fix(ci): use Gitea artifact protocol
Pixel 10 APK / build (push) Successful in 36m15s
2026-08-10 08:34:02 +02:00
Codex 01dcb4705d fix(android): let Flutter own Pixel 10 ABI split
Pixel 10 APK / build (push) Failing after 33m19s
2026-08-10 07:59:11 +02:00
Codex 6508dfa8a7 fix(android): emit ARM64-only Pixel 10 APK
Pixel 10 APK / build (push) Failing after 10m35s
2026-08-10 07:47:26 +02:00
Codex ac4a267d30 fix(ci): size Android heap from measured usage
Pixel 10 APK / build (push) Failing after 32m39s
2026-08-10 07:12:29 +02:00
Codex b4afa40c19 fix(ci): bound and slim Pixel 10 build
Pixel 10 APK / build (push) Failing after 35m20s
2026-08-10 06:35:51 +02:00
Codex 96a0334a71 fix(ci): constrain Android build resources 2026-08-10 06:34:02 +02:00
Codex 56fea16f7b fix(ci): reclaim disk before Android build
Pixel 10 APK / build (push) Failing after 3h12m50s
2026-08-09 21:55:50 +02:00
Codex 2f51f3e0a2 fix(ci): install Rust Android toolchain
Pixel 10 APK / build (push) Failing after 26m16s
2026-08-09 21:27:47 +02:00
Codex 9d1d60d170 fix(ci): limit Gradle pressure on shared runner
Pixel 10 APK / build (push) Failing after 21m2s
2026-08-09 19:36:13 +02:00
Codex 158b4ffa56 fix(ci): install native minimum Android platform
Pixel 10 APK / build (push) Failing after 34m53s
2026-08-09 19:08:41 +02:00
Codex 0ecfe2cc68 fix(ci): install Android 17 preview platform
Pixel 10 APK / build (push) Failing after 11m43s
2026-08-09 18:55:23 +02:00
Codex 96aa647cf9 fix(ci): enable preview Android SDK channel
Pixel 10 APK / build (push) Failing after 1m44s
2026-08-09 18:52:27 +02:00
Codex b725d4730e fix(ci): install Android compile SDK platform
Pixel 10 APK / build (push) Failing after 1m42s
2026-08-09 18:49:02 +02:00
Codex eb47eba676 fix(scripts): support Python 3.9 quote builds
Pixel 10 APK / build (push) Failing after 11m43s
2026-08-09 18:35:29 +02:00
Codex 273035ab02 fix(ci): trust downloaded Flutter SDK
Pixel 10 APK / build (push) Failing after 4m22s
2026-08-09 18:29:53 +02:00
Codex d3fc18dbec fix(ci): use configured Android SDK manager
Pixel 10 APK / build (push) Failing after 2m48s
2026-08-09 18:25:36 +02:00
Codex 93bf64c61d fix(ci): provision Android SDK on runner
Pixel 10 APK / build (push) Failing after 1m47s
2026-08-09 18:18:36 +02:00
Codex 56fe95dbd4 fix(ci): install Flutter runner prerequisites
Pixel 10 APK / build (push) Failing after 19m20s
2026-08-09 18:03:01 +02:00
Codex 1046a1fe71 docs(android): document Gitea Pixel 10 releases
Pixel 10 APK / build (push) Failing after 5m55s
2026-08-09 17:27:14 +02:00
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
62 changed files with 5267 additions and 4812 deletions
+242
View File
@@ -0,0 +1,242 @@
name: Pixel 10 APK
on:
workflow_dispatch:
push:
branches:
- pixel10
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
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
- name: Install runner prerequisites
shell: bash
run: |
set -euo pipefail
required_commands=(jq unzip zip xz readelf python3)
missing_command=false
for command_name in "${required_commands[@]}"; do
if ! command -v "$command_name" >/dev/null 2>&1; then
missing_command=true
break
fi
done
if [[ "$missing_command" == "true" ]]; then
sudo_command=()
if [[ "$EUID" -ne 0 ]]; then
sudo_command=(sudo)
fi
export DEBIAN_FRONTEND=noninteractive
"${sudo_command[@]}" apt-get update
"${sudo_command[@]}" apt-get install -y --no-install-recommends \
jq unzip zip xz-utils binutils python3
fi
- name: Validate signing configuration
shell: bash
run: |
set -euo pipefail
test -n "$KEY_JKS"
test -n "$KEY_PASSWORD"
test -n "$KEY_ALIAS"
printf '%s' "$KEY_JKS" | base64 -d > "$KEY_PATH"
chmod 600 "$KEY_PATH"
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: actions/setup-go@v5
with:
go-version: 1.25.x
- name: Set up Rust Android toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.5
# The act_runner cache endpoint is not reachable from job containers
# in this setup and otherwise adds two ~5 minute timeouts per run.
cache: false
- name: Trust Flutter SDK checkout
shell: bash
run: |
set -euo pipefail
flutter_sdk="$(dirname "$(dirname "$(command -v flutter)")")"
git config --global --add safe.directory "$flutter_sdk"
- name: Install Android SDK platform and NDK
shell: bash
run: |
set -euo pipefail
android_api="$(sed -n 's/^[[:space:]]*compileSdkVersion[[:space:]]*=[[:space:]]*//p' apps/weblibre/android/build.gradle | head -n 1)"
android_platform="$android_api.0"
ndk_version="$(sed -n 's/^weblibre\.ndkVersion[[:space:]]*=[[:space:]]*//p' apps/weblibre/android/gradle.properties)"
test -n "$android_api"
test -n "$ndk_version"
yes | sdkmanager --channel=3 --install \
"platforms;android-24" \
"platforms;android-$android_platform" \
"build-tools;36.0.0" \
"cmake;3.22.1" \
"ndk;$ndk_version" || test "${PIPESTATUS[1]}" -eq 0
test -f "$ANDROID_HOME/platforms/android-24/android.jar"
test -f "$ANDROID_HOME/platforms/android-$android_platform/android.jar"
test -f "$ANDROID_HOME/build-tools/36.0.0/aapt2"
test -x "$ANDROID_HOME/cmake/3.22.1/bin/cmake"
test -f "$ANDROID_HOME/ndk/$ndk_version/source.properties"
{
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: Test Pixel 10 performance regressions
working-directory: apps/weblibre
run: >-
flutter test --no-pub
test/features/geckoview/utils/image_helper_test.dart
test/features/geckoview/domain/providers/tab_detail_state_test.dart
- 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
# Pixel 10 is ARM64-only. Avoid building and storing three unused ABIs.
TARGET: android/arm64
# Go writes verbose compiler progress to stderr. Gitea's act runner
# otherwise labels every one of those harmless lines as "ERROR".
run: melos run build-go-runtime --no-select 2>&1
- name: Reclaim disk before Android build
shell: bash
run: |
set -euo pipefail
# These inputs and compiler caches are only needed to create the AAR
# installed by the previous step. Cargokit, Gradle and artifact upload
# need the reclaimed space later in the same job container.
rm -rf \
"$RUNNER_TEMP/sing-box" \
"$RUNNER_TEMP/IPtProxy" \
native/go_mobile_runtime/.work/android
go clean -cache -modcache
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
minimum_kib=$((8 * 1024 * 1024))
df -h /
if (( available_kib < minimum_kib )); then
echo "At least 8 GiB of free runner disk is required before the Android build; only $((available_kib / 1024 / 1024)) GiB is available." >&2
exit 1
fi
- name: Build and verify Pixel 10 APK
env:
CARGO_BUILD_JOBS: "2"
CARGO_INCREMENTAL: "0"
# The previous measured run kept about 16 GiB RAM free. GeckoView's
# Jetifier transform needs more than a 1 GiB heap, while two workers
# still leave ample headroom for the colocated Gitea instance.
JAVA_TOOL_OPTIONS: -XX:ActiveProcessorCount=2
GRADLE_OPTS: >-
-Dorg.gradle.jvmargs=-Xmx4G
-Dorg.gradle.workers.max=2
-Dorg.gradle.parallel=false
-Dorg.gradle.daemon=false
-Dkotlin.compiler.execution.strategy=in-process
shell: bash
run: |
set -euo pipefail
# A deadlocked native build used to stay alive until act_runner's
# three-hour job container expired. Keep a hard local deadline and
# emit a heartbeat so a stalled or disk-starved build is obvious.
timeout --foreground --signal=TERM --kill-after=1m 30m \
melos run build-browser-pixel10 --no-select &
build_pid=$!
monitor_resources() {
while kill -0 "$build_pid" 2>/dev/null; do
sleep 60
kill -0 "$build_pid" 2>/dev/null || return 0
available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')"
echo "Pixel 10 build heartbeat: $((available_kib / 1024 / 1024)) GiB disk available"
awk '/MemAvailable:/ { printf "Memory available: %.1f GiB\n", $2 / 1024 / 1024 }' /proc/meminfo
if (( available_kib < 2 * 1024 * 1024 )); then
echo "Stopping Android build before the runner disk is exhausted." >&2
kill -TERM "$build_pid" 2>/dev/null || true
return 1
fi
done
}
monitor_resources &
monitor_pid=$!
cleanup_monitor() {
kill "$monitor_pid" 2>/dev/null || true
wait "$monitor_pid" 2>/dev/null || true
}
trap cleanup_monitor EXIT
set +e
wait "$build_pid"
build_status=$?
set -e
exit "$build_status"
# Gitea's artifact service implements the v3 protocol. The v4 action
# rejects non-GitHub servers with GHESNotSupportedError before upload.
- uses: actions/upload-artifact@v3
with:
name: mrbweblibre-pixel10
path: apps/weblibre/build/app/outputs/flutter-apk/app-pixel10-release.apk
if-no-files-found: error
- name: Remove release key
if: always()
run: rm -f "$KEY_PATH"
+85
View File
@@ -0,0 +1,85 @@
# 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 asks Flutter for a split ARM64 artifact, verifies that no other
ABI is packaged, and publishes it under the stable path:
```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`.
## Gitea Actions
The workflow at `.gitea/workflows/pixel10.yml` runs on every push to the
`pixel10` branch and can also be started manually. It needs an Actions runner
with the `ubuntu-latest` label and these repository secrets:
| Secret | Value |
| --- | --- |
| `PIXEL10_KEY_JKS` | Base64-encoded Android signing keystore |
| `PIXEL10_KEY_ALIAS` | Alias of the signing key |
| `PIXEL10_KEY_PASSWORD` | Keystore and key password |
Create a dedicated key once and keep both the keystore and password backed up.
Losing the signing key makes it impossible to install future updates over an
existing MrbWebLibre installation.
```bash
keytool -genkeypair -v \
-keystore pixel10-release.jks \
-alias mrbweblibre \
-keyalg RSA -keysize 4096 -validity 10000
base64 -w 0 pixel10-release.jks
```
Add the resulting one-line Base64 value as `PIXEL10_KEY_JKS`; do not commit the
keystore itself. The workflow removes the decoded file even when a build fails.
## 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">
+11
View File
@@ -73,6 +73,17 @@ 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
// and retain Impeller. The Pixel 10 build script selects android-arm64
// with Flutter's ABI split; Gradle forbids combining that split with a
// second ndk.abiFilters declaration here.
pixel10 {
dimension "track"
applicationId "cc.mrblake.mrbweblibre"
versionNameSuffix "-pixel10"
manifestPlaceholders = [appName: "MrbWebLibre", enableImpeller: "true"]
}
}
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,
);
}
}
@@ -54,6 +54,14 @@ class TabProgressStates extends _$TabProgressStates {
state = {...state}..[tabId] = progress;
}
void removeAll(Set<String> tabIds) {
if (!state.keys.any(tabIds.contains)) {
return;
}
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
}
}
@Riverpod()
@@ -88,6 +96,17 @@ class TabThumbnails extends _$TabThumbnails {
state = {...state}..[tabId] = thumbnail;
}
void removeAll(Set<String> tabIds) {
if (!state.keys.any(tabIds.contains)) {
return;
}
// EquatableImage owns its ui.Image through a finalizer. Dropping the map
// reference is safer than disposing it here because an outgoing tab-preview
// frame may still hold the same wrapper briefly.
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
}
}
@Riverpod()
@@ -112,6 +131,14 @@ class TabHistoryStates extends _$TabHistoryStates {
state = {...state}..[tabId] = history;
}
void removeAll(Set<String> tabIds) {
if (!state.keys.any(tabIds.contains)) {
return;
}
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
}
}
@Riverpod()
@@ -142,6 +169,14 @@ class TabFindResultStates extends _$TabFindResultStates {
FindResultState resultFor(String tabId) =>
state[tabId] ?? FindResultState.$default();
void removeAll(Set<String> tabIds) {
if (!state.keys.any(tabIds.contains)) {
return;
}
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
}
}
@Riverpod()
@@ -170,6 +205,14 @@ class TabTranslationStates extends _$TabTranslationStates {
state = {...state}..[tabId] = translation;
}
void removeAll(Set<String> tabIds) {
if (!state.keys.any(tabIds.contains)) {
return;
}
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
}
}
@Riverpod()
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/domain/entities/states/translation.d
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
@@ -75,6 +76,14 @@ class TabStates extends _$TabStates {
state = {...state}..[tabId] = next;
}
void _removeAll(Set<String> tabIds) {
if (!state.keys.any(tabIds.contains)) {
return;
}
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
}
Future<void> _onTabContentStateChange(TabContentState contentState) async {
final current = await patchedState(contentState.id);
@@ -187,6 +196,9 @@ class TabStates extends _$TabStates {
bytes,
targetWidth: thumbnailDecodeWidth,
allowUpscaling: false,
// Periodic screenshots are almost always unique. Caching each decode
// retained up to 100 obsolete GPU images in the global icon LRU.
cacheResult: false,
),
);
@@ -415,6 +427,29 @@ class TabStates extends _$TabStates {
},
);
ref.listen(tabListProvider, (previous, next) {
if (previous == null) {
// The first list can be a partial restore snapshot. There is no reliable
// removal signal until Gecko has emitted at least two snapshots.
return;
}
final activeTabIds = next.value.toSet();
final removedTabIds = previous.value
.where((tabId) => !activeTabIds.contains(tabId))
.toSet();
if (removedTabIds.isEmpty) {
return;
}
_removeAll(removedTabIds);
ref.read(tabProgressStatesProvider.notifier).removeAll(removedTabIds);
ref.read(tabThumbnailsProvider.notifier).removeAll(removedTabIds);
ref.read(tabHistoryStatesProvider.notifier).removeAll(removedTabIds);
ref.read(tabFindResultStatesProvider.notifier).removeAll(removedTabIds);
ref.read(tabTranslationStatesProvider.notifier).removeAll(removedTabIds);
});
ref.onDispose(() async {
for (final sub in subscriptions) {
await sub.cancel();
@@ -32,14 +32,18 @@ import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/pending_tab_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/find_in_page/domain/repositories/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
@@ -446,35 +450,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 +600,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 +1002,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);
@@ -1146,6 +1214,21 @@ class TabRepository extends _$TabRepository {
ref.listen(
tabListProvider,
(previous, next) async {
final activeTabIds = next.value.toSet();
final removedTabIds = previous?.value
.where((tabId) => !activeTabIds.contains(tabId))
.toSet();
if (removedTabIds != null) {
for (final tabId in removedTabIds) {
// These families are keepAlive so tab-specific state survives while
// a tab is merely in the background. Once Gecko confirms removal,
// keeping their services and listeners serves no purpose.
ref.invalidate(tabSessionProvider(tabId: tabId));
ref.invalidate(desktopModeProvider(tabId));
ref.invalidate(findInPageRepositoryProvider(tabId));
}
}
if (_suppressNextReclose) {
_suppressNextReclose = false;
// Drop tombstones for the tabs that just came back via undo so
@@ -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';
@@ -1000,6 +1000,20 @@ class BrowserScreen extends HookConsumerWidget {
final pendingProxyLoadErrors = useRef(<String, _PendingProxyLoadError>{});
final selectedTabIdForProxyPrompt = ref.watch(selectedTabProvider);
ref.listen(tabListProvider, (previous, next) {
if (previous == null) return;
final activeTabIds = next.value.toSet();
for (final tabId in previous.value.where(
(tabId) => !activeTabIds.contains(tabId),
)) {
// UI-scoped families intentionally survive while a tab is backgrounded,
// but must not retain listeners and text state after it is closed.
ref.invalidate(toolbarVisibilityControllerProvider(tabId));
ref.invalidate(findInPageControllerProvider(tabId));
}
});
Future<void> handleProxyLoadError({
required String tabId,
required String? contextId,
@@ -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);
@@ -111,8 +111,16 @@ class _BrowserViewState extends ConsumerState<BrowserView>
static const _pointerThrottleInterval = Duration(milliseconds: 32);
DateTime _lastPointerEvent = DateTime(0);
Offset _accumulatedDelta = Offset.zero;
bool _screenshotCaptureInFlight = false;
Future<void> _timerTick(Timer timer) async {
// Timer.periodic does not await async callbacks. A slow Gecko capture used
// to overlap the next tick, multiplying GPU readbacks, bitmap encoders and
// thumbnail events exactly while the device was already under load.
if (_screenshotCaptureInFlight) {
return;
}
// Skip the (expensive) Gecko render-to-bitmap while a full-cover route
// (settings, tab tray, search, …) occludes the browser. The screenshot
// would force an off-screen render the user can't see and competes for the
@@ -133,15 +141,17 @@ class _BrowserViewState extends ConsumerState<BrowserView>
return;
}
await ref
.read(selectedTabSessionProvider)
.requestScreenshot(requireImageResult: false)
.onError((error, stackTrace) {
logger.e(error, stackTrace: stackTrace);
timer.cancel();
return null;
});
_screenshotCaptureInFlight = true;
try {
await ref
.read(selectedTabSessionProvider)
.requestScreenshot(requireImageResult: false);
} catch (error, stackTrace) {
logger.e(error, stackTrace: stackTrace);
timer.cancel();
} finally {
_screenshotCaptureInFlight = false;
}
}
@override
@@ -281,21 +291,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(
@@ -27,6 +27,7 @@ import 'package:weblibre/core/logger.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
@@ -66,6 +67,21 @@ class PwaManifestState extends _$PwaManifestState {
},
);
ref.listen(tabListProvider, (previous, next) {
if (previous == null) return;
final activeTabIds = next.value.toSet();
final removedTabIds = previous.value
.where((tabId) => !activeTabIds.contains(tabId))
.toSet();
if (!removedTabIds.any(state.containsKey)) {
return;
}
state = {...state}
..removeWhere((tabId, _) => removedTabIds.contains(tabId));
});
return {};
}
}
@@ -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>> {
@@ -42,6 +42,7 @@ Future<EquatableImage?> tryDecodeImage(
int? targetWidth,
int? targetHeight,
bool allowUpscaling = true,
bool cacheResult = true,
}) async {
// The decode options are part of the identity of the result, not just of the
// request: the same bytes decoded at a thumbnail's target width and at an
@@ -55,11 +56,13 @@ Future<EquatableImage?> tryDecodeImage(
allowUpscaling: allowUpscaling,
);
final cached = _cache.get(identity);
if (cached?.value != null) {
return cached;
} else if (cached != null) {
_cache.remove(identity);
if (cacheResult) {
final cached = _cache.get(identity);
if (cached?.value != null) {
return cached;
} else if (cached != null) {
_cache.remove(identity);
}
}
try {
@@ -74,7 +77,9 @@ Future<EquatableImage?> tryDecodeImage(
final image = EquatableImage(frameInfo.image, identity: identity);
if (image.value != null && image.value!.width > 0) {
_cache.set(identity, image);
if (cacheResult) {
_cache.set(identity, image);
}
return image;
}
} catch (e, s) {
@@ -87,7 +92,9 @@ Future<EquatableImage?> tryDecodeImage(
targetHeight: targetHeight,
);
if (svgImage != null) {
_cache.set(identity, svgImage);
if (cacheResult) {
_cache.set(identity, svgImage);
}
return svgImage;
}
} catch (svgError, svgStackTrace) {
@@ -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+41
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
void main() {
test('closed tab progress is pruned without touching active tabs', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(tabProgressStatesProvider.notifier);
notifier.update('closed-tab', 80);
notifier.update('active-tab', 40);
notifier.removeAll({'closed-tab', 'unknown-tab'});
expect(container.read(tabProgressStatesProvider), {'active-tab': 40});
});
test('pruning unrelated ids does not publish a new map', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(tabProgressStatesProvider.notifier);
notifier.update('active-tab', 40);
final before = container.read(tabProgressStatesProvider);
notifier.removeAll({'unknown-tab'});
expect(
identical(container.read(tabProgressStatesProvider), before),
isTrue,
);
});
}
@@ -75,6 +75,27 @@ void main() {
});
});
testWidgets('tryDecodeImage can bypass the global image cache', (
tester,
) async {
await tester.runAsync(() async {
clearImageCache();
final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon));
final first = await tryDecodeImage(svgBytes, cacheResult: false);
final second = await tryDecodeImage(svgBytes, cacheResult: false);
expect(first, isNotNull);
expect(second, isNotNull);
expect(first, equals(second));
expect(identical(first, second), isFalse);
// These are deliberately distinct uncached image resources.
first!.dispose();
second!.dispose();
});
});
test('ImageIdentity compares structurally, not by a folded hash', () {
const a = (
digest: 0x0123456789ABCDEF,
@@ -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
@@ -9,32 +9,42 @@ package eu.weblibre.flutter_mozilla_components.ext
import android.graphics.Bitmap
import android.os.Build
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
fun Bitmap.resize(maxWidth: Int, maxHeight: Int): Bitmap {
var width = this.width
var height = this.height
val aspectRatio: Float = width.toFloat() / height.toFloat()
if (width > height) {
width = maxWidth
height = (width / aspectRatio).toInt()
} else {
height = maxHeight
width = (height * aspectRatio).toInt()
require(maxWidth > 0 && maxHeight > 0) {
"Bitmap bounds must be positive"
}
return Bitmap.createScaledBitmap(this, width, height, true)
if (width <= maxWidth && height <= maxHeight) {
return this
}
val scale = minOf(
maxWidth.toFloat() / width.toFloat(),
maxHeight.toFloat() / height.toFloat(),
)
val targetWidth = (width * scale).roundToInt().coerceAtLeast(1)
val targetHeight = (height * scale).roundToInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(this, targetWidth, targetHeight, true)
}
fun Bitmap.toWebPBytes(): ByteArray {
fun Bitmap.toWebPBytes(
lossless: Boolean = true,
quality: Int = 100,
): ByteArray {
val stream = ByteArrayOutputStream()
val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSLESS
if (lossless) {
Bitmap.CompressFormat.WEBP_LOSSLESS
} else {
Bitmap.CompressFormat.WEBP_LOSSY
}
} else {
@Suppress("DEPRECATION")
Bitmap.CompressFormat.WEBP
}
compress(compressFormat, 100, stream)
compress(compressFormat, quality.coerceIn(0, 100), stream)
return stream.toByteArray()
}
}
@@ -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) { _ -> }
}
}
}
}
@@ -19,6 +19,15 @@ import eu.weblibre.flutter_mozilla_components.pigeons.ImageSrcHitResult
import eu.weblibre.flutter_mozilla_components.pigeons.PhoneHitResult
import eu.weblibre.flutter_mozilla_components.pigeons.UnknownHitResult
import eu.weblibre.flutter_mozilla_components.pigeons.VideoHitResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.LastAccessAction
@@ -44,6 +53,53 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
/**
* Thumbnail scaling and WebP encoding are CPU-heavy and this middleware is
* normally invoked on the browser/UI dispatch path. Keep that work off the
* frame-critical thread and serialize it so periodic captures cannot build
* up a queue of competing bitmap encoders.
*/
private val thumbnailEncodingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val thumbnailEncodingMutex = Mutex()
private var thumbnailEncodingJob: Job? = null
private fun forwardThumbnail(action: ContentAction.UpdateThumbnailAction) {
// Only the newest selected-tab preview is useful. Cancellation does not
// interrupt Bitmap.compress itself, so the mutex also prevents a newer
// request from starting a second encoder before the old one unwinds.
thumbnailEncodingJob?.cancel()
thumbnailEncodingJob = thumbnailEncodingScope.launch {
thumbnailEncodingMutex.withLock {
if (!isActive) return@withLock
val resized = action.thumbnail.resize(maxWidth = 720, maxHeight = 720)
try {
// Thumbnails are displayed at a few hundred logical pixels;
// lossless 1280x800 WebP added CPU and channel traffic with
// no visible benefit.
val bytes = resized.toWebPBytes(lossless = false, quality = 82)
if (!isActive) return@withLock
runOnUiThread {
flutterEvents.onThumbnailChange(
EventSequence.next(),
action.sessionId,
bytes,
) { _ -> }
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Log.e("FlutterEventMiddleware", "Failed to encode thumbnail", error)
} finally {
if (resized !== action.thumbnail && !resized.isRecycled) {
resized.recycle()
}
}
}
}
}
@Suppress("ComplexMethod")
override fun invoke(
@@ -53,12 +109,7 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd
) {
when (action) {
is ContentAction.UpdateThumbnailAction -> {
val resized = action.thumbnail.resize(maxWidth = 1280, maxHeight = 800);
val bytes = resized.toWebPBytes()
runOnUiThread {
flutterEvents.onThumbnailChange(EventSequence.next(), action.sessionId, bytes) { _ -> }
}
forwardThumbnail(action)
}
//UpdateReaderConnectRequiredAction seems to be the only event that is called predictable
//after a hot reload
@@ -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()));
}
});
}
+10
View File
@@ -106,3 +106,13 @@ 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 --split-per-abi --no-tree-shake-icons
arm64_apk="$(find build/app/outputs/flutter-apk -maxdepth 1 -type f -name '*pixel10*' -name '*arm64-v8a*' -name '*release.apk' -print -quit)"
test -n "$arm64_apk"
mv "$arm64_apk" build/app/outputs/flutter-apk/app-pixel10-release.apk
../../scripts/verify-pixel10-apk.sh build/app/outputs/flutter-apk/app-pixel10-release.apk
+2
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sqlite3
+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."