Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab2bc0cee | ||
|
|
03227320f4 | ||
|
|
4a5a01dc97 | ||
|
|
01dcb4705d | ||
|
|
6508dfa8a7 | ||
|
|
ac4a267d30 | ||
|
|
b4afa40c19 | ||
|
|
96a0334a71 | ||
|
|
56fea16f7b | ||
|
|
2f51f3e0a2 | ||
|
|
9d1d60d170 | ||
|
|
158b4ffa56 | ||
|
|
0ecfe2cc68 | ||
|
|
96aa647cf9 | ||
|
|
b725d4730e | ||
|
|
eb47eba676 | ||
|
|
273035ab02 | ||
|
|
d3fc18dbec | ||
|
|
93bf64c61d | ||
|
|
56fe95dbd4 | ||
|
|
1046a1fe71 | ||
|
|
05e8639b67 | ||
|
|
7b942e9bc3 | ||
|
|
cc27a6daa8 | ||
|
|
54e277be72 | ||
|
|
599fb997f1 | ||
|
|
d7055a6f44 | ||
|
|
00e8ae7ff0 | ||
|
|
7bcc528064 | ||
|
|
d3b3d020aa | ||
|
|
c969821343 |
@@ -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
@@ -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 60–120 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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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?
|
||||
>;
|
||||
|
||||
+68
-10
@@ -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';
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+28
-24
@@ -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();
|
||||
|
||||
|
||||
+3
-12
@@ -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(
|
||||
|
||||
+3
-12
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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);
|
||||
|
||||
+1
-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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+33
-2
@@ -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) { _ -> }
|
||||
}
|
||||
}
|
||||
+4
@@ -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
|
||||
|
||||
+83
@@ -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"
|
||||
+6
-21
@@ -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)
|
||||
|
||||
+6
-1
@@ -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) {
|
||||
|
||||
+2
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+10
-4
@@ -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 app→browser→app 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 app→browser→app 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
|
||||
}
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ fun AppLinkPolicySnapshot.toAppLinkPolicy(): AppLinkPolicy {
|
||||
port = pattern.port?.toInt(),
|
||||
)
|
||||
},
|
||||
authExceptionsEnabled = authExceptionsEnabled,
|
||||
contextOverrides = contextOverrides.mapValues { (_, override) ->
|
||||
ContextAppLinkPolicy(
|
||||
globalMode = override.mode.toAppLinkMode(),
|
||||
|
||||
+3
@@ -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"
|
||||
|
||||
+62
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+60
-70
@@ -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
|
||||
|
||||
+120
-16
@@ -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 5–6); 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
|
||||
|
||||
+26
-16
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+38
-20
@@ -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) { _ -> }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
-6
@@ -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
|
||||
|
||||
+30
-12
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -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(
|
||||
|
||||
+44
@@ -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)
|
||||
|
||||
+71
-23
@@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
Executable
+91
@@ -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."
|
||||
Reference in New Issue
Block a user