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
|
# 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"><strong>A privacy-focused Android browser with powerful browsing separation, local-first tools, and deep customization.</strong></p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ android {
|
|||||||
versionNameSuffix "-alpha"
|
versionNameSuffix "-alpha"
|
||||||
manifestPlaceholders = [appName: "WebLibre Alpha (Legacy)", enableImpeller: "false"]
|
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 {
|
sourceSets {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ AppLinkPolicySnapshot? appLinkPolicySnapshot(Ref ref) {
|
|||||||
key: _toNativeRule(value),
|
key: _toNativeRule(value),
|
||||||
},
|
},
|
||||||
marketplaceFallbackEnabled: settings.appLinkMarketplaceFallback,
|
marketplaceFallbackEnabled: settings.appLinkMarketplaceFallback,
|
||||||
|
authExceptionsEnabled: settings.appLinkAuthExceptionsEnabled,
|
||||||
protectGeneralContext: protection.protectGeneralContext,
|
protectGeneralContext: protection.protectGeneralContext,
|
||||||
protectedContextIds: protection.protectedContextIds.toList(),
|
protectedContextIds: protection.protectedContextIds.toList(),
|
||||||
strictContextIds: protection.strictContextIds.toList(),
|
strictContextIds: protection.strictContextIds.toList(),
|
||||||
|
|||||||
+1
-1
@@ -120,7 +120,7 @@ final class AppLinkPolicySnapshotProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$appLinkPolicySnapshotHash() =>
|
String _$appLinkPolicySnapshotHash() =>
|
||||||
r'6fe2dca118d7162561fc7f6280d1a0411d50972a';
|
r'4d456a3cbf091e95ac35d913e8fa20d5f25978ab';
|
||||||
|
|
||||||
/// Single serialised writer that mirrors the Dart-owned app-link policy to the
|
/// 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
|
/// 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
|
* 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/>.
|
* 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:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/core/logger.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
|
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||||
/// event handler, queries the native pending store on attach/resume/event, and
|
/// event handler, queries the native pending store on attach/resume/event, and
|
||||||
/// exposes resolution (including the remember-then-resolve flow). The presented
|
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||||
@@ -52,7 +75,7 @@ class AppLinksCoordinator extends _$AppLinksCoordinator {
|
|||||||
final _service = GeckoAppLinksService();
|
final _service = GeckoAppLinksService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<AppLinkPromptRequest> build() {
|
List<PendingAppLinkPrompt> build() {
|
||||||
final receiver = _AppLinkEventsReceiver((owner) {
|
final receiver = _AppLinkEventsReceiver((owner) {
|
||||||
if (owner == AppLinkPromptOwner.flutterBrowser) {
|
if (owner == AppLinkPromptOwner.flutterBrowser) {
|
||||||
// ignore: discarded_futures
|
// ignore: discarded_futures
|
||||||
@@ -76,11 +99,22 @@ class AppLinksCoordinator extends _$AppLinksCoordinator {
|
|||||||
final prompts = await _service.getPendingAppLinkPrompts(
|
final prompts = await _service.getPendingAppLinkPrompts(
|
||||||
AppLinkPromptOwner.flutterBrowser,
|
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(
|
logger.i(
|
||||||
'app-link refresh -> ${prompts.length} prompt(s): '
|
'app-link refresh -> ${prompts.length} prompt(s): '
|
||||||
'${prompts.map((p) => '${p.requestId}@${p.tabId}(${p.isModal ? 'modal' : 'banner'})').toList()}',
|
'${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) {
|
} catch (error, stackTrace) {
|
||||||
logger.w(
|
logger.w(
|
||||||
'Failed to query pending app-link prompts',
|
'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
|
/// list is authoritative from the query and deduped by `requestId` — the event
|
||||||
/// is only a nudge to re-query.
|
/// is only a nudge to re-query.
|
||||||
final class AppLinksCoordinatorProvider
|
final class AppLinksCoordinatorProvider
|
||||||
extends $NotifierProvider<AppLinksCoordinator, List<AppLinkPromptRequest>> {
|
extends $NotifierProvider<AppLinksCoordinator, List<PendingAppLinkPrompt>> {
|
||||||
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||||
/// event handler, queries the native pending store on attach/resume/event, and
|
/// event handler, queries the native pending store on attach/resume/event, and
|
||||||
/// exposes resolution (including the remember-then-resolve flow). The presented
|
/// exposes resolution (including the remember-then-resolve flow). The presented
|
||||||
@@ -48,16 +48,16 @@ final class AppLinksCoordinatorProvider
|
|||||||
AppLinksCoordinator create() => AppLinksCoordinator();
|
AppLinksCoordinator create() => AppLinksCoordinator();
|
||||||
|
|
||||||
/// {@macro riverpod.override_with_value}
|
/// {@macro riverpod.override_with_value}
|
||||||
Override overrideWithValue(List<AppLinkPromptRequest> value) {
|
Override overrideWithValue(List<PendingAppLinkPrompt> value) {
|
||||||
return $ProviderOverride(
|
return $ProviderOverride(
|
||||||
origin: this,
|
origin: this,
|
||||||
providerOverride: $SyncValueProvider<List<AppLinkPromptRequest>>(value),
|
providerOverride: $SyncValueProvider<List<PendingAppLinkPrompt>>(value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$appLinksCoordinatorHash() =>
|
String _$appLinksCoordinatorHash() =>
|
||||||
r'183fc7ac1264a63c24b1d10f4a22cbfbf6046da7';
|
r'3dc91825b659add92d2f651306c30b9aec4e557b';
|
||||||
|
|
||||||
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
/// Orchestrates Flutter-owned app-link prompts (§2.6): registers the availability
|
||||||
/// event handler, queries the native pending store on attach/resume/event, and
|
/// 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.
|
/// is only a nudge to re-query.
|
||||||
|
|
||||||
abstract class _$AppLinksCoordinator
|
abstract class _$AppLinksCoordinator
|
||||||
extends $Notifier<List<AppLinkPromptRequest>> {
|
extends $Notifier<List<PendingAppLinkPrompt>> {
|
||||||
List<AppLinkPromptRequest> build();
|
List<PendingAppLinkPrompt> build();
|
||||||
@$mustCallSuper
|
@$mustCallSuper
|
||||||
@override
|
@override
|
||||||
WhenComplete runBuild() {
|
WhenComplete runBuild() {
|
||||||
final ref =
|
final ref =
|
||||||
this.ref
|
this.ref
|
||||||
as $Ref<List<AppLinkPromptRequest>, List<AppLinkPromptRequest>>;
|
as $Ref<List<PendingAppLinkPrompt>, List<PendingAppLinkPrompt>>;
|
||||||
final element =
|
final element =
|
||||||
ref.element
|
ref.element
|
||||||
as $ClassProviderElement<
|
as $ClassProviderElement<
|
||||||
AnyNotifier<
|
AnyNotifier<
|
||||||
List<AppLinkPromptRequest>,
|
List<PendingAppLinkPrompt>,
|
||||||
List<AppLinkPromptRequest>
|
List<PendingAppLinkPrompt>
|
||||||
>,
|
>,
|
||||||
List<AppLinkPromptRequest>,
|
List<PendingAppLinkPrompt>,
|
||||||
Object?,
|
Object?,
|
||||||
Object?
|
Object?
|
||||||
>;
|
>;
|
||||||
|
|||||||
+68
-10
@@ -54,30 +54,79 @@ class AppLinkPromptHost extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
final activeRequests = prompts
|
// Native expiry is lazy — it only runs when the store is queried or consumed — and nothing
|
||||||
.where((request) => request.tabId == activeTabId)
|
// 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();
|
.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
|
final modalRequest = activeRequests
|
||||||
.where((request) => request.isModal)
|
.where((prompt) => prompt.isModal)
|
||||||
.lastOrNull;
|
.lastOrNull;
|
||||||
// At most one banner per tab; a newer banner-class request simply becomes the
|
// At most one banner per tab; a newer banner-class request simply becomes the
|
||||||
// one the UI renders.
|
// one the UI renders.
|
||||||
final bannerRequest = activeRequests
|
final bannerRequest = activeRequests
|
||||||
.where((request) => !request.isModal)
|
.where((prompt) => !prompt.isModal)
|
||||||
.lastOrNull;
|
.lastOrNull;
|
||||||
|
|
||||||
// A modal is shown at most once per requestId. Rotation/teardown is not a
|
// 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
|
// 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).
|
// (a subsequent build re-runs this effect with the still-present id).
|
||||||
final shownModalId = useRef<int?>(null);
|
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(() {
|
useEffect(() {
|
||||||
final request = modalRequest;
|
final request = modalRequest;
|
||||||
if (request == null) {
|
final shownId = shownModalId.value;
|
||||||
shownModalId.value = null;
|
if (shownId != null && shownId != request?.requestId) {
|
||||||
return null;
|
retireShownModal();
|
||||||
}
|
}
|
||||||
if (shownModalId.value == request.requestId) {
|
if (request == null || shownModalId.value == request.requestId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
shownModalId.value = request.requestId;
|
shownModalId.value = request.requestId;
|
||||||
@@ -86,8 +135,17 @@ class AppLinkPromptHost extends HookConsumerWidget {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => AppLinkPromptDialog(request: request),
|
builder: (dialogContext) {
|
||||||
|
shownModalRoute.value = ModalRoute.of<void>(dialogContext);
|
||||||
|
return AppLinkPromptDialog(request: request.request);
|
||||||
|
},
|
||||||
).then((_) {
|
).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):
|
// Catch-all for a passive dismissal (Android back / touch-outside):
|
||||||
// the dialog buttons resolve the request themselves, but a barrier
|
// the dialog buttons resolve the request themselves, but a barrier
|
||||||
// dismiss closes it without resolving, leaving the native request
|
// dismiss closes it without resolving, leaving the native request
|
||||||
@@ -111,7 +169,7 @@ class AppLinkPromptHost extends HookConsumerWidget {
|
|||||||
|
|
||||||
return AppLinkOpenBanner(
|
return AppLinkOpenBanner(
|
||||||
key: ValueKey(bannerRequest.requestId),
|
key: ValueKey(bannerRequest.requestId),
|
||||||
request: bannerRequest,
|
request: bannerRequest.request,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ class TabProgressStates extends _$TabProgressStates {
|
|||||||
|
|
||||||
state = {...state}..[tabId] = progress;
|
state = {...state}..[tabId] = progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void removeAll(Set<String> tabIds) {
|
||||||
|
if (!state.keys.any(tabIds.contains)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
@@ -88,6 +96,17 @@ class TabThumbnails extends _$TabThumbnails {
|
|||||||
|
|
||||||
state = {...state}..[tabId] = thumbnail;
|
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()
|
@Riverpod()
|
||||||
@@ -112,6 +131,14 @@ class TabHistoryStates extends _$TabHistoryStates {
|
|||||||
|
|
||||||
state = {...state}..[tabId] = history;
|
state = {...state}..[tabId] = history;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void removeAll(Set<String> tabIds) {
|
||||||
|
if (!state.keys.any(tabIds.contains)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
@@ -142,6 +169,14 @@ class TabFindResultStates extends _$TabFindResultStates {
|
|||||||
|
|
||||||
FindResultState resultFor(String tabId) =>
|
FindResultState resultFor(String tabId) =>
|
||||||
state[tabId] ?? FindResultState.$default();
|
state[tabId] ?? FindResultState.$default();
|
||||||
|
|
||||||
|
void removeAll(Set<String> tabIds) {
|
||||||
|
if (!state.keys.any(tabIds.contains)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
@@ -170,6 +205,14 @@ class TabTranslationStates extends _$TabTranslationStates {
|
|||||||
|
|
||||||
state = {...state}..[tabId] = translation;
|
state = {...state}..[tabId] = translation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void removeAll(Set<String> tabIds) {
|
||||||
|
if (!state.keys.any(tabIds.contains)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {...state}..removeWhere((tabId, _) => tabIds.contains(tabId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@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.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.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_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/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/isolation_context.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.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;
|
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 {
|
Future<void> _onTabContentStateChange(TabContentState contentState) async {
|
||||||
final current = await patchedState(contentState.id);
|
final current = await patchedState(contentState.id);
|
||||||
|
|
||||||
@@ -187,6 +196,9 @@ class TabStates extends _$TabStates {
|
|||||||
bytes,
|
bytes,
|
||||||
targetWidth: thumbnailDecodeWidth,
|
targetWidth: thumbnailDecodeWidth,
|
||||||
allowUpscaling: false,
|
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 {
|
ref.onDispose(() async {
|
||||||
for (final sub in subscriptions) {
|
for (final sub in subscriptions) {
|
||||||
await sub.cancel();
|
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/states/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.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.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/pending_tab_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.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/selected_tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.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_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/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/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/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/database/database.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.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';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
@@ -446,35 +450,85 @@ class TabRepository extends _$TabRepository {
|
|||||||
String tabId, {
|
String tabId, {
|
||||||
String? containerId,
|
String? containerId,
|
||||||
bool skipContainerCheck = true,
|
bool skipContainerCheck = true,
|
||||||
}) async {
|
}) => _selectAdjacentTab(
|
||||||
final previousTabId = await _adjacentVisibleTabByOrder(
|
tabId,
|
||||||
tabId,
|
containerId: containerId,
|
||||||
containerId: containerId,
|
skipContainerCheck: skipContainerCheck,
|
||||||
skipContainerCheck: skipContainerCheck,
|
selectPrevious: true,
|
||||||
selectPrevious: true,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (ref.mounted && previousTabId != null) {
|
|
||||||
return selectTab(previousTabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> selectNextTab(
|
Future<bool> selectNextTab(
|
||||||
String tabId, {
|
String tabId, {
|
||||||
String? containerId,
|
String? containerId,
|
||||||
bool skipContainerCheck = true,
|
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 {
|
}) 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,
|
tabId,
|
||||||
containerId: containerId,
|
containerId: containerId,
|
||||||
skipContainerCheck: skipContainerCheck,
|
skipContainerCheck: skipContainerCheck,
|
||||||
selectPrevious: false,
|
selectPrevious: selectPrevious,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (ref.mounted && previousTabId != null) {
|
if (ref.mounted && adjacentTabId != null) {
|
||||||
return selectTab(previousTabId);
|
return selectTab(adjacentTabId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -546,12 +600,14 @@ class TabRepository extends _$TabRepository {
|
|||||||
required bool skipContainerCheck,
|
required bool skipContainerCheck,
|
||||||
required bool selectPrevious,
|
required bool selectPrevious,
|
||||||
}) {
|
}) {
|
||||||
// "Previous/next" here is interpreted relative to the *tab bar*
|
// Storage-order walk: neighbours by `order_key` only, so it sees neither
|
||||||
// direction, even when the user triggered the navigation from the tab
|
// the tray's sort and filters nor its grouping. User-facing sequential
|
||||||
// tray (which has its own `tabListDirection`). If the two settings
|
// navigation goes through the rendered order in [_selectAdjacentTab] and
|
||||||
// disagree, "next tab" while looking at the tray flows by tab-bar
|
// reaches this only as a fallback; what remains here is picking a tab
|
||||||
// direction. Treat as intentional — keyboard / gesture navigation is
|
// after a close and container-scoped stepping.
|
||||||
// anchored to the bar's mental model.
|
//
|
||||||
|
// "Previous/next" is interpreted relative to the *tab bar* direction,
|
||||||
|
// which is the only direction this path has to go by.
|
||||||
final newestFirst =
|
final newestFirst =
|
||||||
ref.read(generalSettingsWithDefaultsProvider).tabBarDirection ==
|
ref.read(generalSettingsWithDefaultsProvider).tabBarDirection ==
|
||||||
TabDirection.newestFirst;
|
TabDirection.newestFirst;
|
||||||
@@ -946,6 +1002,18 @@ class TabRepository extends _$TabRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void build() {
|
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 eventSerivce = ref.watch(eventServiceProvider);
|
||||||
final tabContentService = ref.watch(tabContentServiceProvider);
|
final tabContentService = ref.watch(tabContentServiceProvider);
|
||||||
|
|
||||||
@@ -1146,6 +1214,21 @@ class TabRepository extends _$TabRepository {
|
|||||||
ref.listen(
|
ref.listen(
|
||||||
tabListProvider,
|
tabListProvider,
|
||||||
(previous, next) async {
|
(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) {
|
if (_suppressNextReclose) {
|
||||||
_suppressNextReclose = false;
|
_suppressNextReclose = false;
|
||||||
// Drop tombstones for the tabs that just came back via undo so
|
// 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> {
|
abstract class _$TabRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -1211,6 +1211,116 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
|
|||||||
return EquatableValue(result);
|
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(
|
String _nearestVisibleParentId(
|
||||||
TabsWithRootAndDepthResult row,
|
TabsWithRootAndDepthResult row,
|
||||||
String rootId,
|
String rootId,
|
||||||
|
|||||||
@@ -1253,3 +1253,307 @@ final class GroupedTabListItemsFamily extends $Family
|
|||||||
@override
|
@override
|
||||||
String toString() => r'groupedTabListItemsProvider';
|
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 pendingProxyLoadErrors = useRef(<String, _PendingProxyLoadError>{});
|
||||||
final selectedTabIdForProxyPrompt = ref.watch(selectedTabProvider);
|
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({
|
Future<void> handleProxyLoadError({
|
||||||
required String tabId,
|
required String tabId,
|
||||||
required String? contextId,
|
required String? contextId,
|
||||||
|
|||||||
+5
-2
@@ -380,7 +380,10 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
final dragStartPosition = useRef(Offset.zero);
|
final dragStartPosition = useRef(Offset.zero);
|
||||||
|
|
||||||
// Swipe along the primary switch axis moves between tabs. [delta] is
|
// 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 {
|
Future<void> switchTabsBy(double delta) async {
|
||||||
final selectedTab = ref.read(selectedTabProvider);
|
final selectedTab = ref.read(selectedTabProvider);
|
||||||
final setting = await ref
|
final setting = await ref
|
||||||
@@ -395,7 +398,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
|||||||
.read(tabRepositoryProvider.notifier)
|
.read(tabRepositoryProvider.notifier)
|
||||||
.selectPreviouslyOpenedTab(selectedTab);
|
.selectPreviouslyOpenedTab(selectedTab);
|
||||||
case TabBarSwipeAction.navigateOrderedTabs:
|
case TabBarSwipeAction.navigateOrderedTabs:
|
||||||
if (delta < 0) {
|
if (delta > 0) {
|
||||||
await ref
|
await ref
|
||||||
.read(tabRepositoryProvider.notifier)
|
.read(tabRepositoryProvider.notifier)
|
||||||
.selectPreviousTab(selectedTab);
|
.selectPreviousTab(selectedTab);
|
||||||
|
|||||||
+28
-24
@@ -111,8 +111,16 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
static const _pointerThrottleInterval = Duration(milliseconds: 32);
|
static const _pointerThrottleInterval = Duration(milliseconds: 32);
|
||||||
DateTime _lastPointerEvent = DateTime(0);
|
DateTime _lastPointerEvent = DateTime(0);
|
||||||
Offset _accumulatedDelta = Offset.zero;
|
Offset _accumulatedDelta = Offset.zero;
|
||||||
|
bool _screenshotCaptureInFlight = false;
|
||||||
|
|
||||||
Future<void> _timerTick(Timer timer) async {
|
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
|
// Skip the (expensive) Gecko render-to-bitmap while a full-cover route
|
||||||
// (settings, tab tray, search, …) occludes the browser. The screenshot
|
// (settings, tab tray, search, …) occludes the browser. The screenshot
|
||||||
// would force an off-screen render the user can't see and competes for the
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ref
|
_screenshotCaptureInFlight = true;
|
||||||
.read(selectedTabSessionProvider)
|
try {
|
||||||
.requestScreenshot(requireImageResult: false)
|
await ref
|
||||||
.onError((error, stackTrace) {
|
.read(selectedTabSessionProvider)
|
||||||
logger.e(error, stackTrace: stackTrace);
|
.requestScreenshot(requireImageResult: false);
|
||||||
timer.cancel();
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(error, stackTrace: stackTrace);
|
||||||
return null;
|
timer.cancel();
|
||||||
});
|
} finally {
|
||||||
|
_screenshotCaptureInFlight = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -281,21 +291,15 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
child: Visibility(
|
child: Visibility(
|
||||||
visible: isGeckoViewVisible,
|
visible: isGeckoViewVisible,
|
||||||
child: GeckoView(
|
child: GeckoView(
|
||||||
preInitializationStep: () async {
|
// Reports when the native container enters the window, which
|
||||||
await ref
|
// under the [Offstage] above is not until the home surface is
|
||||||
.read(eventServiceProvider)
|
// dismissed. [GeckoView] attaches the browser fragment on every
|
||||||
.viewReadyStateEvents
|
// such report, so an engine kept alive but unpainted for the
|
||||||
.firstWhere((state) => state == true)
|
// whole of startup still gets its fragment the moment it is
|
||||||
.timeout(
|
// shown. See https://github.com/FaFre/WebLibre/issues/557.
|
||||||
const Duration(seconds: 3),
|
viewReadyEvents: ref
|
||||||
onTimeout: () {
|
.read(eventServiceProvider)
|
||||||
logger.e(
|
.viewReadyStateEvents,
|
||||||
'Browser fragement not reported ready, trying to intitialize anyways',
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
postInitializationStep: () async {
|
postInitializationStep: () async {
|
||||||
await widget.postInitializationStep?.call();
|
await widget.postInitializationStep?.call();
|
||||||
|
|
||||||
|
|||||||
+3
-12
@@ -222,11 +222,11 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
final grouped = ref.watch(
|
final visibleItems = ref.watch(
|
||||||
groupedTabListItemsProvider(containerId: containerId),
|
visibleTabListItemsProvider(containerId: containerId),
|
||||||
);
|
);
|
||||||
primaryRows = [
|
primaryRows = [
|
||||||
for (final item in grouped.value)
|
for (final item in visibleItems.value)
|
||||||
switch (item) {
|
switch (item) {
|
||||||
TabListStandaloneItem(:final tabId) => TabViewItem.standalone(
|
TabListStandaloneItem(:final tabId) => TabViewItem.standalone(
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
@@ -241,15 +241,6 @@ class _TabGridView extends HookConsumerWidget {
|
|||||||
: TabViewItem.standalone(tabId: c.tabId),
|
: 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(
|
final tabSuggestionsEnabled = ref.watch(
|
||||||
|
|||||||
+3
-12
@@ -283,11 +283,11 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
final grouped = ref.watch(
|
final visibleItems = ref.watch(
|
||||||
groupedTabListItemsProvider(containerId: containerId),
|
visibleTabListItemsProvider(containerId: containerId),
|
||||||
);
|
);
|
||||||
primaryRows = [
|
primaryRows = [
|
||||||
for (final item in grouped.value)
|
for (final item in visibleItems.value)
|
||||||
switch (item) {
|
switch (item) {
|
||||||
TabListStandaloneItem(:final tabId) => TabViewItem.standalone(
|
TabListStandaloneItem(:final tabId) => TabViewItem.standalone(
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
@@ -302,15 +302,6 @@ class _TabListView extends HookConsumerWidget {
|
|||||||
: TabViewItem.standalone(tabId: c.tabId),
|
: 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(
|
final tabSuggestionsEnabled = ref.watch(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import 'package:weblibre/core/logger.dart';
|
|||||||
import 'package:weblibre/extensions/uri.dart';
|
import 'package:weblibre/extensions/uri.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.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/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/domain/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart';
|
import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart';
|
||||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.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 {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -34,7 +34,8 @@ class EngineSuggestions extends _$EngineSuggestions {
|
|||||||
/// autocomplete (history/top-domains); when that yields nothing, falls back
|
/// autocomplete (history/top-domains); when that yields nothing, falls back
|
||||||
/// to a popular-domain prefix match from the bundled Tranco-derived
|
/// to a popular-domain prefix match from the bundled Tranco-derived
|
||||||
/// `sites.db` so typing "git" still completes to "github.com" without any
|
/// `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 {
|
Future<String?> getAutocompleteSuggestion(String query) async {
|
||||||
final engineResult = await ref
|
final engineResult = await ref
|
||||||
.read(engineSuggestionsServiceProvider)
|
.read(engineSuggestionsServiceProvider)
|
||||||
@@ -47,6 +48,16 @@ class EngineSuggestions extends _$EngineSuggestions {
|
|||||||
|
|
||||||
if (!ref.mounted) return null;
|
if (!ref.mounted) return null;
|
||||||
|
|
||||||
|
final popularSitesEnabled = ref.read(
|
||||||
|
generalSettingsWithDefaultsProvider.select(
|
||||||
|
(s) => s.popularSitesAutocompleteEnabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!popularSitesEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final popularSites = await ref
|
final popularSites = await ref
|
||||||
.read(popularSitesRepositoryProvider.notifier)
|
.read(popularSitesRepositoryProvider.notifier)
|
||||||
.searchByPrefix(query, limit: 1);
|
.searchByPrefix(query, limit: 1);
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ final class EngineSuggestionsProvider
|
|||||||
EngineSuggestions create() => EngineSuggestions();
|
EngineSuggestions create() => EngineSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$engineSuggestionsHash() => r'4918e80a1e7dfb59fe67d0895e62a39f2704851f';
|
String _$engineSuggestionsHash() => r'b2bc587d8df12b5614e2c00494a0d666e2c30746';
|
||||||
|
|
||||||
abstract class _$EngineSuggestions
|
abstract class _$EngineSuggestions
|
||||||
extends $StreamNotifier<List<GeckoSuggestion>> {
|
extends $StreamNotifier<List<GeckoSuggestion>> {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Future<EquatableImage?> tryDecodeImage(
|
|||||||
int? targetWidth,
|
int? targetWidth,
|
||||||
int? targetHeight,
|
int? targetHeight,
|
||||||
bool allowUpscaling = true,
|
bool allowUpscaling = true,
|
||||||
|
bool cacheResult = true,
|
||||||
}) async {
|
}) async {
|
||||||
// The decode options are part of the identity of the result, not just of the
|
// 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
|
// request: the same bytes decoded at a thumbnail's target width and at an
|
||||||
@@ -55,11 +56,13 @@ Future<EquatableImage?> tryDecodeImage(
|
|||||||
allowUpscaling: allowUpscaling,
|
allowUpscaling: allowUpscaling,
|
||||||
);
|
);
|
||||||
|
|
||||||
final cached = _cache.get(identity);
|
if (cacheResult) {
|
||||||
if (cached?.value != null) {
|
final cached = _cache.get(identity);
|
||||||
return cached;
|
if (cached?.value != null) {
|
||||||
} else if (cached != null) {
|
return cached;
|
||||||
_cache.remove(identity);
|
} else if (cached != null) {
|
||||||
|
_cache.remove(identity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -74,7 +77,9 @@ Future<EquatableImage?> tryDecodeImage(
|
|||||||
final image = EquatableImage(frameInfo.image, identity: identity);
|
final image = EquatableImage(frameInfo.image, identity: identity);
|
||||||
|
|
||||||
if (image.value != null && image.value!.width > 0) {
|
if (image.value != null && image.value!.width > 0) {
|
||||||
_cache.set(identity, image);
|
if (cacheResult) {
|
||||||
|
_cache.set(identity, image);
|
||||||
|
}
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
@@ -87,7 +92,9 @@ Future<EquatableImage?> tryDecodeImage(
|
|||||||
targetHeight: targetHeight,
|
targetHeight: targetHeight,
|
||||||
);
|
);
|
||||||
if (svgImage != null) {
|
if (svgImage != null) {
|
||||||
_cache.set(identity, svgImage);
|
if (cacheResult) {
|
||||||
|
_cache.set(identity, svgImage);
|
||||||
|
}
|
||||||
return svgImage;
|
return svgImage;
|
||||||
}
|
}
|
||||||
} catch (svgError, svgStackTrace) {
|
} catch (svgError, svgStackTrace) {
|
||||||
|
|||||||
@@ -817,6 +817,11 @@ class _AppLinksModeSection extends HookConsumerWidget {
|
|||||||
(s) => s.appLinkMarketplaceFallback,
|
(s) => s.appLinkMarketplaceFallback,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final authExceptionsEnabled = ref.watch(
|
||||||
|
generalSettingsWithDefaultsProvider.select(
|
||||||
|
(s) => s.appLinkAuthExceptionsEnabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
final rules = ref.watch(
|
final rules = ref.watch(
|
||||||
generalSettingsWithDefaultsProvider.select((s) => s.appLinkRules),
|
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),
|
_AppLinkRulesSubsection(rules: rules),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ const List<SettingsSectionDefinition> searchSettingsSections = [
|
|||||||
keywords: ['submit', 'keyboard', 'suggestions'],
|
keywords: ['submit', 'keyboard', 'suggestions'],
|
||||||
child: _AcceptSuggestionOnSubmitTile(),
|
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(
|
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 {
|
class _LocalIndexEnabledTile extends HookConsumerWidget {
|
||||||
const _LocalIndexEnabledTile();
|
const _LocalIndexEnabledTile();
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,12 @@ class GeneralSettings with FastEquatable {
|
|||||||
/// Defaults to false — the wrong default for a de-Googled browser.
|
/// Defaults to false — the wrong default for a de-Googled browser.
|
||||||
final bool appLinkMarketplaceFallback;
|
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→
|
/// Whether the local search index (`history` table populated via tab→
|
||||||
/// history triggers) is active. When false, the SQL trigger guard returns
|
/// history triggers) is active. When false, the SQL trigger guard returns
|
||||||
/// without writing; existing rows stay until the user clears them.
|
/// 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.
|
/// accept and complete an inline search suggestion. Defaults to false.
|
||||||
final bool acceptSuggestionOnSubmit;
|
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.
|
/// Whether dark mode should use pure-black ("OLED"/high-contrast) surfaces.
|
||||||
/// Only takes effect when the effective brightness is dark. Defaults to false.
|
/// Only takes effect when the effective brightness is dark. Defaults to false.
|
||||||
final bool pureBlack;
|
final bool pureBlack;
|
||||||
@@ -354,9 +365,11 @@ class GeneralSettings with FastEquatable {
|
|||||||
required this.appLinkRules,
|
required this.appLinkRules,
|
||||||
required this.appLinkContextOverrides,
|
required this.appLinkContextOverrides,
|
||||||
required this.appLinkMarketplaceFallback,
|
required this.appLinkMarketplaceFallback,
|
||||||
|
required this.appLinkAuthExceptionsEnabled,
|
||||||
required this.enableLocalSearchIndex,
|
required this.enableLocalSearchIndex,
|
||||||
required this.indexPrivateTabs,
|
required this.indexPrivateTabs,
|
||||||
required this.acceptSuggestionOnSubmit,
|
required this.acceptSuggestionOnSubmit,
|
||||||
|
required this.popularSitesAutocompleteEnabled,
|
||||||
required this.pureBlack,
|
required this.pureBlack,
|
||||||
required this.globalDesktopMode,
|
required this.globalDesktopMode,
|
||||||
required this.desktopModeSites,
|
required this.desktopModeSites,
|
||||||
@@ -430,9 +443,11 @@ class GeneralSettings with FastEquatable {
|
|||||||
Map<String, PersistedAppLinkRule>? appLinkRules,
|
Map<String, PersistedAppLinkRule>? appLinkRules,
|
||||||
Map<String, ContextAppLinkPolicy>? appLinkContextOverrides,
|
Map<String, ContextAppLinkPolicy>? appLinkContextOverrides,
|
||||||
bool? appLinkMarketplaceFallback,
|
bool? appLinkMarketplaceFallback,
|
||||||
|
bool? appLinkAuthExceptionsEnabled,
|
||||||
bool? enableLocalSearchIndex,
|
bool? enableLocalSearchIndex,
|
||||||
bool? indexPrivateTabs,
|
bool? indexPrivateTabs,
|
||||||
bool? acceptSuggestionOnSubmit,
|
bool? acceptSuggestionOnSubmit,
|
||||||
|
bool? popularSitesAutocompleteEnabled,
|
||||||
bool? pureBlack,
|
bool? pureBlack,
|
||||||
bool? globalDesktopMode,
|
bool? globalDesktopMode,
|
||||||
List<String>? desktopModeSites,
|
List<String>? desktopModeSites,
|
||||||
@@ -517,9 +532,12 @@ class GeneralSettings with FastEquatable {
|
|||||||
appLinkRules = appLinkRules ?? const {},
|
appLinkRules = appLinkRules ?? const {},
|
||||||
appLinkContextOverrides = appLinkContextOverrides ?? const {},
|
appLinkContextOverrides = appLinkContextOverrides ?? const {},
|
||||||
appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false,
|
appLinkMarketplaceFallback = appLinkMarketplaceFallback ?? false,
|
||||||
|
appLinkAuthExceptionsEnabled = appLinkAuthExceptionsEnabled ?? true,
|
||||||
enableLocalSearchIndex = enableLocalSearchIndex ?? true,
|
enableLocalSearchIndex = enableLocalSearchIndex ?? true,
|
||||||
indexPrivateTabs = indexPrivateTabs ?? false,
|
indexPrivateTabs = indexPrivateTabs ?? false,
|
||||||
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
|
acceptSuggestionOnSubmit = acceptSuggestionOnSubmit ?? true,
|
||||||
|
popularSitesAutocompleteEnabled =
|
||||||
|
popularSitesAutocompleteEnabled ?? true,
|
||||||
pureBlack = pureBlack ?? false,
|
pureBlack = pureBlack ?? false,
|
||||||
globalDesktopMode = globalDesktopMode ?? false,
|
globalDesktopMode = globalDesktopMode ?? false,
|
||||||
desktopModeSites = desktopModeSites ?? const [],
|
desktopModeSites = desktopModeSites ?? const [],
|
||||||
@@ -684,9 +702,11 @@ class GeneralSettings with FastEquatable {
|
|||||||
appLinkRules,
|
appLinkRules,
|
||||||
appLinkContextOverrides,
|
appLinkContextOverrides,
|
||||||
appLinkMarketplaceFallback,
|
appLinkMarketplaceFallback,
|
||||||
|
appLinkAuthExceptionsEnabled,
|
||||||
enableLocalSearchIndex,
|
enableLocalSearchIndex,
|
||||||
indexPrivateTabs,
|
indexPrivateTabs,
|
||||||
acceptSuggestionOnSubmit,
|
acceptSuggestionOnSubmit,
|
||||||
|
popularSitesAutocompleteEnabled,
|
||||||
pureBlack,
|
pureBlack,
|
||||||
globalDesktopMode,
|
globalDesktopMode,
|
||||||
desktopModeSites,
|
desktopModeSites,
|
||||||
|
|||||||
@@ -161,12 +161,20 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
|
|
||||||
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback);
|
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback);
|
||||||
|
|
||||||
|
GeneralSettings appLinkAuthExceptionsEnabled(
|
||||||
|
bool appLinkAuthExceptionsEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
|
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex);
|
||||||
|
|
||||||
GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
|
GeneralSettings indexPrivateTabs(bool indexPrivateTabs);
|
||||||
|
|
||||||
GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit);
|
GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit);
|
||||||
|
|
||||||
|
GeneralSettings popularSitesAutocompleteEnabled(
|
||||||
|
bool popularSitesAutocompleteEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
GeneralSettings pureBlack(bool pureBlack);
|
GeneralSettings pureBlack(bool pureBlack);
|
||||||
|
|
||||||
GeneralSettings globalDesktopMode(bool globalDesktopMode);
|
GeneralSettings globalDesktopMode(bool globalDesktopMode);
|
||||||
@@ -249,9 +257,11 @@ abstract class _$GeneralSettingsCWProxy {
|
|||||||
Map<String, PersistedAppLinkRule> appLinkRules,
|
Map<String, PersistedAppLinkRule> appLinkRules,
|
||||||
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
|
Map<String, ContextAppLinkPolicy> appLinkContextOverrides,
|
||||||
bool appLinkMarketplaceFallback,
|
bool appLinkMarketplaceFallback,
|
||||||
|
bool appLinkAuthExceptionsEnabled,
|
||||||
bool enableLocalSearchIndex,
|
bool enableLocalSearchIndex,
|
||||||
bool indexPrivateTabs,
|
bool indexPrivateTabs,
|
||||||
bool acceptSuggestionOnSubmit,
|
bool acceptSuggestionOnSubmit,
|
||||||
|
bool popularSitesAutocompleteEnabled,
|
||||||
bool pureBlack,
|
bool pureBlack,
|
||||||
bool globalDesktopMode,
|
bool globalDesktopMode,
|
||||||
List<String> desktopModeSites,
|
List<String> desktopModeSites,
|
||||||
@@ -551,6 +561,11 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) =>
|
GeneralSettings appLinkMarketplaceFallback(bool appLinkMarketplaceFallback) =>
|
||||||
call(appLinkMarketplaceFallback: appLinkMarketplaceFallback);
|
call(appLinkMarketplaceFallback: appLinkMarketplaceFallback);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings appLinkAuthExceptionsEnabled(
|
||||||
|
bool appLinkAuthExceptionsEnabled,
|
||||||
|
) => call(appLinkAuthExceptionsEnabled: appLinkAuthExceptionsEnabled);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
|
GeneralSettings enableLocalSearchIndex(bool enableLocalSearchIndex) =>
|
||||||
call(enableLocalSearchIndex: enableLocalSearchIndex);
|
call(enableLocalSearchIndex: enableLocalSearchIndex);
|
||||||
@@ -563,6 +578,11 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit) =>
|
GeneralSettings acceptSuggestionOnSubmit(bool acceptSuggestionOnSubmit) =>
|
||||||
call(acceptSuggestionOnSubmit: acceptSuggestionOnSubmit);
|
call(acceptSuggestionOnSubmit: acceptSuggestionOnSubmit);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GeneralSettings popularSitesAutocompleteEnabled(
|
||||||
|
bool popularSitesAutocompleteEnabled,
|
||||||
|
) => call(popularSitesAutocompleteEnabled: popularSitesAutocompleteEnabled);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GeneralSettings pureBlack(bool pureBlack) => call(pureBlack: pureBlack);
|
GeneralSettings pureBlack(bool pureBlack) => call(pureBlack: pureBlack);
|
||||||
|
|
||||||
@@ -655,9 +675,11 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
Object? appLinkRules = const $CopyWithPlaceholder(),
|
Object? appLinkRules = const $CopyWithPlaceholder(),
|
||||||
Object? appLinkContextOverrides = const $CopyWithPlaceholder(),
|
Object? appLinkContextOverrides = const $CopyWithPlaceholder(),
|
||||||
Object? appLinkMarketplaceFallback = const $CopyWithPlaceholder(),
|
Object? appLinkMarketplaceFallback = const $CopyWithPlaceholder(),
|
||||||
|
Object? appLinkAuthExceptionsEnabled = const $CopyWithPlaceholder(),
|
||||||
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
|
Object? enableLocalSearchIndex = const $CopyWithPlaceholder(),
|
||||||
Object? indexPrivateTabs = const $CopyWithPlaceholder(),
|
Object? indexPrivateTabs = const $CopyWithPlaceholder(),
|
||||||
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
|
Object? acceptSuggestionOnSubmit = const $CopyWithPlaceholder(),
|
||||||
|
Object? popularSitesAutocompleteEnabled = const $CopyWithPlaceholder(),
|
||||||
Object? pureBlack = const $CopyWithPlaceholder(),
|
Object? pureBlack = const $CopyWithPlaceholder(),
|
||||||
Object? globalDesktopMode = const $CopyWithPlaceholder(),
|
Object? globalDesktopMode = const $CopyWithPlaceholder(),
|
||||||
Object? desktopModeSites = const $CopyWithPlaceholder(),
|
Object? desktopModeSites = const $CopyWithPlaceholder(),
|
||||||
@@ -1050,6 +1072,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
? _value.appLinkMarketplaceFallback
|
? _value.appLinkMarketplaceFallback
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: appLinkMarketplaceFallback as bool,
|
: appLinkMarketplaceFallback as bool,
|
||||||
|
appLinkAuthExceptionsEnabled:
|
||||||
|
appLinkAuthExceptionsEnabled == const $CopyWithPlaceholder() ||
|
||||||
|
appLinkAuthExceptionsEnabled == null
|
||||||
|
? _value.appLinkAuthExceptionsEnabled
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: appLinkAuthExceptionsEnabled as bool,
|
||||||
enableLocalSearchIndex:
|
enableLocalSearchIndex:
|
||||||
enableLocalSearchIndex == const $CopyWithPlaceholder() ||
|
enableLocalSearchIndex == const $CopyWithPlaceholder() ||
|
||||||
enableLocalSearchIndex == null
|
enableLocalSearchIndex == null
|
||||||
@@ -1068,6 +1096,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
|
|||||||
? _value.acceptSuggestionOnSubmit
|
? _value.acceptSuggestionOnSubmit
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: acceptSuggestionOnSubmit as bool,
|
: 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
|
pureBlack: pureBlack == const $CopyWithPlaceholder() || pureBlack == null
|
||||||
? _value.pureBlack
|
? _value.pureBlack
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
@@ -1240,9 +1274,12 @@ GeneralSettings _$GeneralSettingsFromJson(
|
|||||||
json['appLinkContextOverrides'] as Map<String, dynamic>?,
|
json['appLinkContextOverrides'] as Map<String, dynamic>?,
|
||||||
),
|
),
|
||||||
appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?,
|
appLinkMarketplaceFallback: json['appLinkMarketplaceFallback'] as bool?,
|
||||||
|
appLinkAuthExceptionsEnabled: json['appLinkAuthExceptionsEnabled'] as bool?,
|
||||||
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
|
enableLocalSearchIndex: json['enableLocalSearchIndex'] as bool?,
|
||||||
indexPrivateTabs: json['indexPrivateTabs'] as bool?,
|
indexPrivateTabs: json['indexPrivateTabs'] as bool?,
|
||||||
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
|
acceptSuggestionOnSubmit: json['acceptSuggestionOnSubmit'] as bool?,
|
||||||
|
popularSitesAutocompleteEnabled:
|
||||||
|
json['popularSitesAutocompleteEnabled'] as bool?,
|
||||||
pureBlack: json['pureBlack'] as bool?,
|
pureBlack: json['pureBlack'] as bool?,
|
||||||
globalDesktopMode: json['globalDesktopMode'] as bool?,
|
globalDesktopMode: json['globalDesktopMode'] as bool?,
|
||||||
desktopModeSites: (json['desktopModeSites'] as List<dynamic>?)
|
desktopModeSites: (json['desktopModeSites'] as List<dynamic>?)
|
||||||
@@ -1337,9 +1374,11 @@ Map<String, dynamic> _$GeneralSettingsToJson(
|
|||||||
(k, e) => MapEntry(k, e.toJson()),
|
(k, e) => MapEntry(k, e.toJson()),
|
||||||
),
|
),
|
||||||
'appLinkMarketplaceFallback': instance.appLinkMarketplaceFallback,
|
'appLinkMarketplaceFallback': instance.appLinkMarketplaceFallback,
|
||||||
|
'appLinkAuthExceptionsEnabled': instance.appLinkAuthExceptionsEnabled,
|
||||||
'enableLocalSearchIndex': instance.enableLocalSearchIndex,
|
'enableLocalSearchIndex': instance.enableLocalSearchIndex,
|
||||||
'indexPrivateTabs': instance.indexPrivateTabs,
|
'indexPrivateTabs': instance.indexPrivateTabs,
|
||||||
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
|
'acceptSuggestionOnSubmit': instance.acceptSuggestionOnSubmit,
|
||||||
|
'popularSitesAutocompleteEnabled': instance.popularSitesAutocompleteEnabled,
|
||||||
'pureBlack': instance.pureBlack,
|
'pureBlack': instance.pureBlack,
|
||||||
'globalDesktopMode': instance.globalDesktopMode,
|
'globalDesktopMode': instance.globalDesktopMode,
|
||||||
'desktopModeSites': instance.desktopModeSites,
|
'desktopModeSites': instance.desktopModeSites,
|
||||||
|
|||||||
@@ -109,9 +109,11 @@ const generalSettingColumnTypes = <String, DriftSqlType>{
|
|||||||
'customTabsEnabled': DriftSqlType.bool,
|
'customTabsEnabled': DriftSqlType.bool,
|
||||||
'appLinksMode': DriftSqlType.string,
|
'appLinksMode': DriftSqlType.string,
|
||||||
'appLinkMarketplaceFallback': DriftSqlType.bool,
|
'appLinkMarketplaceFallback': DriftSqlType.bool,
|
||||||
|
'appLinkAuthExceptionsEnabled': DriftSqlType.bool,
|
||||||
'enableLocalSearchIndex': DriftSqlType.bool,
|
'enableLocalSearchIndex': DriftSqlType.bool,
|
||||||
'indexPrivateTabs': DriftSqlType.bool,
|
'indexPrivateTabs': DriftSqlType.bool,
|
||||||
'acceptSuggestionOnSubmit': DriftSqlType.bool,
|
'acceptSuggestionOnSubmit': DriftSqlType.bool,
|
||||||
|
'popularSitesAutocompleteEnabled': DriftSqlType.bool,
|
||||||
'pureBlack': DriftSqlType.bool,
|
'pureBlack': DriftSqlType.bool,
|
||||||
'showSearchCloseButton': DriftSqlType.bool,
|
'showSearchCloseButton': DriftSqlType.bool,
|
||||||
'homeTarget': DriftSqlType.string,
|
'homeTarget': DriftSqlType.string,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: weblibre
|
|||||||
description: "The Privacy-Focused & AI-Powered Research Browser"
|
description: "The Privacy-Focused & AI-Powered Research Browser"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
version: 0.30.0-alpha-1+40
|
version: 0.30.0-alpha-3+41
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
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', () {
|
test('ImageIdentity compares structurally, not by a folded hash', () {
|
||||||
const a = (
|
const a = (
|
||||||
digest: 0x0123456789ABCDEF,
|
digest: 0x0123456789ABCDEF,
|
||||||
|
|||||||
+33
-2
@@ -42,6 +42,32 @@ private class NativeFragmentView(
|
|||||||
|
|
||||||
private val container: View
|
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 {
|
init {
|
||||||
val vParams: ViewGroup.LayoutParams =
|
val vParams: ViewGroup.LayoutParams =
|
||||||
FrameLayout.LayoutParams(
|
FrameLayout.LayoutParams(
|
||||||
@@ -56,13 +82,13 @@ private class NativeFragmentView(
|
|||||||
container = BackGestureFilterFrameLayout(activity, activity)
|
container = BackGestureFilterFrameLayout(activity, activity)
|
||||||
container.layoutParams = vParams
|
container.layoutParams = vParams
|
||||||
container.id = containerId
|
container.id = containerId
|
||||||
|
container.addOnAttachStateChangeListener(attachStateListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFlutterViewAttached(flutterView: View) {
|
override fun onFlutterViewAttached(flutterView: View) {
|
||||||
super.onFlutterViewAttached(flutterView)
|
super.onFlutterViewAttached(flutterView)
|
||||||
|
|
||||||
components.engineReportedInitialized = false
|
components.engineReportedInitialized = false
|
||||||
flutterEvents.onViewReadyStateChange(EventSequence.next(), true) { _ -> }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getView(): View {
|
override fun getView(): View {
|
||||||
@@ -70,6 +96,11 @@ private class NativeFragmentView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun dispose() {
|
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()
|
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)) {
|
if (GlobalComponents.components == null && !GlobalComponents.ensureExternalComponents(applicationContext)) {
|
||||||
finish()
|
finish()
|
||||||
return
|
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 (caller == packageName) return false
|
||||||
if (!IntentGatekeeperPreferences.isBlocked(applicationContext, caller)) return false
|
if (!IntentGatekeeperPreferences.isBlocked(applicationContext, caller)) return false
|
||||||
|
|
||||||
@@ -102,32 +102,17 @@ class IntentReceiverActivity : Activity() {
|
|||||||
return true
|
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() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
coroutineScope.cancel()
|
coroutineScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processIntent(intent: Intent) {
|
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.components == null) {
|
||||||
if (GlobalComponents.ensureExternalComponents(applicationContext)) {
|
if (GlobalComponents.ensureExternalComponents(applicationContext)) {
|
||||||
routeIntent(intent)
|
routeIntent(intent)
|
||||||
|
|||||||
+6
-1
@@ -134,7 +134,12 @@ class GeckoAppLinksApiImpl(
|
|||||||
try {
|
try {
|
||||||
val components = GlobalComponents.components
|
val components = GlobalComponents.components
|
||||||
val list = 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()
|
?: emptyList()
|
||||||
callback(Result.success(list))
|
callback(Result.success(list))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
+2
@@ -57,6 +57,7 @@ data class AppLinkPolicy(
|
|||||||
val protectedContextIds: Set<String>,
|
val protectedContextIds: Set<String>,
|
||||||
val strictContextIds: Set<String>,
|
val strictContextIds: Set<String>,
|
||||||
val protectedTargetPatterns: List<ProtectedTargetPattern>,
|
val protectedTargetPatterns: List<ProtectedTargetPattern>,
|
||||||
|
val authExceptionsEnabled: Boolean,
|
||||||
/**
|
/**
|
||||||
* Per-container overrides keyed by contextId; only isolated containers appear. A navigation whose
|
* 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).
|
* 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(),
|
protectedContextIds = emptySet(),
|
||||||
strictContextIds = emptySet(),
|
strictContextIds = emptySet(),
|
||||||
protectedTargetPatterns = emptyList(),
|
protectedTargetPatterns = emptyList(),
|
||||||
|
authExceptionsEnabled = true,
|
||||||
contextOverrides = emptyMap(),
|
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.
|
* behaviour so the app opens in its own recents entry.
|
||||||
* - [AUTOMATIC]: global-`always` or a remembered `alwaysOpen` rule — `NEW_TASK`, subject to the
|
* - [AUTOMATIC]: global-`always` or a remembered `alwaysOpen` rule — `NEW_TASK`, subject to the
|
||||||
* 2 s same-package cooldown loop-breaker (§2.4).
|
* 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`.
|
* - [MARKETPLACE]: install-app fallback — `NEW_TASK | CLEAR_TASK`.
|
||||||
*/
|
*/
|
||||||
enum class AppLinkLaunchMode {
|
enum class AppLinkLaunchMode {
|
||||||
MANUAL,
|
MANUAL,
|
||||||
AUTOMATIC,
|
AUTOMATIC,
|
||||||
|
AUTHENTICATION,
|
||||||
MARKETPLACE,
|
MARKETPLACE,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +38,9 @@ enum class AppLinkLaunchResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Launches external apps. Every launch re-resolves immediately first (no cache) and verifies the
|
* 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
|
* expected package before `startActivity` (§2.7). Automatic and authentication launches honour a 2 s
|
||||||
* cooldown to break app→browser→app ping-pong loops (§2.4); manual and prompt-resolved opens are
|
* same-package cooldown to break app→browser→app ping-pong loops (§2.4); manual and prompt-resolved
|
||||||
* user gestures that bypass the check but still record it.
|
* opens are user gestures that bypass the check but still record it.
|
||||||
*/
|
*/
|
||||||
class AppLinkLauncher(
|
class AppLinkLauncher(
|
||||||
private val resolver: ExternalAppResolver,
|
private val resolver: ExternalAppResolver,
|
||||||
@@ -81,7 +85,7 @@ class AppLinkLauncher(
|
|||||||
else -> resolved.packageName
|
else -> resolved.packageName
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode == AppLinkLaunchMode.AUTOMATIC) {
|
if (mode == AppLinkLaunchMode.AUTOMATIC || mode == AppLinkLaunchMode.AUTHENTICATION) {
|
||||||
val (lastPackage, lastTs) = lastLaunch
|
val (lastPackage, lastTs) = lastLaunch
|
||||||
if (lastPackage != null && lastPackage == targetPackage &&
|
if (lastPackage != null && lastPackage == targetPackage &&
|
||||||
clock.elapsedRealtime() < lastTs + cooldownMs
|
clock.elapsedRealtime() < lastTs + cooldownMs
|
||||||
@@ -117,6 +121,8 @@ class AppLinkLauncher(
|
|||||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
AppLinkLaunchMode.AUTOMATIC ->
|
AppLinkLaunchMode.AUTOMATIC ->
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
AppLinkLaunchMode.AUTHENTICATION ->
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
AppLinkLaunchMode.MARKETPLACE ->
|
AppLinkLaunchMode.MARKETPLACE ->
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -28,6 +28,7 @@ fun AppLinkPolicySnapshot.toAppLinkPolicy(): AppLinkPolicy {
|
|||||||
port = pattern.port?.toInt(),
|
port = pattern.port?.toInt(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
authExceptionsEnabled = authExceptionsEnabled,
|
||||||
contextOverrides = contextOverrides.mapValues { (_, override) ->
|
contextOverrides = contextOverrides.mapValues { (_, override) ->
|
||||||
ContextAppLinkPolicy(
|
ContextAppLinkPolicy(
|
||||||
globalMode = override.mode.toAppLinkMode(),
|
globalMode = override.mode.toAppLinkMode(),
|
||||||
|
|||||||
+3
@@ -93,6 +93,7 @@ class AppLinkPolicyStore internal constructor(
|
|||||||
root.put(FIELD_MIGRATED, migrated)
|
root.put(FIELD_MIGRATED, migrated)
|
||||||
root.put(FIELD_GLOBAL_MODE, policy.globalMode.name)
|
root.put(FIELD_GLOBAL_MODE, policy.globalMode.name)
|
||||||
root.put(FIELD_MARKETPLACE, policy.marketplaceFallbackEnabled)
|
root.put(FIELD_MARKETPLACE, policy.marketplaceFallbackEnabled)
|
||||||
|
root.put(FIELD_AUTH_EXCEPTIONS, policy.authExceptionsEnabled)
|
||||||
root.put(FIELD_PROTECT_GENERAL, policy.protectGeneralContext)
|
root.put(FIELD_PROTECT_GENERAL, policy.protectGeneralContext)
|
||||||
root.put(FIELD_PROTECTED_CONTEXTS, JSONArray(policy.protectedContextIds.toList()))
|
root.put(FIELD_PROTECTED_CONTEXTS, JSONArray(policy.protectedContextIds.toList()))
|
||||||
root.put(FIELD_STRICT_CONTEXTS, JSONArray(policy.strictContextIds.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)),
|
globalMode = AppLinkMode.valueOf(root.getString(FIELD_GLOBAL_MODE)),
|
||||||
rules = rules,
|
rules = rules,
|
||||||
marketplaceFallbackEnabled = root.optBoolean(FIELD_MARKETPLACE, false),
|
marketplaceFallbackEnabled = root.optBoolean(FIELD_MARKETPLACE, false),
|
||||||
|
authExceptionsEnabled = root.optBoolean(FIELD_AUTH_EXCEPTIONS, true),
|
||||||
protectGeneralContext = root.optBoolean(FIELD_PROTECT_GENERAL, false),
|
protectGeneralContext = root.optBoolean(FIELD_PROTECT_GENERAL, false),
|
||||||
protectedContextIds = root.optJSONArray(FIELD_PROTECTED_CONTEXTS).toStringSet(),
|
protectedContextIds = root.optJSONArray(FIELD_PROTECTED_CONTEXTS).toStringSet(),
|
||||||
strictContextIds = root.optJSONArray(FIELD_STRICT_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_MIGRATED = "migrated"
|
||||||
private const val FIELD_GLOBAL_MODE = "globalMode"
|
private const val FIELD_GLOBAL_MODE = "globalMode"
|
||||||
private const val FIELD_MARKETPLACE = "marketplaceFallbackEnabled"
|
private const val FIELD_MARKETPLACE = "marketplaceFallbackEnabled"
|
||||||
|
private const val FIELD_AUTH_EXCEPTIONS = "authExceptionsEnabled"
|
||||||
private const val FIELD_PROTECT_GENERAL = "protectGeneralContext"
|
private const val FIELD_PROTECT_GENERAL = "protectGeneralContext"
|
||||||
private const val FIELD_PROTECTED_CONTEXTS = "protectedContextIds"
|
private const val FIELD_PROTECTED_CONTEXTS = "protectedContextIds"
|
||||||
private const val FIELD_STRICT_CONTEXTS = "strictContextIds"
|
private const val FIELD_STRICT_CONTEXTS = "strictContextIds"
|
||||||
|
|||||||
+62
-4
@@ -56,8 +56,25 @@ class NativeAppLinkPromptFeature(
|
|||||||
private val sessionUseCases: SessionUseCases,
|
private val sessionUseCases: SessionUseCases,
|
||||||
) : LifecycleAwareFeature {
|
) : LifecycleAwareFeature {
|
||||||
private var dialog: AlertDialog? = null
|
private var dialog: AlertDialog? = null
|
||||||
|
private var shownRequest: PendingAppLinkRequest? = null
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
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() {
|
override fun start() {
|
||||||
NativeAppLinkPromptNotifier.register(tabId, this)
|
NativeAppLinkPromptNotifier.register(tabId, this)
|
||||||
showNext()
|
showNext()
|
||||||
@@ -65,20 +82,52 @@ class NativeAppLinkPromptFeature(
|
|||||||
|
|
||||||
override fun stop() {
|
override fun stop() {
|
||||||
NativeAppLinkPromptNotifier.unregister(tabId, this)
|
NativeAppLinkPromptNotifier.unregister(tabId, this)
|
||||||
|
mainHandler.removeCallbacksAndMessages(null)
|
||||||
// Dismissing on stop is not a user dismissal: the request stays pending and
|
// Dismissing on stop is not a user dismissal: the request stays pending and
|
||||||
// is re-presented on the next start().
|
// is re-presented on the next start().
|
||||||
dialog?.setOnDismissListener(null)
|
dialog?.setOnDismissListener(null)
|
||||||
dialog?.dismiss()
|
dialog?.dismiss()
|
||||||
dialog = null
|
dialog = null
|
||||||
|
shownRequest = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A new pending request may have been created for this tab (interceptor, engine thread) after
|
* The tab's pending requests changed (interceptor created one on an engine thread after [start]
|
||||||
* [start] already queried. Re-check on the main thread; [showNext] is idempotent (a no-op while a
|
* already queried, or the navigation middleware invalidated one). Re-check on the main thread;
|
||||||
* dialog is up or when nothing pends).
|
* [showNext] is idempotent (a no-op while a live dialog is up or when nothing pends).
|
||||||
*/
|
*/
|
||||||
fun onPromptAvailable() {
|
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() {
|
private fun showNext() {
|
||||||
@@ -107,6 +156,8 @@ class NativeAppLinkPromptFeature(
|
|||||||
}
|
}
|
||||||
.setOnDismissListener { dialog = null }
|
.setOnDismissListener { dialog = null }
|
||||||
.show()
|
.show()
|
||||||
|
shownRequest = request
|
||||||
|
scheduleExpiryTick(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveOpen(request: PendingAppLinkRequest) {
|
private fun resolveOpen(request: PendingAppLinkRequest) {
|
||||||
@@ -141,7 +192,14 @@ class NativeAppLinkPromptFeature(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun afterResolve() {
|
private fun afterResolve() {
|
||||||
|
mainHandler.removeCallbacks(expiryTick)
|
||||||
dialog = null
|
dialog = null
|
||||||
|
shownRequest = null
|
||||||
showNext()
|
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 scopeKey: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
) {
|
) {
|
||||||
fun toPigeon(): AppLinkPromptRequest = AppLinkPromptRequest(
|
fun toPigeon(expiresInMs: Long): AppLinkPromptRequest = AppLinkPromptRequest(
|
||||||
requestId = requestId,
|
requestId = requestId,
|
||||||
owner = owner,
|
owner = owner,
|
||||||
tabId = tabId,
|
tabId = tabId,
|
||||||
@@ -72,6 +72,7 @@ data class PendingAppLinkRequest(
|
|||||||
engineSupportsScheme = engineSupportsScheme,
|
engineSupportsScheme = engineSupportsScheme,
|
||||||
scopeKey = scopeKey,
|
scopeKey = scopeKey,
|
||||||
),
|
),
|
||||||
|
expiresInMs = expiresInMs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,10 +121,32 @@ object PendingAppLinkStores {
|
|||||||
* Query + consume: requests stay until resolved, invalidated, or expired. The store
|
* 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
|
* never holds its lock across a side effect — [consume] returns the request and the
|
||||||
* caller performs launch/fallback after the lock is released.
|
* 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(
|
class PendingAppLinkStore(
|
||||||
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
|
private val clock: MonotonicClock = MonotonicClock.SYSTEM,
|
||||||
private val requestExpiryMs: Long = REQUEST_EXPIRY_MS,
|
private val requestExpiryMs: Long = REQUEST_EXPIRY_MS,
|
||||||
|
private val bannerExpiryMs: Long = BANNER_EXPIRY_MS,
|
||||||
private val suppressionExpiryMs: Long = SUPPRESSION_EXPIRY_MS,
|
private val suppressionExpiryMs: Long = SUPPRESSION_EXPIRY_MS,
|
||||||
private val dedupeWindowMs: Long = DEDUPE_WINDOW_MS,
|
private val dedupeWindowMs: Long = DEDUPE_WINDOW_MS,
|
||||||
private val fallbackReentryMs: Long = FALLBACK_REENTRY_MS,
|
private val fallbackReentryMs: Long = FALLBACK_REENTRY_MS,
|
||||||
@@ -180,11 +203,26 @@ class PendingAppLinkStore(
|
|||||||
scopeKey = input.scopeKey,
|
scopeKey = input.scopeKey,
|
||||||
createdAt = clock.elapsedRealtime(),
|
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
|
requests[request.requestId] = request
|
||||||
return 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]. */
|
/** Non-consuming query of live requests for [owner]. */
|
||||||
fun getPending(owner: AppLinkPromptOwner): List<PendingAppLinkRequest> {
|
fun getPending(owner: AppLinkPromptOwner): List<PendingAppLinkRequest> {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
@@ -212,80 +250,23 @@ class PendingAppLinkStore(
|
|||||||
synchronized(lock) { requests.remove(requestId) }
|
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) {
|
synchronized(lock) {
|
||||||
|
val owners = requests.values
|
||||||
|
.filter { it.tabId == tabId }
|
||||||
|
.mapTo(mutableSetOf()) { it.owner }
|
||||||
requests.values.removeAll { it.tabId == tabId }
|
requests.values.removeAll { it.tabId == tabId }
|
||||||
suppression.keys.removeAll { it.startsWith("$tabId\u0000") }
|
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) ----
|
// ---- Suppression (§2.6) ----
|
||||||
|
|
||||||
fun recordSuppression(tabId: String, fingerprint: String) {
|
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() {
|
private fun sweepExpiredLocked() {
|
||||||
val now = clock.elapsedRealtime()
|
val now = clock.elapsedRealtime()
|
||||||
requests.values.removeAll { now > it.createdAt + requestExpiryMs }
|
requests.values.removeAll { now > it.createdAt + expiryFor(it) }
|
||||||
suppression.values.removeAll { now > it }
|
suppression.values.removeAll { now > it }
|
||||||
fallbackReentry.values.removeAll { now > it }
|
fallbackReentry.values.removeAll { now > it }
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val REQUEST_EXPIRY_MS = 10 * 60 * 1000L
|
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 SUPPRESSION_EXPIRY_MS = 10 * 60 * 1000L
|
||||||
const val DEDUPE_WINDOW_MS = 2000L
|
const val DEDUPE_WINDOW_MS = 2000L
|
||||||
const val FALLBACK_REENTRY_MS = 10 * 1000L
|
const val FALLBACK_REENTRY_MS = 10 * 1000L
|
||||||
|
|||||||
+120
-16
@@ -58,9 +58,27 @@ class WebLibreAppLinksInterceptor(
|
|||||||
|
|
||||||
val uriScheme = runCatching { uri.toUri().scheme }.getOrNull()
|
val uriScheme = runCatching { uri.toUri().scheme }.getOrNull()
|
||||||
val engineSupportsScheme = AppLinkSchemes.isEngineSupported(uriScheme)
|
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.
|
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +92,6 @@ class WebLibreAppLinksInterceptor(
|
|||||||
|
|
||||||
val resolved = runtime.resolver.resolve(uri, includeHttpAppLinks = true, useCache = true)
|
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"
|
// 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
|
// 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.
|
// 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 effectiveMode = override?.globalMode ?: policy.globalMode
|
||||||
val effectiveRules = override?.rules ?: policy.rules
|
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(
|
val input = ClassifierInput(
|
||||||
resolved = resolved,
|
resolved = resolved,
|
||||||
isProtected = isProtected(policy, session, uri),
|
isProtected = isProtectedNavigation,
|
||||||
isPrivate = session?.content?.private ?: false,
|
isPrivate = isPrivateNavigation,
|
||||||
isWallet = AppLinkSchemes.isWallet(resolved.originalScheme) ||
|
isWallet = isWalletNavigation,
|
||||||
AppLinkSchemes.isWallet(resolved.intentDataScheme),
|
|
||||||
missingSession = session == null,
|
missingSession = session == null,
|
||||||
suppressionHit = session != null &&
|
suppressionHit = suppressionHit,
|
||||||
pendingStore.isSuppressed(session.id, targetFingerprint(uri, resolved)),
|
matchingRule = matchingRule,
|
||||||
matchingRule = effectiveRules[resolved.scopeKey],
|
|
||||||
globalMode = effectiveMode,
|
globalMode = effectiveMode,
|
||||||
marketplaceFallbackEnabled = policy.marketplaceFallbackEnabled,
|
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) ----
|
// ---- Eligibility (§2.4 step 2) ----
|
||||||
|
|
||||||
private fun isEligible(
|
private fun isEligible(
|
||||||
uri: String,
|
|
||||||
lastUri: String?,
|
|
||||||
uriScheme: String?,
|
uriScheme: String?,
|
||||||
engineSupportsScheme: Boolean,
|
engineSupportsScheme: Boolean,
|
||||||
hasUserGesture: Boolean,
|
hasUserGesture: Boolean,
|
||||||
isRedirect: Boolean,
|
isRedirect: Boolean,
|
||||||
isDirectNavigation: Boolean,
|
isDirectNavigation: Boolean,
|
||||||
isSubframeRequest: Boolean,
|
isSubframeRequest: Boolean,
|
||||||
|
isSameDomainNavigation: Boolean,
|
||||||
|
authExceptionsAllowed: Boolean,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (uriScheme == null) return false
|
if (uriScheme == null) return false
|
||||||
// A subframe request not triggered by the user and outside the allowlist stays in-page.
|
// 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
|
val isIntentionalNavigation = hasUserGesture || isAllowedRedirect || isDirectNavigation
|
||||||
// Unintentional engine-supported navigation continues in the browser.
|
// Unintentional engine-supported navigation continues in the browser.
|
||||||
if (engineSupportsScheme && !isIntentionalNavigation) return false
|
if (engineSupportsScheme && !isIntentionalNavigation) return false
|
||||||
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping).
|
// Same-domain engine-supported navigation continues in the browser (AC subdomain stripping),
|
||||||
if (engineSupportsScheme && isSameDomain(lastUri, uri)) return false
|
// 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.
|
// Always-denied schemes never resolve or launch externally.
|
||||||
if (AppLinkSchemes.isAlwaysDenied(uriScheme)) return false
|
if (AppLinkSchemes.isAlwaysDenied(uriScheme)) return false
|
||||||
return true
|
return true
|
||||||
|
|||||||
+26
-16
@@ -9,32 +9,42 @@ package eu.weblibre.flutter_mozilla_components.ext
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
fun Bitmap.resize(maxWidth: Int, maxHeight: Int): Bitmap {
|
fun Bitmap.resize(maxWidth: Int, maxHeight: Int): Bitmap {
|
||||||
var width = this.width
|
require(maxWidth > 0 && maxHeight > 0) {
|
||||||
var height = this.height
|
"Bitmap bounds must be positive"
|
||||||
|
|
||||||
val aspectRatio: Float = width.toFloat() / height.toFloat()
|
|
||||||
|
|
||||||
if (width > height) {
|
|
||||||
width = maxWidth
|
|
||||||
height = (width / aspectRatio).toInt()
|
|
||||||
} else {
|
|
||||||
height = maxHeight
|
|
||||||
width = (height * aspectRatio).toInt()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 stream = ByteArrayOutputStream()
|
||||||
val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
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 {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
Bitmap.CompressFormat.WEBP
|
Bitmap.CompressFormat.WEBP
|
||||||
}
|
}
|
||||||
compress(compressFormat, 100, stream)
|
compress(compressFormat, quality.coerceIn(0, 100), stream)
|
||||||
return stream.toByteArray()
|
return stream.toByteArray()
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-20
@@ -6,9 +6,12 @@
|
|||||||
|
|
||||||
package eu.weblibre.flutter_mozilla_components.middleware
|
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.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.BrowserAction
|
||||||
import mozilla.components.browser.state.action.ContentAction
|
|
||||||
import mozilla.components.browser.state.action.CustomTabListAction
|
import mozilla.components.browser.state.action.CustomTabListAction
|
||||||
import mozilla.components.browser.state.action.EngineAction
|
import mozilla.components.browser.state.action.EngineAction
|
||||||
import mozilla.components.browser.state.action.TabListAction
|
import mozilla.components.browser.state.action.TabListAction
|
||||||
@@ -17,17 +20,18 @@ import mozilla.components.lib.state.Middleware
|
|||||||
import mozilla.components.lib.state.Store
|
import mozilla.components.lib.state.Store
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observes the [BrowserStore] and drives [PendingAppLinkStore] invalidation and
|
* Observes the [BrowserStore] and drives [PendingAppLinkStore] tab teardown and suppression
|
||||||
* suppression clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6):
|
* clearing (APP_LINKS_OWN_IMPLEMENTATION_PLAN.md §2.6):
|
||||||
*
|
*
|
||||||
* - a committed top-level navigation whose URL is not a request's own target
|
* - tab close / Custom Tab removal invalidates the tab's pending requests and suppression, and
|
||||||
* invalidates that request (a banner-class request's target committing keeps it
|
* tells the owning surface to re-query so no dead prompt is left on screen;
|
||||||
* alive — that commit is the page the banner sits on);
|
* - a new user-initiated/direct navigation (omnibar, bookmark, typed URL — which dispatch a
|
||||||
* - tab close / Custom Tab removal invalidates the tab's pending requests and
|
* `LoadUrlAction`) clears the tab's suppression. In-page redirects do not dispatch these
|
||||||
* suppression;
|
* actions, so the redirect-loop defence stays intact.
|
||||||
* - a new user-initiated/direct navigation (omnibar, bookmark, typed URL — which
|
*
|
||||||
* dispatch a `LoadUrlAction`) clears the tab's suppression. In-page redirects
|
* It deliberately does **not** invalidate prompts on navigation: see the [PendingAppLinkStore]
|
||||||
* do not dispatch these actions, so the redirect-loop defence stays intact.
|
* KDoc for the three per-document signals that were tried and why none of them can express
|
||||||
|
* "the user left this page".
|
||||||
*/
|
*/
|
||||||
class AppLinkNavigationMiddleware(
|
class AppLinkNavigationMiddleware(
|
||||||
private val store: PendingAppLinkStore,
|
private val store: PendingAppLinkStore,
|
||||||
@@ -38,13 +42,7 @@ class AppLinkNavigationMiddleware(
|
|||||||
action: BrowserAction,
|
action: BrowserAction,
|
||||||
) {
|
) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is ContentAction.UpdateUrlAction -> {
|
|
||||||
// A committed top-level navigation.
|
|
||||||
this.store.onCommittedNavigation(action.sessionId, action.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
is EngineAction.LoadUrlAction -> {
|
is EngineAction.LoadUrlAction -> {
|
||||||
// App-initiated (direct) navigation — clears suppression.
|
|
||||||
this.store.clearSuppressionForTab(action.tabId)
|
this.store.clearSuppressionForTab(action.tabId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,15 +51,17 @@ class AppLinkNavigationMiddleware(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is TabListAction.RemoveTabAction -> {
|
is TabListAction.RemoveTabAction -> {
|
||||||
this.store.invalidateTab(action.tabId)
|
notifyInvalidated(action.tabId, this.store.invalidateTab(action.tabId))
|
||||||
}
|
}
|
||||||
|
|
||||||
is TabListAction.RemoveTabsAction -> {
|
is TabListAction.RemoveTabsAction -> {
|
||||||
action.tabIds.forEach(this.store::invalidateTab)
|
action.tabIds.forEach { tabId ->
|
||||||
|
notifyInvalidated(tabId, this.store.invalidateTab(tabId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is CustomTabListAction.RemoveCustomTabAction -> {
|
is CustomTabListAction.RemoveCustomTabAction -> {
|
||||||
this.store.invalidateTab(action.tabId)
|
notifyInvalidated(action.tabId, this.store.invalidateTab(action.tabId))
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -69,4 +69,22 @@ class AppLinkNavigationMiddleware(
|
|||||||
|
|
||||||
next(action)
|
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.PhoneHitResult
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.UnknownHitResult
|
import eu.weblibre.flutter_mozilla_components.pigeons.UnknownHitResult
|
||||||
import eu.weblibre.flutter_mozilla_components.pigeons.VideoHitResult
|
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.BrowserAction
|
||||||
import mozilla.components.browser.state.action.ContentAction
|
import mozilla.components.browser.state.action.ContentAction
|
||||||
import mozilla.components.browser.state.action.LastAccessAction
|
import mozilla.components.browser.state.action.LastAccessAction
|
||||||
@@ -44,6 +53,53 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd
|
|||||||
private val components by lazy {
|
private val components by lazy {
|
||||||
requireNotNull(GlobalComponents.components) { "Components not initialized" }
|
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")
|
@Suppress("ComplexMethod")
|
||||||
override fun invoke(
|
override fun invoke(
|
||||||
@@ -53,12 +109,7 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd
|
|||||||
) {
|
) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is ContentAction.UpdateThumbnailAction -> {
|
is ContentAction.UpdateThumbnailAction -> {
|
||||||
val resized = action.thumbnail.resize(maxWidth = 1280, maxHeight = 800);
|
forwardThumbnail(action)
|
||||||
val bytes = resized.toWebPBytes()
|
|
||||||
|
|
||||||
runOnUiThread {
|
|
||||||
flutterEvents.onThumbnailChange(EventSequence.next(), action.sessionId, bytes) { _ -> }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
//UpdateReaderConnectRequiredAction seems to be the only event that is called predictable
|
//UpdateReaderConnectRequiredAction seems to be the only event that is called predictable
|
||||||
//after a hot reload
|
//after a hot reload
|
||||||
|
|||||||
+30
-12
@@ -5830,6 +5830,11 @@ data class AppLinkPolicySnapshot (
|
|||||||
/** Remembered rules keyed by canonical scope. */
|
/** Remembered rules keyed by canonical scope. */
|
||||||
val rules: Map<String, NativeAppLinkRule>,
|
val rules: Map<String, NativeAppLinkRule>,
|
||||||
val marketplaceFallbackEnabled: Boolean,
|
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. */
|
/** Regular / no-contextId tabs are proxied via the `general` scope. */
|
||||||
val protectGeneralContext: Boolean,
|
val protectGeneralContext: Boolean,
|
||||||
/** contextIds that resolve to a proxy after inherit/bypass/alias. */
|
/** 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 globalMode = pigeonVar_list[0] as AppLinksMode
|
||||||
val rules = pigeonVar_list[1] as Map<String, NativeAppLinkRule>
|
val rules = pigeonVar_list[1] as Map<String, NativeAppLinkRule>
|
||||||
val marketplaceFallbackEnabled = pigeonVar_list[2] as Boolean
|
val marketplaceFallbackEnabled = pigeonVar_list[2] as Boolean
|
||||||
val protectGeneralContext = pigeonVar_list[3] as Boolean
|
val authExceptionsEnabled = pigeonVar_list[3] as Boolean
|
||||||
val protectedContextIds = pigeonVar_list[4] as List<String>
|
val protectGeneralContext = pigeonVar_list[4] as Boolean
|
||||||
val strictContextIds = pigeonVar_list[5] as List<String>
|
val protectedContextIds = pigeonVar_list[5] as List<String>
|
||||||
val protectedTargetPatterns = pigeonVar_list[6] as List<ProtectedTargetPattern>
|
val strictContextIds = pigeonVar_list[6] as List<String>
|
||||||
val contextOverrides = pigeonVar_list[7] as Map<String, NativeContextAppLinkPolicy>
|
val protectedTargetPatterns = pigeonVar_list[7] as List<ProtectedTargetPattern>
|
||||||
return AppLinkPolicySnapshot(globalMode, rules, marketplaceFallbackEnabled, protectGeneralContext, protectedContextIds, strictContextIds, protectedTargetPatterns, contextOverrides)
|
val contextOverrides = pigeonVar_list[8] as Map<String, NativeContextAppLinkPolicy>
|
||||||
|
return AppLinkPolicySnapshot(globalMode, rules, marketplaceFallbackEnabled, authExceptionsEnabled, protectGeneralContext, protectedContextIds, strictContextIds, protectedTargetPatterns, contextOverrides)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun toList(): List<Any?> {
|
fun toList(): List<Any?> {
|
||||||
@@ -5863,6 +5869,7 @@ data class AppLinkPolicySnapshot (
|
|||||||
globalMode,
|
globalMode,
|
||||||
rules,
|
rules,
|
||||||
marketplaceFallbackEnabled,
|
marketplaceFallbackEnabled,
|
||||||
|
authExceptionsEnabled,
|
||||||
protectGeneralContext,
|
protectGeneralContext,
|
||||||
protectedContextIds,
|
protectedContextIds,
|
||||||
strictContextIds,
|
strictContextIds,
|
||||||
@@ -5878,7 +5885,7 @@ data class AppLinkPolicySnapshot (
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
val other = other as AppLinkPolicySnapshot
|
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 {
|
override fun hashCode(): Int {
|
||||||
@@ -5886,6 +5893,7 @@ data class AppLinkPolicySnapshot (
|
|||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.globalMode)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.globalMode)
|
||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.rules)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.rules)
|
||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.marketplaceFallbackEnabled)
|
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.protectGeneralContext)
|
||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.protectedContextIds)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.protectedContextIds)
|
||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.strictContextIds)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.strictContextIds)
|
||||||
@@ -5894,7 +5902,7 @@ data class AppLinkPolicySnapshot (
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
override fun toString(): String {
|
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.
|
* unsupported-scheme prompt.
|
||||||
*/
|
*/
|
||||||
val isModal: Boolean,
|
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 {
|
companion object {
|
||||||
@@ -5937,7 +5952,8 @@ data class AppLinkPromptRequest (
|
|||||||
val canRemember = pigeonVar_list[8] as Boolean
|
val canRemember = pigeonVar_list[8] as Boolean
|
||||||
val isModal = pigeonVar_list[9] as Boolean
|
val isModal = pigeonVar_list[9] as Boolean
|
||||||
val target = pigeonVar_list[10] as AppLinkTarget
|
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?> {
|
fun toList(): List<Any?> {
|
||||||
@@ -5953,6 +5969,7 @@ data class AppLinkPromptRequest (
|
|||||||
canRemember,
|
canRemember,
|
||||||
isModal,
|
isModal,
|
||||||
target,
|
target,
|
||||||
|
expiresInMs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
@@ -5963,7 +5980,7 @@ data class AppLinkPromptRequest (
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
val other = other as AppLinkPromptRequest
|
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 {
|
override fun hashCode(): Int {
|
||||||
@@ -5979,10 +5996,11 @@ data class AppLinkPromptRequest (
|
|||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.canRemember)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.canRemember)
|
||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.isModal)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.isModal)
|
||||||
result = 31 * result + GeckoPigeonUtils.deepHash(this.target)
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.target)
|
||||||
|
result = 31 * result + GeckoPigeonUtils.deepHash(this.expiresInMs)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
override fun toString(): String {
|
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 ----
|
// ---- §2.2 table: engine-supported (http) scheme, app resolves ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun safeDefaultAllowsAuthExceptions() {
|
||||||
|
assertEquals(true, AppLinkPolicy.SAFE_DEFAULT.authExceptionsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun engineSupportedAlwaysAutoLaunches() {
|
fun engineSupportedAlwaysAutoLaunches() {
|
||||||
val d = AppLinkClassifier.classify(
|
val d = AppLinkClassifier.classify(
|
||||||
|
|||||||
+44
@@ -13,6 +13,7 @@ import kotlin.test.assertEquals
|
|||||||
import org.mockito.ArgumentMatchers.anyBoolean
|
import org.mockito.ArgumentMatchers.anyBoolean
|
||||||
import org.mockito.ArgumentMatchers.anyString
|
import org.mockito.ArgumentMatchers.anyString
|
||||||
import org.mockito.Mockito.mock
|
import org.mockito.Mockito.mock
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
import org.mockito.Mockito.`when`
|
import org.mockito.Mockito.`when`
|
||||||
|
|
||||||
class AppLinkLauncherTest {
|
class AppLinkLauncherTest {
|
||||||
@@ -69,6 +70,33 @@ class AppLinkLauncherTest {
|
|||||||
assertEquals(1, started)
|
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
|
@Test
|
||||||
fun automaticLaunchWithinCooldownIsRefused() {
|
fun automaticLaunchWithinCooldownIsRefused() {
|
||||||
val clock = FakeClock(1000L)
|
val clock = FakeClock(1000L)
|
||||||
@@ -78,6 +106,22 @@ class AppLinkLauncherTest {
|
|||||||
assertEquals(AppLinkLaunchResult.COOLDOWN, l.launch("zoommtg://x", AppLinkLaunchMode.AUTOMATIC))
|
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
|
@Test
|
||||||
fun automaticLaunchAfterCooldownSucceeds() {
|
fun automaticLaunchAfterCooldownSucceeds() {
|
||||||
val clock = FakeClock(1000L)
|
val clock = FakeClock(1000L)
|
||||||
|
|||||||
+71
-23
@@ -103,33 +103,82 @@ class PendingAppLinkStoreTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun bannerTargetCommitKeepsRequestButUnrelatedCommitInvalidates() {
|
fun aNewerBannerForTheTabReplacesTheOlderOne() {
|
||||||
val store = PendingAppLinkStore(FakeClock())
|
val clock = FakeClock()
|
||||||
val banner = store.createRequest(
|
val store = PendingAppLinkStore(clock)
|
||||||
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://youtu.be/x"),
|
val first = store.createRequest(
|
||||||
|
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
|
||||||
)
|
)
|
||||||
// The banner's own target committing keeps it alive.
|
clock.now = 5000L // past the dedupe window, so this is a genuinely new offer
|
||||||
store.onCommittedNavigation("tab1", "https://youtu.be/x")
|
val second = store.createRequest(
|
||||||
assertNotNull(store.peek(banner.requestId))
|
newRequest(
|
||||||
// An unrelated commit invalidates it.
|
urlClass = AppLinkUrlClass.BANNER,
|
||||||
store.onCommittedNavigation("tab1", "https://example.com/other")
|
url = "https://b.example/y",
|
||||||
assertNull(store.peek(banner.requestId))
|
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
|
@Test
|
||||||
fun bannerSurvivesSameSiteRedirectAndNormalisation() {
|
fun aBannerForAnotherTabIsUntouched() {
|
||||||
val store = PendingAppLinkStore(FakeClock())
|
val clock = FakeClock()
|
||||||
// The intercepted URL is rarely byte-identical to the committed one: the initial
|
val store = PendingAppLinkStore(clock)
|
||||||
// load redirects/normalises (www stripped, tracking params added, trailing slash).
|
val other = store.createRequest(
|
||||||
val banner = store.createRequest(
|
newRequest(tabId = "tab2", urlClass = AppLinkUrlClass.BANNER, url = "https://a.example/x"),
|
||||||
newRequest(urlClass = AppLinkUrlClass.BANNER, url = "https://www.reddit.com/r/foo"),
|
|
||||||
)
|
)
|
||||||
store.onCommittedNavigation("tab1", "https://reddit.com/r/foo/?utm_source=share")
|
clock.now = 5000L
|
||||||
assertNotNull(store.peek(banner.requestId))
|
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.
|
@Test
|
||||||
store.onCommittedNavigation("tab1", "https://twitter.com/reddit")
|
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))
|
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
|
@Test
|
||||||
@@ -137,7 +186,7 @@ class PendingAppLinkStoreTest {
|
|||||||
val store = PendingAppLinkStore(FakeClock())
|
val store = PendingAppLinkStore(FakeClock())
|
||||||
val request = store.createRequest(newRequest())
|
val request = store.createRequest(newRequest())
|
||||||
store.recordSuppression("tab1", "fp1")
|
store.recordSuppression("tab1", "fp1")
|
||||||
store.invalidateTab("tab1")
|
assertEquals(setOf(AppLinkPromptOwner.FLUTTER_BROWSER), store.invalidateTab("tab1"))
|
||||||
assertNull(store.peek(request.requestId))
|
assertNull(store.peek(request.requestId))
|
||||||
assertFalse(store.isSuppressed("tab1", "fp1"))
|
assertFalse(store.isSuppressed("tab1", "fp1"))
|
||||||
}
|
}
|
||||||
@@ -148,8 +197,7 @@ class PendingAppLinkStoreTest {
|
|||||||
val store = PendingAppLinkStore(clock, suppressionExpiryMs = 1000L)
|
val store = PendingAppLinkStore(clock, suppressionExpiryMs = 1000L)
|
||||||
store.recordSuppression("tab1", "fp1")
|
store.recordSuppression("tab1", "fp1")
|
||||||
assertTrue(store.isSuppressed("tab1", "fp1"))
|
assertTrue(store.isSuppressed("tab1", "fp1"))
|
||||||
// Ordinary committed navigation does not clear it.
|
// A redirect within the current load does not clear it (no load start is dispatched).
|
||||||
store.onCommittedNavigation("tab1", "https://redirect.example")
|
|
||||||
assertTrue(store.isSuppressed("tab1", "fp1"))
|
assertTrue(store.isSuppressed("tab1", "fp1"))
|
||||||
// Direct navigation clears it.
|
// Direct navigation clears it.
|
||||||
store.clearSuppressionForTab("tab1")
|
store.clearSuppressionForTab("tab1")
|
||||||
|
|||||||
@@ -16,12 +16,24 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_mozilla_components/src/domain/services/gecko_browser.dart';
|
import 'package:flutter_mozilla_components/src/domain/services/gecko_browser.dart';
|
||||||
|
|
||||||
class GeckoView extends StatefulWidget {
|
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;
|
final Future<void> Function()? postInitializationStep;
|
||||||
|
|
||||||
const GeckoView({
|
const GeckoView({
|
||||||
super.key,
|
super.key,
|
||||||
this.preInitializationStep,
|
required this.viewReadyEvents,
|
||||||
this.postInitializationStep,
|
this.postInitializationStep,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,17 +48,58 @@ class _GeckoViewState extends State<GeckoView> {
|
|||||||
|
|
||||||
final browserService = GeckoBrowserService();
|
final browserService = GeckoBrowserService();
|
||||||
late final AppLifecycleListener _listener;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_setupMethodCallHandler();
|
_setupMethodCallHandler();
|
||||||
_listener = AppLifecycleListener(
|
_listener = AppLifecycleListener(
|
||||||
onResume: () async {
|
onResume: () {
|
||||||
//Make sure fragment visible after rsuming the app in case native resources have been disposed
|
//Make sure fragment visible after resuming the app in case native resources have been disposed
|
||||||
await _showNativeFragment();
|
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() {
|
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({
|
Future<bool> _showNativeFragment({
|
||||||
int maxRetries = 100,
|
int maxRetries = 10,
|
||||||
|
|
||||||
/// Default ist about one frame
|
/// Default ist about one frame
|
||||||
Duration retryDelay = const Duration(milliseconds: 1000 ~/ 60),
|
Duration retryDelay = const Duration(milliseconds: 1000 ~/ 60),
|
||||||
@@ -90,6 +150,7 @@ class _GeckoViewState extends State<GeckoView> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
platform.setMethodCallHandler(null);
|
platform.setMethodCallHandler(null);
|
||||||
|
unawaited(_viewReadySubscription?.cancel());
|
||||||
_listener.dispose();
|
_listener.dispose();
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -118,9 +179,11 @@ class _GeckoViewState extends State<GeckoView> {
|
|||||||
params.onPlatformViewCreated(value);
|
params.onPlatformViewCreated(value);
|
||||||
|
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) async {
|
SchedulerBinding.instance.addPostFrameCallback((_) async {
|
||||||
await widget.preInitializationStep?.call();
|
// A first attempt for the common case where the view is painted
|
||||||
|
// from the frame it is created in, so the container is already
|
||||||
await _showNativeFragment();
|
// 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();
|
await widget.postInitializationStep?.call();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2994,6 +2994,10 @@ class AppLinkPolicySnapshot {
|
|||||||
|
|
||||||
final bool marketplaceFallbackEnabled;
|
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.
|
/// Regular / no-contextId tabs are proxied via the `general` scope.
|
||||||
final bool protectGeneralContext;
|
final bool protectGeneralContext;
|
||||||
|
|
||||||
@@ -3014,6 +3018,7 @@ class AppLinkPolicySnapshot {
|
|||||||
required this.globalMode,
|
required this.globalMode,
|
||||||
required this.rules,
|
required this.rules,
|
||||||
required this.marketplaceFallbackEnabled,
|
required this.marketplaceFallbackEnabled,
|
||||||
|
required this.authExceptionsEnabled,
|
||||||
required this.protectGeneralContext,
|
required this.protectGeneralContext,
|
||||||
required this.protectedContextIds,
|
required this.protectedContextIds,
|
||||||
required this.strictContextIds,
|
required this.strictContextIds,
|
||||||
@@ -3045,6 +3050,12 @@ class AppLinkPromptRequest {
|
|||||||
final bool isModal;
|
final bool isModal;
|
||||||
final AppLinkTarget target;
|
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({
|
const AppLinkPromptRequest({
|
||||||
required this.requestId,
|
required this.requestId,
|
||||||
required this.owner,
|
required this.owner,
|
||||||
@@ -3057,6 +3068,7 @@ class AppLinkPromptRequest {
|
|||||||
required this.canRemember,
|
required this.canRemember,
|
||||||
required this.isModal,
|
required this.isModal,
|
||||||
required this.target,
|
required this.target,
|
||||||
|
required this.expiresInMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
Object? _extractReplyValueOrThrow(
|
Object? _extractReplyValueOrThrow(
|
||||||
List<Object?>? replyList,
|
List<Object?>? replyList,
|
||||||
String channelName, {
|
String channelName, {
|
||||||
required bool isNullValid,
|
required bool isNullValid,
|
||||||
}) {
|
}) {
|
||||||
if (replyList == null) {
|
if (replyList == null) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
@@ -34,11 +34,8 @@ Object? _extractReplyValueOrThrow(
|
|||||||
return replyList.firstOrNull;
|
return replyList.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Object?> wrapResponse({
|
|
||||||
Object? result,
|
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||||
PlatformException? error,
|
|
||||||
bool empty = false,
|
|
||||||
}) {
|
|
||||||
if (empty) {
|
if (empty) {
|
||||||
return <Object?>[];
|
return <Object?>[];
|
||||||
}
|
}
|
||||||
@@ -47,7 +44,6 @@ List<Object?> wrapResponse({
|
|||||||
}
|
}
|
||||||
return <Object?>[error.code, error.message, error.details];
|
return <Object?>[error.code, error.message, error.details];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _deepEquals(Object? a, Object? b) {
|
bool _deepEquals(Object? a, Object? b) {
|
||||||
if (identical(a, b)) {
|
if (identical(a, b)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -60,9 +56,8 @@ bool _deepEquals(Object? a, Object? b) {
|
|||||||
}
|
}
|
||||||
if (a is List && b is List) {
|
if (a is List && b is List) {
|
||||||
return a.length == b.length &&
|
return a.length == b.length &&
|
||||||
a.indexed.every(
|
a.indexed
|
||||||
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
|
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (a is Map && b is Map) {
|
if (a is Map && b is Map) {
|
||||||
if (a.length != b.length) {
|
if (a.length != b.length) {
|
||||||
@@ -111,6 +106,7 @@ int _deepHash(Object? value) {
|
|||||||
return value.hashCode;
|
return value.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
enum SingboxProxyProfileType {
|
enum SingboxProxyProfileType {
|
||||||
socks,
|
socks,
|
||||||
http,
|
http,
|
||||||
@@ -129,7 +125,13 @@ enum SingboxProxyProfileType {
|
|||||||
customOutbound,
|
customOutbound,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SingboxProxyRuntimeStatus { stopped, starting, running, stopping, error }
|
enum SingboxProxyRuntimeStatus {
|
||||||
|
stopped,
|
||||||
|
starting,
|
||||||
|
running,
|
||||||
|
stopping,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
class SingboxProxyProfile {
|
class SingboxProxyProfile {
|
||||||
SingboxProxyProfile({
|
SingboxProxyProfile({
|
||||||
@@ -155,12 +157,17 @@ class SingboxProxyProfile {
|
|||||||
String? secretJson;
|
String? secretJson;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[id, name, type, configJson, secretJson];
|
return <Object?>[
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
configJson,
|
||||||
|
secretJson,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static SingboxProxyProfile decode(Object result) {
|
static SingboxProxyProfile decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -182,11 +189,7 @@ class SingboxProxyProfile {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(id, other.id) &&
|
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(type, other.type) && _deepEquals(configJson, other.configJson) && _deepEquals(secretJson, other.secretJson);
|
||||||
_deepEquals(name, other.name) &&
|
|
||||||
_deepEquals(type, other.type) &&
|
|
||||||
_deepEquals(configJson, other.configJson) &&
|
|
||||||
_deepEquals(secretJson, other.secretJson);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -236,8 +239,7 @@ class SingboxProxyRuntimeOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static SingboxProxyRuntimeOptions decode(Object result) {
|
static SingboxProxyRuntimeOptions decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -252,17 +254,13 @@ class SingboxProxyRuntimeOptions {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! SingboxProxyRuntimeOptions ||
|
if (other is! SingboxProxyRuntimeOptions || other.runtimeType != runtimeType) {
|
||||||
other.runtimeType != runtimeType) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(preferredBasePort, other.preferredBasePort) &&
|
return _deepEquals(preferredBasePort, other.preferredBasePort) && _deepEquals(blockUnmatchedTraffic, other.blockUnmatchedTraffic) && _deepEquals(dnsConfig, other.dnsConfig) && _deepEquals(bootstrapDohUrl, other.bootstrapDohUrl);
|
||||||
_deepEquals(blockUnmatchedTraffic, other.blockUnmatchedTraffic) &&
|
|
||||||
_deepEquals(dnsConfig, other.dnsConfig) &&
|
|
||||||
_deepEquals(bootstrapDohUrl, other.bootstrapDohUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -333,8 +331,7 @@ class SingboxProxyDnsServerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static SingboxProxyDnsServerConfig decode(Object result) {
|
static SingboxProxyDnsServerConfig decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -352,20 +349,13 @@ class SingboxProxyDnsServerConfig {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! SingboxProxyDnsServerConfig ||
|
if (other is! SingboxProxyDnsServerConfig || other.runtimeType != runtimeType) {
|
||||||
other.runtimeType != runtimeType) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(tag, other.tag) &&
|
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);
|
||||||
_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
|
@override
|
||||||
@@ -395,18 +385,20 @@ class SingboxProxyDnsConfig {
|
|||||||
String domainStrategy;
|
String domainStrategy;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[servers, finalServerTag, domainStrategy];
|
return <Object?>[
|
||||||
|
servers,
|
||||||
|
finalServerTag,
|
||||||
|
domainStrategy,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static SingboxProxyDnsConfig decode(Object result) {
|
static SingboxProxyDnsConfig decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
return SingboxProxyDnsConfig(
|
return SingboxProxyDnsConfig(
|
||||||
servers: (result[0]! as List<Object?>)
|
servers: (result[0]! as List<Object?>).cast<SingboxProxyDnsServerConfig>(),
|
||||||
.cast<SingboxProxyDnsServerConfig>(),
|
|
||||||
finalServerTag: result[1] as String?,
|
finalServerTag: result[1] as String?,
|
||||||
domainStrategy: result[2]! as String,
|
domainStrategy: result[2]! as String,
|
||||||
);
|
);
|
||||||
@@ -421,9 +413,7 @@ class SingboxProxyDnsConfig {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(servers, other.servers) &&
|
return _deepEquals(servers, other.servers) && _deepEquals(finalServerTag, other.finalServerTag) && _deepEquals(domainStrategy, other.domainStrategy);
|
||||||
_deepEquals(finalServerTag, other.finalServerTag) &&
|
|
||||||
_deepEquals(domainStrategy, other.domainStrategy);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -456,12 +446,17 @@ class SingboxProxyRuntimeEndpoint {
|
|||||||
String password;
|
String password;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[profileId, host, port, username, password];
|
return <Object?>[
|
||||||
|
profileId,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static SingboxProxyRuntimeEndpoint decode(Object result) {
|
static SingboxProxyRuntimeEndpoint decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -477,18 +472,13 @@ class SingboxProxyRuntimeEndpoint {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! SingboxProxyRuntimeEndpoint ||
|
if (other is! SingboxProxyRuntimeEndpoint || other.runtimeType != runtimeType) {
|
||||||
other.runtimeType != runtimeType) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(profileId, other.profileId) &&
|
return _deepEquals(profileId, other.profileId) && _deepEquals(host, other.host) && _deepEquals(port, other.port) && _deepEquals(username, other.username) && _deepEquals(password, other.password);
|
||||||
_deepEquals(host, other.host) &&
|
|
||||||
_deepEquals(port, other.port) &&
|
|
||||||
_deepEquals(username, other.username) &&
|
|
||||||
_deepEquals(password, other.password);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -515,19 +505,21 @@ class SingboxProxyRuntimeState {
|
|||||||
String? message;
|
String? message;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[status, endpoints, message];
|
return <Object?>[
|
||||||
|
status,
|
||||||
|
endpoints,
|
||||||
|
message,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static SingboxProxyRuntimeState decode(Object result) {
|
static SingboxProxyRuntimeState decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
return SingboxProxyRuntimeState(
|
return SingboxProxyRuntimeState(
|
||||||
status: result[0]! as SingboxProxyRuntimeStatus,
|
status: result[0]! as SingboxProxyRuntimeStatus,
|
||||||
endpoints: (result[1]! as List<Object?>)
|
endpoints: (result[1]! as List<Object?>).cast<SingboxProxyRuntimeEndpoint>(),
|
||||||
.cast<SingboxProxyRuntimeEndpoint>(),
|
|
||||||
message: result[2] as String?,
|
message: result[2] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -535,16 +527,13 @@ class SingboxProxyRuntimeState {
|
|||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! SingboxProxyRuntimeState ||
|
if (other is! SingboxProxyRuntimeState || other.runtimeType != runtimeType) {
|
||||||
other.runtimeType != runtimeType) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(status, other.status) &&
|
return _deepEquals(status, other.status) && _deepEquals(endpoints, other.endpoints) && _deepEquals(message, other.message);
|
||||||
_deepEquals(endpoints, other.endpoints) &&
|
|
||||||
_deepEquals(message, other.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -558,41 +547,43 @@ class SingboxProxyRuntimeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SingboxProxyConfigResult {
|
class SingboxProxyConfigResult {
|
||||||
SingboxProxyConfigResult({required this.configJson, required this.endpoints});
|
SingboxProxyConfigResult({
|
||||||
|
required this.configJson,
|
||||||
|
required this.endpoints,
|
||||||
|
});
|
||||||
|
|
||||||
String configJson;
|
String configJson;
|
||||||
|
|
||||||
List<SingboxProxyRuntimeEndpoint> endpoints;
|
List<SingboxProxyRuntimeEndpoint> endpoints;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[configJson, endpoints];
|
return <Object?>[
|
||||||
|
configJson,
|
||||||
|
endpoints,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static SingboxProxyConfigResult decode(Object result) {
|
static SingboxProxyConfigResult decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
return SingboxProxyConfigResult(
|
return SingboxProxyConfigResult(
|
||||||
configJson: result[0]! as String,
|
configJson: result[0]! as String,
|
||||||
endpoints: (result[1]! as List<Object?>)
|
endpoints: (result[1]! as List<Object?>).cast<SingboxProxyRuntimeEndpoint>(),
|
||||||
.cast<SingboxProxyRuntimeEndpoint>(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! SingboxProxyConfigResult ||
|
if (other is! SingboxProxyConfigResult || other.runtimeType != runtimeType) {
|
||||||
other.runtimeType != runtimeType) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(configJson, other.configJson) &&
|
return _deepEquals(configJson, other.configJson) && _deepEquals(endpoints, other.endpoints);
|
||||||
_deepEquals(endpoints, other.endpoints);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -622,12 +613,16 @@ class SingboxProxyLogMessage {
|
|||||||
String? profileId;
|
String? profileId;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[level, message, timestamp, profileId];
|
return <Object?>[
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
profileId,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static SingboxProxyLogMessage decode(Object result) {
|
static SingboxProxyLogMessage decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -648,10 +643,7 @@ class SingboxProxyLogMessage {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(level, other.level) &&
|
return _deepEquals(level, other.level) && _deepEquals(message, other.message) && _deepEquals(timestamp, other.timestamp) && _deepEquals(profileId, other.profileId);
|
||||||
_deepEquals(message, other.message) &&
|
|
||||||
_deepEquals(timestamp, other.timestamp) &&
|
|
||||||
_deepEquals(profileId, other.profileId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -664,6 +656,7 @@ class SingboxProxyLogMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -671,34 +664,34 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
if (value is int) {
|
if (value is int) {
|
||||||
buffer.putUint8(4);
|
buffer.putUint8(4);
|
||||||
buffer.putInt64(value);
|
buffer.putInt64(value);
|
||||||
} else if (value is SingboxProxyProfileType) {
|
} else if (value is SingboxProxyProfileType) {
|
||||||
buffer.putUint8(129);
|
buffer.putUint8(129);
|
||||||
writeValue(buffer, value.index);
|
writeValue(buffer, value.index);
|
||||||
} else if (value is SingboxProxyRuntimeStatus) {
|
} else if (value is SingboxProxyRuntimeStatus) {
|
||||||
buffer.putUint8(130);
|
buffer.putUint8(130);
|
||||||
writeValue(buffer, value.index);
|
writeValue(buffer, value.index);
|
||||||
} else if (value is SingboxProxyProfile) {
|
} else if (value is SingboxProxyProfile) {
|
||||||
buffer.putUint8(131);
|
buffer.putUint8(131);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SingboxProxyRuntimeOptions) {
|
} else if (value is SingboxProxyRuntimeOptions) {
|
||||||
buffer.putUint8(132);
|
buffer.putUint8(132);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SingboxProxyDnsServerConfig) {
|
} else if (value is SingboxProxyDnsServerConfig) {
|
||||||
buffer.putUint8(133);
|
buffer.putUint8(133);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SingboxProxyDnsConfig) {
|
} else if (value is SingboxProxyDnsConfig) {
|
||||||
buffer.putUint8(134);
|
buffer.putUint8(134);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SingboxProxyRuntimeEndpoint) {
|
} else if (value is SingboxProxyRuntimeEndpoint) {
|
||||||
buffer.putUint8(135);
|
buffer.putUint8(135);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SingboxProxyRuntimeState) {
|
} else if (value is SingboxProxyRuntimeState) {
|
||||||
buffer.putUint8(136);
|
buffer.putUint8(136);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SingboxProxyConfigResult) {
|
} else if (value is SingboxProxyConfigResult) {
|
||||||
buffer.putUint8(137);
|
buffer.putUint8(137);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SingboxProxyLogMessage) {
|
} else if (value is SingboxProxyLogMessage) {
|
||||||
buffer.putUint8(138);
|
buffer.putUint8(138);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
@@ -741,13 +734,9 @@ class SingboxProxyApi {
|
|||||||
/// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is
|
/// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
SingboxProxyApi({
|
SingboxProxyApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
BinaryMessenger? binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
String messageChannelSuffix = '',
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
@@ -755,97 +744,82 @@ class SingboxProxyApi {
|
|||||||
final String pigeonVar_messageChannelSuffix;
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
Future<String?> validateProfile(SingboxProxyProfile profile) async {
|
Future<String?> validateProfile(SingboxProxyProfile profile) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[profile]);
|
||||||
<Object?>[profile],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue as String?;
|
return pigeonVar_replyValue as String?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SingboxProxyConfigResult> buildConfig(
|
Future<SingboxProxyConfigResult> buildConfig(List<SingboxProxyProfile> profiles, SingboxProxyRuntimeOptions options) async {
|
||||||
List<SingboxProxyProfile> profiles,
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$pigeonVar_messageChannelSuffix';
|
||||||
SingboxProxyRuntimeOptions options,
|
|
||||||
) async {
|
|
||||||
final pigeonVar_channelName =
|
|
||||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[profiles, options]);
|
||||||
<Object?>[profiles, options],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue! as SingboxProxyConfigResult;
|
return pigeonVar_replyValue! as SingboxProxyConfigResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SingboxProxyRuntimeState> start(
|
Future<SingboxProxyRuntimeState> start(List<SingboxProxyProfile> profiles, SingboxProxyRuntimeOptions options) async {
|
||||||
List<SingboxProxyProfile> profiles,
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$pigeonVar_messageChannelSuffix';
|
||||||
SingboxProxyRuntimeOptions options,
|
|
||||||
) async {
|
|
||||||
final pigeonVar_channelName =
|
|
||||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[profiles, options]);
|
||||||
<Object?>[profiles, options],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
|
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop(List<String> profileIds) async {
|
Future<void> stop(List<String> profileIds) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[profileIds]);
|
||||||
<Object?>[profileIds],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopAll() async {
|
Future<void> stopAll() async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -855,15 +829,15 @@ class SingboxProxyApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SingboxProxyRuntimeState> getState() async {
|
Future<SingboxProxyRuntimeState> getState() async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -873,10 +847,11 @@ class SingboxProxyApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
|
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -888,62 +863,46 @@ abstract class SingboxProxyEventsApi {
|
|||||||
|
|
||||||
void onLogMessage(SingboxProxyLogMessage message);
|
void onLogMessage(SingboxProxyLogMessage message);
|
||||||
|
|
||||||
static void setUp(
|
static void setUp(SingboxProxyEventsApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||||
SingboxProxyEventsApi? api, {
|
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
BinaryMessenger? binaryMessenger,
|
|
||||||
String messageChannelSuffix = '',
|
|
||||||
}) {
|
|
||||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
{
|
{
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$messageChannelSuffix',
|
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$messageChannelSuffix', pigeonChannelCodec,
|
||||||
pigeonChannelCodec,
|
binaryMessenger: binaryMessenger);
|
||||||
binaryMessenger: binaryMessenger,
|
|
||||||
);
|
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
pigeonVar_channel.setMessageHandler(null);
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
} else {
|
} else {
|
||||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||||
final List<Object?> args = message! as List<Object?>;
|
final List<Object?> args = message! as List<Object?>;
|
||||||
final SingboxProxyRuntimeState arg_state =
|
final SingboxProxyRuntimeState arg_state = args[0]! as SingboxProxyRuntimeState;
|
||||||
args[0]! as SingboxProxyRuntimeState;
|
|
||||||
try {
|
try {
|
||||||
api.onStateChanged(arg_state);
|
api.onStateChanged(arg_state);
|
||||||
return wrapResponse(empty: true);
|
return wrapResponse(empty: true);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
return wrapResponse(error: e);
|
return wrapResponse(error: e);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return wrapResponse(
|
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||||
error: PlatformException(code: 'error', message: e.toString()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$messageChannelSuffix',
|
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
|
||||||
pigeonChannelCodec,
|
binaryMessenger: binaryMessenger);
|
||||||
binaryMessenger: binaryMessenger,
|
|
||||||
);
|
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
pigeonVar_channel.setMessageHandler(null);
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
} else {
|
} else {
|
||||||
pigeonVar_channel.setMessageHandler((Object? message) async {
|
pigeonVar_channel.setMessageHandler((Object? message) async {
|
||||||
final List<Object?> args = message! as List<Object?>;
|
final List<Object?> args = message! as List<Object?>;
|
||||||
final SingboxProxyLogMessage arg_message =
|
final SingboxProxyLogMessage arg_message = args[0]! as SingboxProxyLogMessage;
|
||||||
args[0]! as SingboxProxyLogMessage;
|
|
||||||
try {
|
try {
|
||||||
api.onLogMessage(arg_message);
|
api.onLogMessage(arg_message);
|
||||||
return wrapResponse(empty: true);
|
return wrapResponse(empty: true);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
return wrapResponse(error: e);
|
return wrapResponse(error: e);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return wrapResponse(
|
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||||
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;
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
Object? _extractReplyValueOrThrow(
|
Object? _extractReplyValueOrThrow(
|
||||||
List<Object?>? replyList,
|
List<Object?>? replyList,
|
||||||
String channelName, {
|
String channelName, {
|
||||||
required bool isNullValid,
|
required bool isNullValid,
|
||||||
}) {
|
}) {
|
||||||
if (replyList == null) {
|
if (replyList == null) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
@@ -34,11 +34,8 @@ Object? _extractReplyValueOrThrow(
|
|||||||
return replyList.firstOrNull;
|
return replyList.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Object?> wrapResponse({
|
|
||||||
Object? result,
|
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||||
PlatformException? error,
|
|
||||||
bool empty = false,
|
|
||||||
}) {
|
|
||||||
if (empty) {
|
if (empty) {
|
||||||
return <Object?>[];
|
return <Object?>[];
|
||||||
}
|
}
|
||||||
@@ -47,7 +44,6 @@ List<Object?> wrapResponse({
|
|||||||
}
|
}
|
||||||
return <Object?>[error.code, error.message, error.details];
|
return <Object?>[error.code, error.message, error.details];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _deepEquals(Object? a, Object? b) {
|
bool _deepEquals(Object? a, Object? b) {
|
||||||
if (identical(a, b)) {
|
if (identical(a, b)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -60,9 +56,8 @@ bool _deepEquals(Object? a, Object? b) {
|
|||||||
}
|
}
|
||||||
if (a is List && b is List) {
|
if (a is List && b is List) {
|
||||||
return a.length == b.length &&
|
return a.length == b.length &&
|
||||||
a.indexed.every(
|
a.indexed
|
||||||
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
|
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (a is Map && b is Map) {
|
if (a is Map && b is Map) {
|
||||||
if (a.length != b.length) {
|
if (a.length != b.length) {
|
||||||
@@ -111,29 +106,23 @@ int _deepHash(Object? value) {
|
|||||||
return value.hashCode;
|
return value.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Transport types for Tor connections
|
/// Transport types for Tor connections
|
||||||
enum TransportType {
|
enum TransportType {
|
||||||
/// Direct Tor connection (no bridges)
|
/// Direct Tor connection (no bridges)
|
||||||
none,
|
none,
|
||||||
|
|
||||||
/// obfs4 pluggable transport
|
/// obfs4 pluggable transport
|
||||||
obfs4,
|
obfs4,
|
||||||
|
|
||||||
/// Snowflake pluggable transport (default broker)
|
/// Snowflake pluggable transport (default broker)
|
||||||
snowflake,
|
snowflake,
|
||||||
|
|
||||||
/// Snowflake via AMP cache
|
/// Snowflake via AMP cache
|
||||||
snowflakeAmp,
|
snowflakeAmp,
|
||||||
|
|
||||||
/// Meek pluggable transport
|
/// Meek pluggable transport
|
||||||
meek,
|
meek,
|
||||||
|
|
||||||
/// Meek via Azure CDN
|
/// Meek via Azure CDN
|
||||||
meekAzure,
|
meekAzure,
|
||||||
|
|
||||||
/// WebTunnel pluggable transport
|
/// WebTunnel pluggable transport
|
||||||
webtunnel,
|
webtunnel,
|
||||||
|
|
||||||
/// Custom bridge lines (passthrough)
|
/// Custom bridge lines (passthrough)
|
||||||
custom,
|
custom,
|
||||||
}
|
}
|
||||||
@@ -174,8 +163,7 @@ class TorConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static TorConfiguration decode(Object result) {
|
static TorConfiguration decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -197,11 +185,7 @@ class TorConfiguration {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(transport, other.transport) &&
|
return _deepEquals(transport, other.transport) && _deepEquals(bridgeLines, other.bridgeLines) && _deepEquals(entryNodeCountries, other.entryNodeCountries) && _deepEquals(exitNodeCountries, other.exitNodeCountries) && _deepEquals(strictNodes, other.strictNodes);
|
||||||
_deepEquals(bridgeLines, other.bridgeLines) &&
|
|
||||||
_deepEquals(entryNodeCountries, other.entryNodeCountries) &&
|
|
||||||
_deepEquals(exitNodeCountries, other.exitNodeCountries) &&
|
|
||||||
_deepEquals(strictNodes, other.strictNodes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -250,8 +234,7 @@ class TorStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static TorStatus decode(Object result) {
|
static TorStatus decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -273,11 +256,7 @@ class TorStatus {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(isRunning, other.isRunning) &&
|
return _deepEquals(isRunning, other.isRunning) && _deepEquals(socksPort, other.socksPort) && _deepEquals(bootstrapProgress, other.bootstrapProgress) && _deepEquals(currentCircuit, other.currentCircuit) && _deepEquals(exitNodeCountry, other.exitNodeCountry);
|
||||||
_deepEquals(socksPort, other.socksPort) &&
|
|
||||||
_deepEquals(bootstrapProgress, other.bootstrapProgress) &&
|
|
||||||
_deepEquals(currentCircuit, other.currentCircuit) &&
|
|
||||||
_deepEquals(exitNodeCountry, other.exitNodeCountry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -308,12 +287,15 @@ class TorLogMessage {
|
|||||||
int timestamp;
|
int timestamp;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[severity, message, timestamp];
|
return <Object?>[
|
||||||
|
severity,
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static TorLogMessage decode(Object result) {
|
static TorLogMessage decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -333,9 +315,7 @@ class TorLogMessage {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(severity, other.severity) &&
|
return _deepEquals(severity, other.severity) && _deepEquals(message, other.message) && _deepEquals(timestamp, other.timestamp);
|
||||||
_deepEquals(message, other.message) &&
|
|
||||||
_deepEquals(timestamp, other.timestamp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -348,6 +328,7 @@ class TorLogMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -355,16 +336,16 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
if (value is int) {
|
if (value is int) {
|
||||||
buffer.putUint8(4);
|
buffer.putUint8(4);
|
||||||
buffer.putInt64(value);
|
buffer.putInt64(value);
|
||||||
} else if (value is TransportType) {
|
} else if (value is TransportType) {
|
||||||
buffer.putUint8(129);
|
buffer.putUint8(129);
|
||||||
writeValue(buffer, value.index);
|
writeValue(buffer, value.index);
|
||||||
} else if (value is TorConfiguration) {
|
} else if (value is TorConfiguration) {
|
||||||
buffer.putUint8(130);
|
buffer.putUint8(130);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is TorStatus) {
|
} else if (value is TorStatus) {
|
||||||
buffer.putUint8(131);
|
buffer.putUint8(131);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is TorLogMessage) {
|
} else if (value is TorLogMessage) {
|
||||||
buffer.putUint8(132);
|
buffer.putUint8(132);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
@@ -396,10 +377,8 @@ class TorApi {
|
|||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
@@ -409,30 +388,27 @@ class TorApi {
|
|||||||
/// Start Tor with the given configuration
|
/// Start Tor with the given configuration
|
||||||
/// Returns a Future to avoid blocking the main thread
|
/// Returns a Future to avoid blocking the main thread
|
||||||
Future<int> startTor(TorConfiguration config) async {
|
Future<int> startTor(TorConfiguration config) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[config]);
|
||||||
<Object?>[config],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue! as int;
|
return pigeonVar_replyValue! as int;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop Tor
|
/// Stop Tor
|
||||||
Future<void> stopTor() async {
|
Future<void> stopTor() async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -442,16 +418,16 @@ class TorApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current status
|
/// Get current status
|
||||||
Future<TorStatus> getStatus() async {
|
Future<TorStatus> getStatus() async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -461,17 +437,17 @@ class TorApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue! as TorStatus;
|
return pigeonVar_replyValue! as TorStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request a new Tor identity (new circuit)
|
/// Request a new Tor identity (new circuit)
|
||||||
Future<void> requestNewIdentity() async {
|
Future<void> requestNewIdentity() async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -481,10 +457,11 @@ class TorApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,20 +475,12 @@ abstract class TorLogApi {
|
|||||||
/// Called when status changes
|
/// Called when status changes
|
||||||
void onStatusChanged(TorStatus status);
|
void onStatusChanged(TorStatus status);
|
||||||
|
|
||||||
static void setUp(
|
static void setUp(TorLogApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||||
TorLogApi? api, {
|
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
BinaryMessenger? binaryMessenger,
|
|
||||||
String messageChannelSuffix = '',
|
|
||||||
}) {
|
|
||||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
{
|
{
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix',
|
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
|
||||||
pigeonChannelCodec,
|
binaryMessenger: binaryMessenger);
|
||||||
binaryMessenger: binaryMessenger,
|
|
||||||
);
|
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
pigeonVar_channel.setMessageHandler(null);
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
} else {
|
} else {
|
||||||
@@ -523,20 +492,16 @@ abstract class TorLogApi {
|
|||||||
return wrapResponse(empty: true);
|
return wrapResponse(empty: true);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
return wrapResponse(error: e);
|
return wrapResponse(error: e);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return wrapResponse(
|
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||||
error: PlatformException(code: 'error', message: e.toString()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix',
|
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix', pigeonChannelCodec,
|
||||||
pigeonChannelCodec,
|
binaryMessenger: binaryMessenger);
|
||||||
binaryMessenger: binaryMessenger,
|
|
||||||
);
|
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
pigeonVar_channel.setMessageHandler(null);
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
} else {
|
} else {
|
||||||
@@ -548,10 +513,8 @@ abstract class TorLogApi {
|
|||||||
return wrapResponse(empty: true);
|
return wrapResponse(empty: true);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
return wrapResponse(error: e);
|
return wrapResponse(error: e);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return wrapResponse(
|
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||||
error: PlatformException(code: 'error', message: e.toString()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -563,13 +526,9 @@ class IPtProxyController {
|
|||||||
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
|
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
IPtProxyController({
|
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
BinaryMessenger? binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
String messageChannelSuffix = '',
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
@@ -577,43 +536,39 @@ class IPtProxyController {
|
|||||||
final String pigeonVar_messageChannelSuffix;
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
Future<int> start(TransportType proxyType, String proxy) async {
|
Future<int> start(TransportType proxyType, String proxy) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType, proxy]);
|
||||||
<Object?>[proxyType, proxy],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue! as int;
|
return pigeonVar_replyValue! as int;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop(TransportType proxyType) async {
|
Future<void> stop(TransportType proxyType) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType]);
|
||||||
<Object?>[proxyType],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
Object? _extractReplyValueOrThrow(
|
Object? _extractReplyValueOrThrow(
|
||||||
List<Object?>? replyList,
|
List<Object?>? replyList,
|
||||||
String channelName, {
|
String channelName, {
|
||||||
required bool isNullValid,
|
required bool isNullValid,
|
||||||
}) {
|
}) {
|
||||||
if (replyList == null) {
|
if (replyList == null) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
@@ -46,9 +46,8 @@ bool _deepEquals(Object? a, Object? b) {
|
|||||||
}
|
}
|
||||||
if (a is List && b is List) {
|
if (a is List && b is List) {
|
||||||
return a.length == b.length &&
|
return a.length == b.length &&
|
||||||
a.indexed.every(
|
a.indexed
|
||||||
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
|
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (a is Map && b is Map) {
|
if (a is Map && b is Map) {
|
||||||
if (a.length != b.length) {
|
if (a.length != b.length) {
|
||||||
@@ -97,20 +96,26 @@ int _deepHash(Object? value) {
|
|||||||
return value.hashCode;
|
return value.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class LocalizedResult {
|
class LocalizedResult {
|
||||||
LocalizedResult({required this.languageName, this.countryName});
|
LocalizedResult({
|
||||||
|
required this.languageName,
|
||||||
|
this.countryName,
|
||||||
|
});
|
||||||
|
|
||||||
String languageName;
|
String languageName;
|
||||||
|
|
||||||
String? countryName;
|
String? countryName;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[languageName, countryName];
|
return <Object?>[
|
||||||
|
languageName,
|
||||||
|
countryName,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static LocalizedResult decode(Object result) {
|
static LocalizedResult decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -129,8 +134,7 @@ class LocalizedResult {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(languageName, other.languageName) &&
|
return _deepEquals(languageName, other.languageName) && _deepEquals(countryName, other.countryName);
|
||||||
_deepEquals(countryName, other.countryName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -143,6 +147,7 @@ class LocalizedResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -150,7 +155,7 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
if (value is int) {
|
if (value is int) {
|
||||||
buffer.putUint8(4);
|
buffer.putUint8(4);
|
||||||
buffer.putInt64(value);
|
buffer.putInt64(value);
|
||||||
} else if (value is LocalizedResult) {
|
} else if (value is LocalizedResult) {
|
||||||
buffer.putUint8(129);
|
buffer.putUint8(129);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
@@ -173,40 +178,31 @@ class LocaleResolver {
|
|||||||
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
|
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
LocaleResolver({
|
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
BinaryMessenger? binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
String messageChannelSuffix = '',
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
final String pigeonVar_messageChannelSuffix;
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
Future<LocalizedResult> resolve(
|
Future<LocalizedResult> resolve(String languageTag, String targetLangouageTag) async {
|
||||||
String languageTag,
|
final pigeonVar_channelName = 'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
|
||||||
String targetLangouageTag,
|
|
||||||
) async {
|
|
||||||
final pigeonVar_channelName =
|
|
||||||
'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[languageTag, targetLangouageTag]);
|
||||||
<Object?>[languageTag, targetLangouageTag],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue! as LocalizedResult;
|
return pigeonVar_replyValue! as LocalizedResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
Object? _extractReplyValueOrThrow(
|
Object? _extractReplyValueOrThrow(
|
||||||
List<Object?>? replyList,
|
List<Object?>? replyList,
|
||||||
String channelName, {
|
String channelName, {
|
||||||
required bool isNullValid,
|
required bool isNullValid,
|
||||||
}) {
|
}) {
|
||||||
if (replyList == null) {
|
if (replyList == null) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
@@ -34,11 +34,8 @@ Object? _extractReplyValueOrThrow(
|
|||||||
return replyList.firstOrNull;
|
return replyList.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Object?> wrapResponse({
|
|
||||||
Object? result,
|
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||||
PlatformException? error,
|
|
||||||
bool empty = false,
|
|
||||||
}) {
|
|
||||||
if (empty) {
|
if (empty) {
|
||||||
return <Object?>[];
|
return <Object?>[];
|
||||||
}
|
}
|
||||||
@@ -47,7 +44,6 @@ List<Object?> wrapResponse({
|
|||||||
}
|
}
|
||||||
return <Object?>[error.code, error.message, error.details];
|
return <Object?>[error.code, error.message, error.details];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _deepEquals(Object? a, Object? b) {
|
bool _deepEquals(Object? a, Object? b) {
|
||||||
if (identical(a, b)) {
|
if (identical(a, b)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -60,9 +56,8 @@ bool _deepEquals(Object? a, Object? b) {
|
|||||||
}
|
}
|
||||||
if (a is List && b is List) {
|
if (a is List && b is List) {
|
||||||
return a.length == b.length &&
|
return a.length == b.length &&
|
||||||
a.indexed.every(
|
a.indexed
|
||||||
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
|
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (a is Map && b is Map) {
|
if (a is Map && b is Map) {
|
||||||
if (a.length != b.length) {
|
if (a.length != b.length) {
|
||||||
@@ -111,6 +106,7 @@ int _deepHash(Object? value) {
|
|||||||
return value.hashCode;
|
return value.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Intent {
|
class Intent {
|
||||||
Intent({
|
Intent({
|
||||||
this.fromPackageName,
|
this.fromPackageName,
|
||||||
@@ -145,8 +141,7 @@ class Intent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object encode() {
|
Object encode() {
|
||||||
return _toList();
|
return _toList(); }
|
||||||
}
|
|
||||||
|
|
||||||
static Intent decode(Object result) {
|
static Intent decode(Object result) {
|
||||||
result as List<Object?>;
|
result as List<Object?>;
|
||||||
@@ -169,12 +164,7 @@ class Intent {
|
|||||||
if (identical(this, other)) {
|
if (identical(this, other)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return _deepEquals(fromPackageName, other.fromPackageName) &&
|
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);
|
||||||
_deepEquals(action, other.action) &&
|
|
||||||
_deepEquals(data, other.data) &&
|
|
||||||
_deepEquals(categories, other.categories) &&
|
|
||||||
_deepEquals(mimeType, other.mimeType) &&
|
|
||||||
_deepEquals(extra, other.extra);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -187,6 +177,7 @@ class Intent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -194,7 +185,7 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
if (value is int) {
|
if (value is int) {
|
||||||
buffer.putUint8(4);
|
buffer.putUint8(4);
|
||||||
buffer.putInt64(value);
|
buffer.putInt64(value);
|
||||||
} else if (value is Intent) {
|
} else if (value is Intent) {
|
||||||
buffer.putUint8(129);
|
buffer.putUint8(129);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
@@ -217,13 +208,9 @@ class IntentHost {
|
|||||||
/// Constructor for [IntentHost]. The [binaryMessenger] named argument is
|
/// Constructor for [IntentHost]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
IntentHost({
|
IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
BinaryMessenger? binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
String messageChannelSuffix = '',
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
@@ -235,8 +222,7 @@ class IntentHost {
|
|||||||
/// IntentEvents.setUp() was called (cold-start deep links).
|
/// IntentEvents.setUp() was called (cold-start deep links).
|
||||||
/// Returns null if no launch intent is pending.
|
/// Returns null if no launch intent is pending.
|
||||||
Future<Intent?> getInitialIntent() async {
|
Future<Intent?> getInitialIntent() async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -246,10 +232,11 @@ class IntentHost {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue as Intent?;
|
return pigeonVar_replyValue as Intent?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,20 +246,12 @@ abstract class IntentEvents {
|
|||||||
|
|
||||||
void onIntentReceived(int sequence, Intent intent);
|
void onIntentReceived(int sequence, Intent intent);
|
||||||
|
|
||||||
static void setUp(
|
static void setUp(IntentEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||||
IntentEvents? api, {
|
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
BinaryMessenger? binaryMessenger,
|
|
||||||
String messageChannelSuffix = '',
|
|
||||||
}) {
|
|
||||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
{
|
{
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix',
|
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix', pigeonChannelCodec,
|
||||||
pigeonChannelCodec,
|
binaryMessenger: binaryMessenger);
|
||||||
binaryMessenger: binaryMessenger,
|
|
||||||
);
|
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
pigeonVar_channel.setMessageHandler(null);
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
} else {
|
} else {
|
||||||
@@ -285,10 +264,8 @@ abstract class IntentEvents {
|
|||||||
return wrapResponse(empty: true);
|
return wrapResponse(empty: true);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
return wrapResponse(error: e);
|
return wrapResponse(error: e);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return wrapResponse(
|
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||||
error: PlatformException(code: 'error', message: e.toString()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -300,13 +277,9 @@ class IntentGatekeeperHostApi {
|
|||||||
/// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is
|
/// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
IntentGatekeeperHostApi({
|
IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
BinaryMessenger? binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
String messageChannelSuffix = '',
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
@@ -316,23 +289,21 @@ class IntentGatekeeperHostApi {
|
|||||||
/// Replicates the blocked-packages policy to the native side so the
|
/// Replicates the blocked-packages policy to the native side so the
|
||||||
/// [IntentReceiverActivity] can reject intents without launching Flutter.
|
/// [IntentReceiverActivity] can reject intents without launching Flutter.
|
||||||
Future<void> setConfig(bool enabled, List<String> blockedPackages) async {
|
Future<void> setConfig(bool enabled, List<String> blockedPackages) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled, blockedPackages]);
|
||||||
<Object?>[enabled, blockedPackages],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replicates whether the Custom Tabs feature is enabled to the native side.
|
/// 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
|
/// share-with-URL intents to the main browser instead of launching the
|
||||||
/// stripped-down custom-tab activity. Defaults to enabled on the native side.
|
/// stripped-down custom-tab activity. Defaults to enabled on the native side.
|
||||||
Future<void> setCustomTabsEnabled(bool enabled) async {
|
Future<void> setCustomTabsEnabled(bool enabled) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setCustomTabsEnabled$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setCustomTabsEnabled$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled]);
|
||||||
<Object?>[enabled],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a package name to its user-visible application label via
|
/// Resolves a package name to its user-visible application label via
|
||||||
/// [PackageManager]. Returns `null` if the package is not installed or the
|
/// [PackageManager]. Returns `null` if the package is not installed or the
|
||||||
/// label cannot be resolved.
|
/// label cannot be resolved.
|
||||||
Future<String?> resolvePackageLabel(String packageName) async {
|
Future<String?> resolvePackageLabel(String packageName) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[packageName]);
|
||||||
<Object?>[packageName],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue as String?;
|
return pigeonVar_replyValue as String?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,8 +356,7 @@ class IntentGatekeeperHostApi {
|
|||||||
/// [ackPendingAlwaysAllows] after Flutter settings were updated
|
/// [ackPendingAlwaysAllows] after Flutter settings were updated
|
||||||
/// successfully.
|
/// successfully.
|
||||||
Future<List<String>> getPendingAlwaysAllows() async {
|
Future<List<String>> getPendingAlwaysAllows() async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
@@ -400,32 +366,31 @@ class IntentGatekeeperHostApi {
|
|||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the given packages from the pending "Always allow" set after
|
/// Removes the given packages from the pending "Always allow" set after
|
||||||
/// Flutter has successfully persisted them into its own policy store.
|
/// Flutter has successfully persisted them into its own policy store.
|
||||||
Future<void> ackPendingAlwaysAllows(List<String> packageNames) async {
|
Future<void> ackPendingAlwaysAllows(List<String> packageNames) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.ackPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.ackPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[packageNames]);
|
||||||
<Object?>[packageNames],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
_extractReplyValueOrThrow(
|
_extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: true,
|
isNullValid: true,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
Object? _extractReplyValueOrThrow(
|
Object? _extractReplyValueOrThrow(
|
||||||
List<Object?>? replyList,
|
List<Object?>? replyList,
|
||||||
String channelName, {
|
String channelName, {
|
||||||
required bool isNullValid,
|
required bool isNullValid,
|
||||||
}) {
|
}) {
|
||||||
if (replyList == null) {
|
if (replyList == null) {
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
@@ -34,11 +34,8 @@ Object? _extractReplyValueOrThrow(
|
|||||||
return replyList.firstOrNull;
|
return replyList.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Object?> wrapResponse({
|
|
||||||
Object? result,
|
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
|
||||||
PlatformException? error,
|
|
||||||
bool empty = false,
|
|
||||||
}) {
|
|
||||||
if (empty) {
|
if (empty) {
|
||||||
return <Object?>[];
|
return <Object?>[];
|
||||||
}
|
}
|
||||||
@@ -48,6 +45,7 @@ List<Object?> wrapResponse({
|
|||||||
return <Object?>[error.code, error.message, error.details];
|
return <Object?>[error.code, error.message, error.details];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _PigeonCodec extends StandardMessageCodec {
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
const _PigeonCodec();
|
const _PigeonCodec();
|
||||||
@override
|
@override
|
||||||
@@ -74,13 +72,9 @@ class SpeechToTextApi {
|
|||||||
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
|
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
|
||||||
/// available for dependency injection. If it is left null, the default
|
/// available for dependency injection. If it is left null, the default
|
||||||
/// BinaryMessenger will be used which routes to the host platform.
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
SpeechToTextApi({
|
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
BinaryMessenger? binaryMessenger,
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
String messageChannelSuffix = '',
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
}) : pigeonVar_binaryMessenger = binaryMessenger,
|
|
||||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
@@ -95,23 +89,21 @@ class SpeechToTextApi {
|
|||||||
/// The [locale] parameter specifies the language locale for recognition
|
/// The [locale] parameter specifies the language locale for recognition
|
||||||
/// (e.g., 'en-US', 'de-DE'). If null, uses the device default.
|
/// (e.g., 'en-US', 'de-DE'). If null, uses the device default.
|
||||||
Future<bool> showDialog({String? locale}) async {
|
Future<bool> showDialog({String? locale}) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName = 'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
|
||||||
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
|
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[locale]);
|
||||||
<Object?>[locale],
|
|
||||||
);
|
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
pigeonVar_replyList,
|
pigeonVar_replyList,
|
||||||
pigeonVar_channelName,
|
pigeonVar_channelName,
|
||||||
isNullValid: false,
|
isNullValid: false,
|
||||||
);
|
)
|
||||||
|
;
|
||||||
return pigeonVar_replyValue! as bool;
|
return pigeonVar_replyValue! as bool;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,20 +118,12 @@ abstract class SpeechToTextEvents {
|
|||||||
/// recognition failed or was cancelled.
|
/// recognition failed or was cancelled.
|
||||||
void onTextReceived(String text);
|
void onTextReceived(String text);
|
||||||
|
|
||||||
static void setUp(
|
static void setUp(SpeechToTextEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
|
||||||
SpeechToTextEvents? api, {
|
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
BinaryMessenger? binaryMessenger,
|
|
||||||
String messageChannelSuffix = '',
|
|
||||||
}) {
|
|
||||||
messageChannelSuffix = messageChannelSuffix.isNotEmpty
|
|
||||||
? '.$messageChannelSuffix'
|
|
||||||
: '';
|
|
||||||
{
|
{
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix',
|
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix', pigeonChannelCodec,
|
||||||
pigeonChannelCodec,
|
binaryMessenger: binaryMessenger);
|
||||||
binaryMessenger: binaryMessenger,
|
|
||||||
);
|
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
pigeonVar_channel.setMessageHandler(null);
|
pigeonVar_channel.setMessageHandler(null);
|
||||||
} else {
|
} else {
|
||||||
@@ -151,10 +135,8 @@ abstract class SpeechToTextEvents {
|
|||||||
return wrapResponse(empty: true);
|
return wrapResponse(empty: true);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
return wrapResponse(error: e);
|
return wrapResponse(error: e);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return wrapResponse(
|
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
|
||||||
error: PlatformException(code: 'error', message: e.toString()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,3 +106,13 @@ melos:
|
|||||||
set -e
|
set -e
|
||||||
cd apps/weblibre
|
cd apps/weblibre
|
||||||
flutter build apk --release --flavor alphaLegacy --target-platform android-arm,android-arm64 --split-per-abi --no-tree-shake-icons
|
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
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
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