Refactor H5 line management and cache handling in WebView app

- Introduced shared_preferences for initial H5 cache management.
- Added methods to clear H5 caches and probe line availability.
- Enhanced error handling for line loading and switching.
- Removed openim_common package and its configurations.
- Updated widget tests to reflect changes in H5 line handling and URL management.
This commit is contained in:
Booker
2026-05-29 17:56:55 +07:00
parent 131df57dfe
commit a1e7b3e52c
11 changed files with 881 additions and 288 deletions

View File

@@ -1,25 +1,15 @@
# im_webview_app # im_webview_app
Flutter WebView 套壳 App默认加载: Flutter WebView 套壳 App启动后会请求 `/client_config/query` 获取 H5 线路配置,并加载第一条可用线路。
```text
https://h5-test.imharry.work/
```
## H5 线路切换 ## H5 线路切换
线路切换在 Flutter 套壳层完成H5 页面不需要承载线路切换逻辑。每条线路对应一个独立 WebView切换时只切换当前显示的 WebView不会改写 H5 页面运行中的请求地址 线路切换在 Flutter 套壳层完成。远程配置不可用时,会先使用启动兜底线路;也可以在打包时覆盖启动线路或配置接口地址
默认线路配置在:
```text
openim_common/lib/src/config.dart
```
也可以在打包时覆盖:
```bash ```bash
flutter build apk --release --dart-define=H5_LINE_URLS=https://h5-one.example/,https://h5-two.example/ flutter build apk --release --dart-define=H5_LINE_URLS=https://h5-one.example/,https://h5-two.example/
flutter build apk --release --dart-define=CLIENT_CONFIG_QUERY_URL=https://api.example.com/client_config/query
flutter build apk --release --dart-define=BOOTSTRAP_H5_LINE_URL=https://h5-one.example/
``` ```
## 本地打包 ## 本地打包

View File

@@ -16,6 +16,9 @@ import android.util.Base64
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebStorage
import android.webkit.WebView
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@@ -31,6 +34,7 @@ class MainActivity : FlutterActivity() {
private val CHANNEL = "io.openim.flutter.openim/apk_info" private val CHANNEL = "io.openim.flutter.openim/apk_info"
private val SHELL_BRANDING_CHANNEL = "io.openim.flutter.im_webview_app/shell_branding" private val SHELL_BRANDING_CHANNEL = "io.openim.flutter.im_webview_app/shell_branding"
private val FILE_PICKER_CHANNEL = "io.openim.flutter.openim/file_picker" private val FILE_PICKER_CHANNEL = "io.openim.flutter.openim/file_picker"
private val H5_CACHE_CHANNEL = "io.openim.flutter.im_webview_app/h5_cache"
private val TAG = "MainActivity" private val TAG = "MainActivity"
private val MAX_BRANDING_ICON_SIZE = 192 private val MAX_BRANDING_ICON_SIZE = 192
private val FILE_PICKER_REQUEST_CODE = 4201 private val FILE_PICKER_REQUEST_CODE = 4201
@@ -144,6 +148,36 @@ class MainActivity : FlutterActivity() {
} }
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, H5_CACHE_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"clearAllWebsiteData" -> {
try {
clearAllH5WebsiteData()
result.success(true)
} catch (e: Exception) {
result.error("CLEAR_H5_CACHE_FAILED", e.message, null)
}
}
else -> {
result.notImplemented()
}
}
}
}
private fun clearAllH5WebsiteData() {
WebStorage.getInstance().deleteAllData()
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
val webView = WebView(this)
try {
webView.clearCache(true)
webView.clearHistory()
webView.clearFormData()
} finally {
webView.destroy()
}
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {

View File

@@ -1,5 +1,8 @@
import Flutter import Flutter
import UIKit import UIKit
import WebKit
private let h5CacheChannelName = "io.openim.flutter.im_webview_app/h5_cache"
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -12,5 +15,31 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
let h5CacheChannel = FlutterMethodChannel(
name: h5CacheChannelName,
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
h5CacheChannel.setMethodCallHandler { [weak self] call, result in
switch call.method {
case "clearAllWebsiteData":
guard let self else {
result(false)
return
}
self.clearAllH5WebsiteData(result: result)
default:
result(FlutterMethodNotImplemented)
}
}
}
private func clearAllH5WebsiteData(result: @escaping FlutterResult) {
let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
WKWebsiteDataStore.default().removeData(
ofTypes: dataTypes,
modifiedSince: Date(timeIntervalSince1970: 0)
) {
result(true)
}
} }
} }

View File

@@ -1,8 +1,4 @@
import 'package:openim_common/openim_common.dart' as openim_common; import 'dart:math';
enum Environment {
production,
}
class H5Line { class H5Line {
const H5Line({ const H5Line({
@@ -14,49 +10,29 @@ class H5Line {
final String url; final String url;
Uri get uri => Uri.parse(url); Uri get uri => Uri.parse(url);
String get displayAddress {
final parsed = uri;
final host = parsed.hasPort ? '${parsed.host}:${parsed.port}' : parsed.host;
final path = parsed.path.isEmpty || parsed.path == '/' ? '' : parsed.path;
return '$host$path';
}
} }
class AppConfig { class AppConfig {
static const Environment currentEnvironment = Environment.production;
static const String appName = '本地打包'; static const String appName = '本地打包';
static const String appLogo = ''; static const String appLogo = '';
static const String canonicalWebHost = 'h5-test.imharry.work'; static const String clientConfigDevice = 'h5';
static const String clientConfigSerialNo = 'h5-domain';
static const String _dartDefinedH5LineUrls = static const String _dartDefinedH5LineUrls =
String.fromEnvironment('H5_LINE_URLS'); String.fromEnvironment('H5_LINE_URLS');
static const Set<String> legacyWebHosts = { static const String _dartDefinedClientConfigQueryUrl =
'h5-test.imharry.work', String.fromEnvironment('CLIENT_CONFIG_QUERY_URL');
}; // Bootstrap only: used before /client_config/query returns dynamic H5 domains.
static const String _bootstrapH5LineUrl = String.fromEnvironment(
static final Map<Environment, List<String>> _environmentHosts = { 'BOOTSTRAP_H5_LINE_URL',
Environment.production: [ defaultValue: 'https://h5-test.imharry.work/');
canonicalWebHost, static final Random _wildcardRandom = Random.secure();
],
};
static List<String> get environmentHosts {
final generatedHosts = _normalizedUniqueUrls(
openim_common.Config.environmentHosts,
);
if (generatedHosts.isNotEmpty) {
return generatedHosts;
}
return _normalizedUniqueUrls(_environmentHosts[currentEnvironment] ?? []);
}
static List<H5Line> get h5Lines { static List<H5Line> get h5Lines {
final configuredUrls = _dartDefinedH5LineUrls.trim().isEmpty final configuredUrls = _dartDefinedH5LineUrls.trim().isEmpty
? environmentHosts ? _normalizedUniqueUrls(const [_bootstrapH5LineUrl])
: _normalizedUniqueUrls(_dartDefinedH5LineUrls.split(',')); : _normalizedUniqueUrls(_dartDefinedH5LineUrls.split(','));
final urls = configuredUrls.isEmpty final urls = configuredUrls.isEmpty
? _normalizedUniqueUrls(const [canonicalWebHost]) ? _normalizedUniqueUrls(const [_bootstrapH5LineUrl])
: configuredUrls; : configuredUrls;
return [ return [
@@ -65,22 +41,73 @@ class AppConfig {
]; ];
} }
static String homeUrl({int lineIndex = 0, String? appName, String? appLogo}) { static Map<String, String> get clientConfigQueryPayload => const {
'device': clientConfigDevice,
'serialNo': clientConfigSerialNo,
};
static List<Uri> get clientConfigQueryUris {
final dartDefinedUrl = _dartDefinedClientConfigQueryUrl.trim();
if (dartDefinedUrl.isNotEmpty) {
return [Uri.parse(dartDefinedUrl)];
}
final lines = h5Lines; final lines = h5Lines;
final safeIndex = _safeLineIndex(lineIndex, lines.length); if (lines.isEmpty) {
return lines[safeIndex].url; return const [];
}
final homeUri = lines.first.uri;
return [
homeUri.replace(
path: '/client_config/query',
queryParameters: const {},
fragment: '',
),
homeUri.replace(
path: '/api/user/client_config/query',
queryParameters: const {},
fragment: '',
),
];
} }
static String withFreshShellParams(String url) { static List<H5Line> h5LinesFromResourceUrl(
return _removeShellParams(Uri.parse(url)).toString(); String resourceUrl, {
String Function()? wildcardFactory,
}) {
final urls = _normalizedUniqueUrls(
splitResourceUrlLines(resourceUrl).map(
(value) => _replaceWildcardHost(
value,
wildcardFactory: wildcardFactory,
),
),
);
return [
for (var index = 0; index < urls.length; index += 1)
H5Line(label: '线路${index + 1}', url: urls[index]),
];
} }
static bool shouldRewriteMainFrameUrl(String url) { static List<String> splitResourceUrlLines(String resourceUrl) {
return false; if (resourceUrl.trim().isEmpty) {
} return const [];
}
static String canonicalizeMainFrameUrl(String url) { final lines = <String>[];
return url; final seen = <String>{};
for (final value
in resourceUrl.split(RegExp(r'\\r\\n|\\n|\\r|\\t|[\r\n\t]+'))) {
final line = value.trim();
if (line.isEmpty || seen.contains(line)) {
continue;
}
lines.add(line);
seen.add(line);
}
return List.unmodifiable(lines);
} }
static bool isLoginPageUrl(String? url) { static bool isLoginPageUrl(String? url) {
@@ -96,46 +123,6 @@ class AppConfig {
return _isLoginPath(uri.path) || _isLoginPath(uri.fragment); return _isLoginPath(uri.path) || _isLoginPath(uri.fragment);
} }
static int? lineIndexForUrl(String url) {
final uri = Uri.tryParse(url);
if (uri == null) {
return null;
}
final lines = h5Lines;
for (var index = 0; index < lines.length; index += 1) {
if (_isSameLine(lines[index].uri, uri)) {
return index;
}
}
return null;
}
static Uri _removeShellParams(Uri uri) {
final queryParameters = Map<String, String>.from(uri.queryParameters);
queryParameters.remove('flutter_shell');
queryParameters.remove('shell_cache_bust');
queryParameters.remove('shell_app_name');
queryParameters.remove('shell_app_logo');
final fragmentParameters = Uri.splitQueryString(uri.fragment);
fragmentParameters.remove('shell_app_name');
fragmentParameters.remove('shell_app_logo');
return uri.replace(
queryParameters: queryParameters,
fragment: fragmentParameters.isEmpty
? ''
: Uri(queryParameters: fragmentParameters).query,
);
}
static int _safeLineIndex(int index, int length) {
if (length <= 0 || index < 0 || index >= length) {
return 0;
}
return index;
}
static bool _isLoginPath(String path) { static bool _isLoginPath(String path) {
final normalized = path.trim(); final normalized = path.trim();
if (normalized.isEmpty) { if (normalized.isEmpty) {
@@ -151,29 +138,6 @@ class AppConfig {
return segments.last == 'login'; return segments.last == 'login';
} }
static bool _isSameLine(Uri lineUri, Uri uri) {
if (lineUri.host.toLowerCase() != uri.host.toLowerCase()) {
return false;
}
if (lineUri.hasPort && lineUri.port != uri.port) {
return false;
}
if (lineUri.scheme.isNotEmpty && lineUri.scheme != uri.scheme) {
return false;
}
final linePath = _pathWithTrailingSlash(lineUri.path);
final uriPath = _pathWithTrailingSlash(uri.path);
return linePath == '/' || uriPath.startsWith(linePath);
}
static String _pathWithTrailingSlash(String path) {
if (path.isEmpty) {
return '/';
}
return path.endsWith('/') ? path : '$path/';
}
static List<String> _normalizedUniqueUrls(Iterable<String> values) { static List<String> _normalizedUniqueUrls(Iterable<String> values) {
final urls = <String>[]; final urls = <String>[];
final seen = <String>{}; final seen = <String>{};
@@ -212,4 +176,28 @@ class AppConfig {
final path = uri.path.isEmpty ? '/' : uri.path; final path = uri.path.isEmpty ? '/' : uri.path;
return uri.replace(path: path).toString(); return uri.replace(path: path).toString();
} }
static String _replaceWildcardHost(
String value, {
String Function()? wildcardFactory,
}) {
if (!value.contains('*.')) {
return value;
}
return value.replaceAllMapped(
'*.',
(_) => '${(wildcardFactory ?? _randomWildcardLabel)()}.',
);
}
static String _randomWildcardLabel() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
return String.fromCharCodes(
List<int>.generate(
16,
(_) => chars.codeUnitAt(_wildcardRandom.nextInt(chars.length)),
),
);
}
} }

View File

@@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart';
@@ -19,7 +21,11 @@ const _shellBrandingChannel =
MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); MethodChannel('io.openim.flutter.im_webview_app/shell_branding');
const _androidFilePickerChannel = const _androidFilePickerChannel =
MethodChannel('io.openim.flutter.openim/file_picker'); MethodChannel('io.openim.flutter.openim/file_picker');
const _h5CacheChannel =
MethodChannel('io.openim.flutter.im_webview_app/h5_cache');
const _keyboardAnimationDuration = Duration(milliseconds: 250); const _keyboardAnimationDuration = Duration(milliseconds: 250);
const _initialH5CacheClearedKey = 'initial_h5_cache_cleared_v1';
const _lineProbeTimeout = Duration(seconds: 5);
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -67,6 +73,12 @@ class ShellBranding {
static String _trim(String? value) => value?.trim() ?? ''; static String _trim(String? value) => value?.trim() ?? '';
} }
enum _LineAvailability {
checking,
available,
unavailable,
}
class ImWebViewApp extends StatelessWidget { class ImWebViewApp extends StatelessWidget {
const ImWebViewApp({ const ImWebViewApp({
super.key, super.key,
@@ -100,15 +112,19 @@ class H5ShellPage extends StatefulWidget {
} }
class _H5ShellPageState extends State<H5ShellPage> { class _H5ShellPageState extends State<H5ShellPage> {
late final List<H5Line> _h5Lines; late List<H5Line> _h5Lines;
late final List<_H5LineWebViewSlot> _lineSlots; late List<_H5LineWebViewSlot> _lineSlots;
bool _shellBrandingLoaded = false; bool _shellBrandingLoaded = false;
bool _isPreparingInitialLine = true;
bool _isAutoSwitchingLine = false;
int _currentLineIndex = 0; int _currentLineIndex = 0;
double _lastKeyboardInset = 0; double _lastKeyboardInset = 0;
bool _lastKeyboardVisible = false; bool _lastKeyboardVisible = false;
int _keyboardSyncToken = 0; int _keyboardSyncToken = 0;
int _lineProbeGeneration = 0;
late ShellBranding _shellBranding; late ShellBranding _shellBranding;
Future<void>? _cacheClearTask;
H5Line get _currentLine => _h5Lines[_currentLineIndex]; H5Line get _currentLine => _h5Lines[_currentLineIndex];
_H5LineWebViewSlot get _currentSlot => _lineSlots[_currentLineIndex]; _H5LineWebViewSlot get _currentSlot => _lineSlots[_currentLineIndex];
@@ -118,14 +134,18 @@ class _H5ShellPageState extends State<H5ShellPage> {
super.initState(); super.initState();
_h5Lines = AppConfig.h5Lines; _h5Lines = AppConfig.h5Lines;
_shellBranding = widget.initialShellBranding; _shellBranding = widget.initialShellBranding;
_lineSlots = [ _lineSlots = _createLineSlots(_h5Lines);
for (var index = 0; index < _h5Lines.length; index += 1) unawaited(_initializeLines());
}
List<_H5LineWebViewSlot> _createLineSlots(List<H5Line> lines) {
return [
for (var index = 0; index < lines.length; index += 1)
_H5LineWebViewSlot( _H5LineWebViewSlot(
line: _h5Lines[index], line: lines[index],
controller: _buildController(index), controller: _buildController(index),
), ),
]; ];
unawaited(_ensureLineLoaded(_currentLineIndex));
} }
WebViewController _buildController(int lineIndex) { WebViewController _buildController(int lineIndex) {
@@ -181,13 +201,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
}, },
onWebResourceError: (error) { onWebResourceError: (error) {
if (error.isForMainFrame ?? true) { if (error.isForMainFrame ?? true) {
final slot = _lineSlots[lineIndex]; _handleMainFrameLoadError(lineIndex, error.description);
slot.shellCoverFallbackTimer?.cancel();
slot.loadError = error.description;
slot.showShellCover = false;
if (mounted && lineIndex == _currentLineIndex) {
setState(() {});
}
} }
}, },
onNavigationRequest: _handleNavigationRequest, onNavigationRequest: _handleNavigationRequest,
@@ -238,6 +252,413 @@ class _H5ShellPageState extends State<H5ShellPage> {
super.dispose(); super.dispose();
} }
Future<void> _initializeLines() async {
await _clearInitialH5CachesIfNeeded();
final runtimeLines = await _fetchRuntimeH5Lines();
if (!mounted) {
return;
}
if (runtimeLines.isNotEmpty && !_hasSameLineUrls(_h5Lines, runtimeLines)) {
setState(() {
_replaceLineSlots(runtimeLines);
});
}
final firstAvailableIndex = await _probeLinesUntilFirstAvailable();
if (!mounted) {
return;
}
if (firstAvailableIndex == null) {
setState(() {
_isPreparingInitialLine = false;
_currentLineIndex = 0;
_currentSlot.loadError = '所有线路暂不可用,请稍后重试';
_currentSlot.showShellCover = false;
});
return;
}
setState(() {
_isPreparingInitialLine = false;
});
await _loadLine(firstAvailableIndex);
}
Future<void> _clearInitialH5CachesIfNeeded() async {
try {
final preferences = await SharedPreferences.getInstance();
if (preferences.getBool(_initialH5CacheClearedKey) == true) {
return;
}
await _clearAllH5Caches();
await preferences.setBool(_initialH5CacheClearedKey, true);
} catch (_) {
await _clearAllH5Caches();
}
}
Future<void> _clearAllH5Caches() {
final runningTask = _cacheClearTask;
if (runningTask != null) {
return runningTask;
}
final task = _clearAllH5CachesInternal();
_cacheClearTask = task;
return task.whenComplete(() {
if (identical(_cacheClearTask, task)) {
_cacheClearTask = null;
}
});
}
Future<void> _clearAllH5CachesInternal() async {
await _clearNativeH5WebsiteData();
await _clearCookiesSafely();
if (_lineSlots.isEmpty) {
final controller = WebViewController();
await _clearControllerStorage(controller);
return;
}
for (final slot in _lineSlots) {
await _clearPageStorage(slot.controller);
await _clearControllerStorage(slot.controller);
}
}
Future<void> _clearNativeH5WebsiteData() async {
try {
await _h5CacheChannel.invokeMethod<void>('clearAllWebsiteData');
} catch (_) {
// Older native shells may not expose the cache channel yet.
}
}
Future<void> _clearCookiesSafely() async {
try {
await WebViewCookieManager().clearCookies();
} catch (_) {
// Cookie clearing is best-effort across platform WebView implementations.
}
}
Future<void> _clearControllerStorage(WebViewController controller) async {
try {
await controller.clearCache();
} catch (_) {}
try {
await controller.clearLocalStorage();
} catch (_) {}
}
Future<void> _clearPageStorage(WebViewController controller) async {
const script = '''
(() => {
try {
if ('caches' in window) {
caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key))));
}
} catch (_) {}
try {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations()
.then((items) => Promise.all(items.map((item) => item.unregister())));
}
} catch (_) {}
try {
if (typeof indexedDB !== 'undefined' && indexedDB.databases) {
indexedDB.databases().then((databases) => {
databases.forEach((database) => {
if (database && database.name) {
indexedDB.deleteDatabase(database.name);
}
});
});
}
} catch (_) {}
try { window.localStorage.clear(); } catch (_) {}
try { window.sessionStorage.clear(); } catch (_) {}
})();
''';
try {
await controller.runJavaScript(script);
} catch (_) {}
}
Future<List<H5Line>> _fetchRuntimeH5Lines() async {
_logH5Lines('启动兜底线路', _h5Lines);
for (final uri in AppConfig.clientConfigQueryUris) {
try {
_logH5LineDebug(
'请求线路配置: $uri payload=${jsonEncode(AppConfig.clientConfigQueryPayload)}',
);
final lines = await _fetchRuntimeH5LinesFrom(uri);
if (lines.isNotEmpty) {
_logH5Lines('接口换算后的线路', lines);
return lines;
}
_logH5LineDebug('线路配置接口没有返回可用 resourceUrl: $uri');
} catch (error) {
_logH5LineDebug('线路配置请求失败: $uri error=$error');
// Try the next compatible endpoint shape.
}
}
_logH5LineDebug('没有获取到远程线路配置,将继续使用启动兜底线路');
return const [];
}
Future<List<H5Line>> _fetchRuntimeH5LinesFrom(Uri uri) async {
final client = HttpClient()..connectionTimeout = _lineProbeTimeout;
try {
final request = await client.postUrl(uri).timeout(_lineProbeTimeout);
request.headers.contentType = ContentType.json;
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
request.headers.set('operationID', _createOperationID());
request.write(jsonEncode(AppConfig.clientConfigQueryPayload));
final response = await request.close().timeout(_lineProbeTimeout);
final body = await utf8.decoder.bind(response).join();
_logH5LineDebug(
'线路配置响应: $uri status=${response.statusCode} body=${_previewForLog(body)}',
);
if (response.statusCode < 200 || response.statusCode >= 300) {
return const [];
}
final decoded = jsonDecode(body);
final resourceUrl = _firstResourceUrlFromClientConfig(decoded);
if (resourceUrl == null || resourceUrl.trim().isEmpty) {
_logH5LineDebug('线路配置未找到 resourceUrl: $uri');
return const [];
}
_logH5LineDebug(
'resourceUrl 原始值: ${_escapeForLog(resourceUrl)}',
);
final resourceLines = AppConfig.splitResourceUrlLines(resourceUrl);
_logStringList('resourceUrl 拆分结果', resourceLines);
return AppConfig.h5LinesFromResourceUrl(resourceUrl);
} finally {
client.close(force: true);
}
}
String _createOperationID() =>
'flutter-h5-line-${DateTime.now().microsecondsSinceEpoch}';
String? _firstResourceUrlFromClientConfig(Object? decoded) {
if (decoded is! Map) {
return null;
}
final data = decoded['data'];
final entries = decoded['entries'] ??
(data is Map ? data['entries'] : null) ??
(data is List ? data : null);
if (entries is! List) {
return null;
}
for (final entry in entries) {
if (entry is! Map) {
continue;
}
final resourceType = entry['resourceType']?.toString().trim() ?? '';
final resourceUrl = entry['resourceUrl']?.toString();
if (resourceUrl == null || resourceUrl.trim().isEmpty) {
continue;
}
if (resourceType.isNotEmpty && resourceType != 'text') {
continue;
}
return resourceUrl;
}
return null;
}
Future<int?> _probeLinesUntilFirstAvailable() async {
final generation = ++_lineProbeGeneration;
for (var index = 0; index < _lineSlots.length; index += 1) {
if (!mounted || generation != _lineProbeGeneration) {
return null;
}
if (await _probeLineAndUpdate(index, generation: generation)) {
unawaited(_probeRemainingLines(index + 1, generation));
return index;
}
}
return null;
}
Future<void> _probeRemainingLines(int startIndex, int generation) async {
for (var index = startIndex; index < _lineSlots.length; index += 1) {
if (!mounted || generation != _lineProbeGeneration) {
return;
}
await _probeLineAndUpdate(index, generation: generation);
}
}
Future<void> _refreshAllLineAvailability() async {
final generation = ++_lineProbeGeneration;
if (mounted) {
setState(() {
for (final slot in _lineSlots) {
slot.availability = _LineAvailability.checking;
}
});
}
await Future.wait([
for (var index = 0; index < _lineSlots.length; index += 1)
_probeLineAndUpdate(index, generation: generation),
]);
}
Future<bool> _probeLineAndUpdate(
int index, {
required int generation,
}) async {
if (index < 0 || index >= _lineSlots.length) {
return false;
}
final line = _lineSlots[index].line;
_setLineAvailability(
index,
_LineAvailability.checking,
generation: generation,
line: line,
);
final available = await _isLineReachable(line);
if (!mounted ||
generation != _lineProbeGeneration ||
index < 0 ||
index >= _lineSlots.length ||
_lineSlots[index].line.url != line.url) {
return false;
}
_setLineAvailability(
index,
available ? _LineAvailability.available : _LineAvailability.unavailable,
generation: generation,
line: line,
);
_logH5LineDebug(
'线路探测: ${line.label} ${line.url} => ${available ? '可用' : '不可用'}',
);
return available;
}
Future<bool> _isLineReachable(H5Line line) async {
final client = HttpClient()..connectionTimeout = _lineProbeTimeout;
try {
final request = await client.getUrl(line.uri).timeout(_lineProbeTimeout);
request.followRedirects = true;
request.maxRedirects = 5;
final response = await request.close().timeout(_lineProbeTimeout);
final available = response.statusCode >= 200 && response.statusCode < 400;
unawaited(response.drain<void>().catchError((_) {}));
return available;
} catch (_) {
return false;
} finally {
client.close(force: true);
}
}
void _setLineAvailability(
int index,
_LineAvailability availability, {
required int generation,
required H5Line line,
}) {
if (!mounted ||
generation != _lineProbeGeneration ||
index < 0 ||
index >= _lineSlots.length ||
_lineSlots[index].line.url != line.url ||
_lineSlots[index].availability == availability) {
return;
}
setState(() {
_lineSlots[index].availability = availability;
});
}
void _replaceLineSlots(List<H5Line> lines) {
for (final slot in _lineSlots) {
slot.dispose();
}
_lineProbeGeneration += 1;
_h5Lines = lines;
_lineSlots = _createLineSlots(lines);
_currentLineIndex = 0;
_isAutoSwitchingLine = false;
_logH5Lines('WebView 使用的线路', _h5Lines);
}
void _logH5Lines(String title, List<H5Line> lines) {
_logH5LineDebug('$title count=${lines.length}');
for (var index = 0; index < lines.length; index += 1) {
_logH5LineDebug(
'$title[${index + 1}] ${lines[index].label} => ${lines[index].url}',
);
}
}
void _logStringList(String title, List<String> values) {
_logH5LineDebug('$title count=${values.length}');
for (var index = 0; index < values.length; index += 1) {
_logH5LineDebug('$title[${index + 1}] ${_escapeForLog(values[index])}');
}
}
void _logH5LineDebug(String message) {
debugPrint('[H5LineDebug] $message');
}
String _previewForLog(String value, {int maxLength = 1200}) {
final escaped = _escapeForLog(value);
if (escaped.length <= maxLength) {
return escaped;
}
return '${escaped.substring(0, maxLength)}...<${escaped.length} chars>';
}
String _escapeForLog(String value) {
return value
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
}
bool _hasSameLineUrls(List<H5Line> left, List<H5Line> right) {
if (left.length != right.length) {
return false;
}
for (var index = 0; index < left.length; index += 1) {
if (left[index].url != right[index].url) {
return false;
}
}
return true;
}
Future<void> _runJavaScriptSafely(int lineIndex, String source) async { Future<void> _runJavaScriptSafely(int lineIndex, String source) async {
try { try {
await _lineSlots[lineIndex].controller.runJavaScript(source); await _lineSlots[lineIndex].controller.runJavaScript(source);
@@ -252,6 +673,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
await _installRouteObserver(lineIndex); await _installRouteObserver(lineIndex);
await _syncKeyboardState(lineIndex); await _syncKeyboardState(lineIndex);
final slot = _lineSlots[lineIndex]; final slot = _lineSlots[lineIndex];
slot.availability = _LineAvailability.available;
slot.progress = 100; slot.progress = 100;
if (mounted && if (mounted &&
lineIndex == _currentLineIndex && lineIndex == _currentLineIndex &&
@@ -548,6 +970,64 @@ class _H5ShellPageState extends State<H5ShellPage> {
); );
} }
void _handleMainFrameLoadError(int lineIndex, String description) {
if (lineIndex < 0 || lineIndex >= _lineSlots.length) {
return;
}
final slot = _lineSlots[lineIndex];
slot.shellCoverFallbackTimer?.cancel();
slot.availability = _LineAvailability.unavailable;
slot.loadError = description;
slot.showShellCover = false;
if (mounted && lineIndex == _currentLineIndex) {
setState(() {});
unawaited(_switchToNextUsableLine(lineIndex));
}
}
Future<void> _switchToNextUsableLine(int failedIndex) async {
if (_isAutoSwitchingLine || _lineSlots.length <= 1) {
return;
}
_isAutoSwitchingLine = true;
try {
for (final index in _fallbackLineIndexes(failedIndex)) {
if (!mounted || failedIndex != _currentLineIndex) {
return;
}
final slot = _lineSlots[index];
if (slot.availability == _LineAvailability.unavailable) {
continue;
}
var available = slot.availability == _LineAvailability.available;
if (!available) {
final generation = _lineProbeGeneration;
available = await _probeLineAndUpdate(index, generation: generation);
}
if (available && mounted && failedIndex == _currentLineIndex) {
await _loadLine(index);
return;
}
}
} finally {
_isAutoSwitchingLine = false;
}
}
Iterable<int> _fallbackLineIndexes(int failedIndex) sync* {
for (var index = failedIndex + 1; index < _lineSlots.length; index += 1) {
yield index;
}
for (var index = 0; index < failedIndex; index += 1) {
yield index;
}
}
void _updateSlotUrl(int lineIndex, String? url) { void _updateSlotUrl(int lineIndex, String? url) {
if (url == null || lineIndex < 0 || lineIndex >= _lineSlots.length) { if (url == null || lineIndex < 0 || lineIndex >= _lineSlots.length) {
return; return;
@@ -560,9 +1040,12 @@ class _H5ShellPageState extends State<H5ShellPage> {
} }
} }
Future<void> _ensureLineLoaded(int lineIndex) async { Future<void> _loadLineHomeRequest(
int lineIndex, {
bool force = false,
}) async {
final slot = _lineSlots[lineIndex]; final slot = _lineSlots[lineIndex];
if (slot.hasLoadedInitialRequest) { if (slot.hasLoadedInitialRequest && !force) {
return; return;
} }
@@ -596,19 +1079,23 @@ class _H5ShellPageState extends State<H5ShellPage> {
if (slot.hasLoadedInitialRequest) { if (slot.hasLoadedInitialRequest) {
await slot.controller.reload(); await slot.controller.reload();
} else { } else {
await _ensureLineLoaded(_currentLineIndex); await _loadLineHomeRequest(_currentLineIndex);
} }
} }
Future<void> _loadLine(int index) async { Future<void> _loadLine(int index, {bool forceReload = false}) async {
final safeIndex = index < 0 || index >= _h5Lines.length ? 0 : index; final safeIndex = index < 0 || index >= _h5Lines.length ? 0 : index;
if (_lineSlots[safeIndex].availability == _LineAvailability.unavailable) {
return;
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_currentLineIndex = safeIndex; _currentLineIndex = safeIndex;
}); });
} }
await _ensureLineLoaded(safeIndex); await _loadLineHomeRequest(safeIndex, force: forceReload);
await _syncKeyboardState(safeIndex); await _syncKeyboardState(safeIndex);
} }
@@ -634,7 +1121,13 @@ class _H5ShellPageState extends State<H5ShellPage> {
); );
} }
void _showLineSwitcher() { Future<void> _showLineSwitcher() async {
await _clearAllH5Caches();
await _refreshAllLineAvailability();
if (!mounted) {
return;
}
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
useSafeArea: true, useSafeArea: true,
@@ -646,12 +1139,14 @@ class _H5ShellPageState extends State<H5ShellPage> {
builder: (sheetContext) { builder: (sheetContext) {
return _LineSwitcherSheet( return _LineSwitcherSheet(
lines: _h5Lines, lines: _h5Lines,
slots: _lineSlots,
currentIndex: _currentLineIndex, currentIndex: _currentLineIndex,
onSelect: (index) { onSelect: (index) {
Navigator.of(sheetContext).pop(); if (_lineSlots[index].availability != _LineAvailability.available) {
if (index != _currentLineIndex) { return;
unawaited(_loadLine(index));
} }
Navigator.of(sheetContext).pop();
unawaited(_loadLine(index, forceReload: true));
}, },
); );
}, },
@@ -695,12 +1190,14 @@ class _H5ShellPageState extends State<H5ShellPage> {
final topInset = MediaQuery.paddingOf(context).top; final topInset = MediaQuery.paddingOf(context).top;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom; final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
_scheduleKeyboardStateSync(bottomInset); _scheduleKeyboardStateSync(bottomInset);
final showLineSwitch = !currentSlot.showShellCover && final showLineSwitch = !_isPreparingInitialLine &&
!currentSlot.showShellCover &&
currentSlot.loadError == null && currentSlot.loadError == null &&
currentSlot.isLoginPage && currentSlot.isLoginPage &&
bottomInset == 0; bottomInset == 0;
final shouldPaintShellFallback = final shouldPaintShellFallback =
currentSlot.isAwaitingFirstScreen && currentSlot.loadError == null; (_isPreparingInitialLine || currentSlot.isAwaitingFirstScreen) &&
currentSlot.loadError == null;
return PopScope( return PopScope(
canPop: false, canPop: false,
@@ -760,7 +1257,7 @@ class _H5ShellPageState extends State<H5ShellPage> {
bottom: MediaQuery.paddingOf(context).bottom + 14, bottom: MediaQuery.paddingOf(context).bottom + 14,
child: _LineSwitchBar( child: _LineSwitchBar(
currentLine: _currentLine, currentLine: _currentLine,
onSwitch: _showLineSwitcher, onSwitch: () => unawaited(_showLineSwitcher()),
), ),
), ),
], ],
@@ -806,6 +1303,7 @@ class _H5LineWebViewSlot {
int progress = 0; int progress = 0;
String? loadError; String? loadError;
_LineAvailability availability = _LineAvailability.checking;
bool showShellCover = true; bool showShellCover = true;
bool hasPresentedFirstScreen = false; bool hasPresentedFirstScreen = false;
bool hasLoadedInitialRequest = false; bool hasLoadedInitialRequest = false;
@@ -960,11 +1458,13 @@ class _LineSwitchBar extends StatelessWidget {
class _LineSwitcherSheet extends StatelessWidget { class _LineSwitcherSheet extends StatelessWidget {
const _LineSwitcherSheet({ const _LineSwitcherSheet({
required this.lines, required this.lines,
required this.slots,
required this.currentIndex, required this.currentIndex,
required this.onSelect, required this.onSelect,
}); });
final List<H5Line> lines; final List<H5Line> lines;
final List<_H5LineWebViewSlot> slots;
final int currentIndex; final int currentIndex;
final ValueChanged<int> onSelect; final ValueChanged<int> onSelect;
@@ -998,10 +1498,12 @@ class _LineSwitcherSheet extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 10), separatorBuilder: (context, index) => const SizedBox(height: 10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final line = lines[index]; final line = lines[index];
final availability = slots[index].availability;
final selected = index == currentIndex; final selected = index == currentIndex;
return _LineOptionTile( return _LineOptionTile(
line: line, line: line,
availability: availability,
selected: selected, selected: selected,
onTap: () => onSelect(index), onTap: () => onSelect(index),
); );
@@ -1017,29 +1519,43 @@ class _LineSwitcherSheet extends StatelessWidget {
class _LineOptionTile extends StatelessWidget { class _LineOptionTile extends StatelessWidget {
const _LineOptionTile({ const _LineOptionTile({
required this.line, required this.line,
required this.availability,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
}); });
final H5Line line; final H5Line line;
final _LineAvailability availability;
final bool selected; final bool selected;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final enabled = availability == _LineAvailability.available;
final foregroundColor = enabled ? const Color(0xFF17233D) : _shellSubText;
final borderColor = selected && enabled
? _shellAccent
: enabled
? const Color(0xFFE1EAF5)
: const Color(0xFFE4E8EE);
return Material( return Material(
color: selected ? const Color(0xFFEFF7FF) : Colors.white, color: selected && enabled
? const Color(0xFFEFF7FF)
: enabled
? Colors.white
: const Color(0xFFF4F6F9),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: InkWell( child: InkWell(
onTap: onTap, onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Container( child: Container(
constraints: const BoxConstraints(minHeight: 64), constraints: const BoxConstraints(minHeight: 58),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: selected ? _shellAccent : const Color(0xFFE1EAF5), color: borderColor,
), ),
), ),
child: Row( child: Row(
@@ -1048,7 +1564,7 @@ class _LineOptionTile extends StatelessWidget {
selected selected
? Icons.radio_button_checked_rounded ? Icons.radio_button_checked_rounded
: Icons.radio_button_unchecked_rounded, : Icons.radio_button_unchecked_rounded,
color: selected ? _shellAccent : _shellSubText, color: selected && enabled ? _shellAccent : _shellSubText,
size: 22, size: 22,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -1061,34 +1577,21 @@ class _LineOptionTile extends StatelessWidget {
line.label, line.label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: Color(0xFF17233D), color: foregroundColor,
fontSize: 16, fontSize: 16,
height: 1.2, height: 1.2,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0, letterSpacing: 0,
), ),
), ),
const SizedBox(height: 4),
Text(
line.displayAddress,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: _shellSubText,
fontSize: 12,
height: 1.2,
fontWeight: FontWeight.w500,
letterSpacing: 0,
),
),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon( Icon(
Icons.chevron_right_rounded, Icons.chevron_right_rounded,
color: _shellSubText, color: enabled ? _shellSubText : const Color(0xFFC1CBD8),
size: 22, size: 22,
), ),
], ],

View File

@@ -1,2 +0,0 @@
export 'src/config.dart';

View File

@@ -1,9 +0,0 @@
class Config {
static const List<String> defaultEnvironmentHosts = [
'h5-test.imharry.work',
];
static List<String> get environmentHosts {
return defaultEnvironmentHosts;
}
}

View File

@@ -1,7 +0,0 @@
name: openim_common
description: Build-server compatible configuration bridge for the H5 shell.
publish_to: 'none'
environment:
sdk: ">=3.6.0 <4.0.0"

View File

@@ -49,6 +49,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -128,13 +144,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.17.0"
openim_common:
dependency: "direct main"
description:
path: openim_common
relative: true
source: path
version: "0.0.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -143,6 +152,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -191,6 +224,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.1" version: "0.2.1"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -199,6 +240,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -372,6 +469,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.25.1" version: "3.25.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0" flutter: ">=3.38.0"

View File

@@ -32,12 +32,11 @@ dependencies:
sdk: flutter sdk: flutter
permission_handler: ^12.0.1 permission_handler: ^12.0.1
shared_preferences: ^2.5.5
url_launcher: ^6.3.2 url_launcher: ^6.3.2
webview_flutter: ^4.13.1 webview_flutter: ^4.13.1
webview_flutter_android: ^4.10.5 webview_flutter_android: ^4.10.5
webview_flutter_wkwebview: ^3.23.1 webview_flutter_wkwebview: ^3.23.1
openim_common:
path: openim_common
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -11,108 +11,71 @@ void main() {
); );
}); });
test('loads the configured H5 root URL without shell URL params', () { test('exposes bootstrap H5 URL as Flutter shell lines', () {
const logo = 'data:image/png;base64,abc+/=';
final uri = Uri.parse(
AppConfig.homeUrl(appName: 'Shell Test', appLogo: logo),
);
expect(uri.scheme, 'https');
expect(uri.host, 'h5-test.imharry.work');
expect(uri.path, '/');
expect(uri.queryParameters.containsKey('flutter_shell'), isFalse);
expect(uri.queryParameters.containsKey('shell_app_name'), isFalse);
expect(uri.queryParameters.containsKey('shell_app_logo'), isFalse);
expect(uri.queryParameters.containsKey('shell_cache_bust'), isFalse);
expect(uri.toString(), isNot(contains('%25')));
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
isFalse);
});
test('exposes configured H5 URLs as Flutter shell lines', () {
final lines = AppConfig.h5Lines; final lines = AppConfig.h5Lines;
expect(lines, isNotEmpty); expect(lines, isNotEmpty);
expect(lines.first.label, '线路1'); expect(lines.first.label, '线路1');
expect(lines.first.url, AppConfig.homeUrl(lineIndex: 0)); expect(Uri.parse(lines.first.url).scheme, 'https');
expect(Uri.parse(lines.first.url).host, 'h5-test.imharry.work'); expect(Uri.parse(lines.first.url).path, '/');
}); });
test('falls back to the first H5 line for invalid line indexes', () { test('uses fixed client config query parameters', () {
expect(AppConfig.homeUrl(lineIndex: -1), AppConfig.h5Lines.first.url); expect(AppConfig.clientConfigQueryPayload, {
expect(AppConfig.homeUrl(lineIndex: 99), AppConfig.h5Lines.first.url); 'device': 'h5',
'serialNo': 'h5-domain',
});
expect(
AppConfig.clientConfigQueryUris.first.path,
'/client_config/query',
);
}); });
test('matches runtime URLs back to their Flutter shell line', () { test('splits client config resourceUrl into indexed H5 lines', () {
final lineUrl = AppConfig.h5Lines.first.url; final lines = AppConfig.h5LinesFromResourceUrl(
'line-one.example\nhttps://line-two.example/app\n\nline-three.example',
);
expect(AppConfig.lineIndexForUrl('${lineUrl}login'), 0); expect(lines.map((line) => line.label), ['线路1', '线路2', '线路3']);
expect(Uri.parse(lines[0].url).host, 'line-one.example');
expect(Uri.parse(lines[1].url).host, 'line-two.example');
expect(Uri.parse(lines[1].url).path, '/app');
expect(Uri.parse(lines[2].url).host, 'line-three.example');
});
test('splits literal backslash-n resourceUrl values', () {
final lines = AppConfig.h5LinesFromResourceUrl(
r'line-one.example\n*.line-two.example\nline-three.example',
wildcardFactory: () => 'abcdefghijklmnop',
);
expect(lines.map((line) => line.label), ['线路1', '线路2', '线路3']);
expect(Uri.parse(lines[0].url).host, 'line-one.example');
expect(Uri.parse(lines[1].url).host, 'abcdefghijklmnop.line-two.example');
expect(Uri.parse(lines[2].url).host, 'line-three.example');
});
test('expands wildcard H5 domains with a 16 character label', () {
final lines = AppConfig.h5LinesFromResourceUrl(
'*.albzyuxq.vip',
wildcardFactory: () => 'abcdefghijklmnop',
);
final host = Uri.parse(lines.single.url).host;
expect(host, 'abcdefghijklmnop.albzyuxq.vip');
expect(host.split('.').first.length, 16);
}); });
test('detects only H5 login routes for shell line switch display', () { test('detects only H5 login routes for shell line switch display', () {
expect(AppConfig.isLoginPageUrl('https://line-one.example/login'), isTrue);
expect( expect(
AppConfig.isLoginPageUrl('https://h5-test.imharry.work/login'), isTrue); AppConfig.isLoginPageUrl('https://line-one.example/app/login'), isTrue);
expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/app/login'), expect(
isTrue); AppConfig.isLoginPageUrl('https://line-one.example/#/login'), isTrue);
expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/#/login'), expect(AppConfig.isLoginPageUrl('https://line-one.example/'), isFalse);
isTrue); expect(
expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/'), isFalse); AppConfig.isLoginPageUrl('https://line-one.example/contact'), isFalse);
expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/contact'), expect(
isFalse); AppConfig.isLoginPageUrl('https://line-one.example/getCode'), isFalse);
expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/getCode'),
isFalse);
});
test('refreshes an H5 route URL without adding branding to the URL', () {
final uri = Uri.parse(
AppConfig.withFreshShellParams(
'https://h5-test.imharry.work/login?from=runtime&shell_app_name=Old'
'&flutter_shell=1&shell_cache_bust=1#shell_app_logo=old',
),
);
expect(uri.path, '/login');
expect(uri.queryParameters['from'], 'runtime');
expect(uri.queryParameters.containsKey('flutter_shell'), isFalse);
expect(uri.queryParameters.containsKey('shell_app_name'), isFalse);
expect(uri.queryParameters.containsKey('shell_app_logo'), isFalse);
expect(uri.queryParameters.containsKey('shell_cache_bust'), isFalse);
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
isFalse);
});
test('keeps H5 runtime URLs untouched during shell routing', () {
final uri = Uri.parse(
AppConfig.canonicalizeMainFrameUrl(
'https://line-two.example/login?shell_app_name=Old'
'&flutter_shell=1#shell_app_logo=old',
),
);
expect(uri.host, 'line-two.example');
expect(uri.path, '/login');
expect(uri.queryParameters['shell_app_name'], 'Old');
expect(uri.queryParameters['flutter_shell'], '1');
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
isTrue);
});
test('does not rewrite H5 main-frame URLs for line switching', () {
final uri = Uri.parse(
AppConfig.canonicalizeMainFrameUrl(
'https://h5-test.imharry.work/login?from=runtime'
'&shell_app_name=Old#shell_app_logo=old',
),
);
expect(uri.scheme, 'https');
expect(uri.host, 'h5-test.imharry.work');
expect(uri.path, '/login');
expect(uri.queryParameters['from'], 'runtime');
expect(uri.queryParameters['shell_app_name'], 'Old');
expect(Uri.splitQueryString(uri.fragment).containsKey('shell_app_logo'),
isTrue);
expect(AppConfig.shouldRewriteMainFrameUrl(uri.toString()), isFalse);
}); });
} }