From a1e7b3e52ca41178f2dcc50a9dc93f18fd78428d Mon Sep 17 00:00:00 2001 From: Booker Date: Fri, 29 May 2026 17:56:55 +0700 Subject: [PATCH] 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. --- README.md | 18 +- .../io/openim/flutter/openim/MainActivity.kt | 34 + ios/Runner/AppDelegate.swift | 29 + lib/config/app_config.dart | 206 +++--- lib/main.dart | 599 ++++++++++++++++-- openim_common/lib/openim_common.dart | 2 - openim_common/lib/src/config.dart | 9 - openim_common/pubspec.yaml | 7 - pubspec.lock | 119 +++- pubspec.yaml | 3 +- test/widget_test.dart | 143 ++--- 11 files changed, 881 insertions(+), 288 deletions(-) delete mode 100644 openim_common/lib/openim_common.dart delete mode 100644 openim_common/lib/src/config.dart delete mode 100644 openim_common/pubspec.yaml diff --git a/README.md b/README.md index 2c21616..87736d7 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,15 @@ # im_webview_app -Flutter WebView 套壳 App,默认加载: - -```text -https://h5-test.imharry.work/ -``` +Flutter WebView 套壳 App,启动后会请求 `/client_config/query` 获取 H5 线路配置,并加载第一条可用线路。 ## H5 线路切换 -线路切换在 Flutter 套壳层完成,H5 页面不需要承载线路切换逻辑。每条线路对应一个独立 WebView,切换时只切换当前显示的 WebView,不会改写 H5 页面运行中的请求地址。 - -默认线路配置在: - -```text -openim_common/lib/src/config.dart -``` - -也可以在打包时覆盖: +线路切换在 Flutter 套壳层完成。远程配置不可用时,会先使用启动兜底线路;也可以在打包时覆盖启动线路或配置接口地址: ```bash 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/ ``` ## 本地打包 diff --git a/android/app/src/main/kotlin/io/openim/flutter/openim/MainActivity.kt b/android/app/src/main/kotlin/io/openim/flutter/openim/MainActivity.kt index 99dbc09..953fc6c 100644 --- a/android/app/src/main/kotlin/io/openim/flutter/openim/MainActivity.kt +++ b/android/app/src/main/kotlin/io/openim/flutter/openim/MainActivity.kt @@ -16,6 +16,9 @@ import android.util.Base64 import android.util.Log import android.view.View import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebStorage +import android.webkit.WebView import android.widget.FrameLayout import android.widget.ImageView import androidx.core.content.FileProvider @@ -31,6 +34,7 @@ class MainActivity : FlutterActivity() { private val CHANNEL = "io.openim.flutter.openim/apk_info" 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 H5_CACHE_CHANNEL = "io.openim.flutter.im_webview_app/h5_cache" private val TAG = "MainActivity" private val MAX_BRANDING_ICON_SIZE = 192 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?) { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..4335179 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,8 @@ import Flutter import UIKit +import WebKit + +private let h5CacheChannelName = "io.openim.flutter.im_webview_app/h5_cache" @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -12,5 +15,31 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { 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) + } } } diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index f53a421..30a1c3f 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -1,8 +1,4 @@ -import 'package:openim_common/openim_common.dart' as openim_common; - -enum Environment { - production, -} +import 'dart:math'; class H5Line { const H5Line({ @@ -14,49 +10,29 @@ class H5Line { final String 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 { - static const Environment currentEnvironment = Environment.production; static const String appName = '本地打包'; 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 = String.fromEnvironment('H5_LINE_URLS'); - static const Set legacyWebHosts = { - 'h5-test.imharry.work', - }; - - static final Map> _environmentHosts = { - Environment.production: [ - canonicalWebHost, - ], - }; - - static List get environmentHosts { - final generatedHosts = _normalizedUniqueUrls( - openim_common.Config.environmentHosts, - ); - if (generatedHosts.isNotEmpty) { - return generatedHosts; - } - - return _normalizedUniqueUrls(_environmentHosts[currentEnvironment] ?? []); - } + static const String _dartDefinedClientConfigQueryUrl = + String.fromEnvironment('CLIENT_CONFIG_QUERY_URL'); + // Bootstrap only: used before /client_config/query returns dynamic H5 domains. + static const String _bootstrapH5LineUrl = String.fromEnvironment( + 'BOOTSTRAP_H5_LINE_URL', + defaultValue: 'https://h5-test.imharry.work/'); + static final Random _wildcardRandom = Random.secure(); static List get h5Lines { final configuredUrls = _dartDefinedH5LineUrls.trim().isEmpty - ? environmentHosts + ? _normalizedUniqueUrls(const [_bootstrapH5LineUrl]) : _normalizedUniqueUrls(_dartDefinedH5LineUrls.split(',')); final urls = configuredUrls.isEmpty - ? _normalizedUniqueUrls(const [canonicalWebHost]) + ? _normalizedUniqueUrls(const [_bootstrapH5LineUrl]) : configuredUrls; return [ @@ -65,22 +41,73 @@ class AppConfig { ]; } - static String homeUrl({int lineIndex = 0, String? appName, String? appLogo}) { + static Map get clientConfigQueryPayload => const { + 'device': clientConfigDevice, + 'serialNo': clientConfigSerialNo, + }; + + static List get clientConfigQueryUris { + final dartDefinedUrl = _dartDefinedClientConfigQueryUrl.trim(); + if (dartDefinedUrl.isNotEmpty) { + return [Uri.parse(dartDefinedUrl)]; + } + final lines = h5Lines; - final safeIndex = _safeLineIndex(lineIndex, lines.length); - return lines[safeIndex].url; + if (lines.isEmpty) { + 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) { - return _removeShellParams(Uri.parse(url)).toString(); + static List h5LinesFromResourceUrl( + 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) { - return false; - } + static List splitResourceUrlLines(String resourceUrl) { + if (resourceUrl.trim().isEmpty) { + return const []; + } - static String canonicalizeMainFrameUrl(String url) { - return url; + final lines = []; + final seen = {}; + 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) { @@ -96,46 +123,6 @@ class AppConfig { 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.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) { final normalized = path.trim(); if (normalized.isEmpty) { @@ -151,29 +138,6 @@ class AppConfig { 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 _normalizedUniqueUrls(Iterable values) { final urls = []; final seen = {}; @@ -212,4 +176,28 @@ class AppConfig { final path = uri.path.isEmpty ? '/' : uri.path; 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.generate( + 16, + (_) => chars.codeUnitAt(_wildcardRandom.nextInt(chars.length)), + ), + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 1a567aa..2088d05 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.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'); const _androidFilePickerChannel = MethodChannel('io.openim.flutter.openim/file_picker'); +const _h5CacheChannel = + MethodChannel('io.openim.flutter.im_webview_app/h5_cache'); const _keyboardAnimationDuration = Duration(milliseconds: 250); +const _initialH5CacheClearedKey = 'initial_h5_cache_cleared_v1'; +const _lineProbeTimeout = Duration(seconds: 5); Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -67,6 +73,12 @@ class ShellBranding { static String _trim(String? value) => value?.trim() ?? ''; } +enum _LineAvailability { + checking, + available, + unavailable, +} + class ImWebViewApp extends StatelessWidget { const ImWebViewApp({ super.key, @@ -100,15 +112,19 @@ class H5ShellPage extends StatefulWidget { } class _H5ShellPageState extends State { - late final List _h5Lines; - late final List<_H5LineWebViewSlot> _lineSlots; + late List _h5Lines; + late List<_H5LineWebViewSlot> _lineSlots; bool _shellBrandingLoaded = false; + bool _isPreparingInitialLine = true; + bool _isAutoSwitchingLine = false; int _currentLineIndex = 0; double _lastKeyboardInset = 0; bool _lastKeyboardVisible = false; int _keyboardSyncToken = 0; + int _lineProbeGeneration = 0; late ShellBranding _shellBranding; + Future? _cacheClearTask; H5Line get _currentLine => _h5Lines[_currentLineIndex]; _H5LineWebViewSlot get _currentSlot => _lineSlots[_currentLineIndex]; @@ -118,14 +134,18 @@ class _H5ShellPageState extends State { super.initState(); _h5Lines = AppConfig.h5Lines; _shellBranding = widget.initialShellBranding; - _lineSlots = [ - for (var index = 0; index < _h5Lines.length; index += 1) + _lineSlots = _createLineSlots(_h5Lines); + unawaited(_initializeLines()); + } + + List<_H5LineWebViewSlot> _createLineSlots(List lines) { + return [ + for (var index = 0; index < lines.length; index += 1) _H5LineWebViewSlot( - line: _h5Lines[index], + line: lines[index], controller: _buildController(index), ), ]; - unawaited(_ensureLineLoaded(_currentLineIndex)); } WebViewController _buildController(int lineIndex) { @@ -181,13 +201,7 @@ class _H5ShellPageState extends State { }, onWebResourceError: (error) { if (error.isForMainFrame ?? true) { - final slot = _lineSlots[lineIndex]; - slot.shellCoverFallbackTimer?.cancel(); - slot.loadError = error.description; - slot.showShellCover = false; - if (mounted && lineIndex == _currentLineIndex) { - setState(() {}); - } + _handleMainFrameLoadError(lineIndex, error.description); } }, onNavigationRequest: _handleNavigationRequest, @@ -238,6 +252,413 @@ class _H5ShellPageState extends State { super.dispose(); } + Future _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 _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 _clearAllH5Caches() { + final runningTask = _cacheClearTask; + if (runningTask != null) { + return runningTask; + } + + final task = _clearAllH5CachesInternal(); + _cacheClearTask = task; + return task.whenComplete(() { + if (identical(_cacheClearTask, task)) { + _cacheClearTask = null; + } + }); + } + + Future _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 _clearNativeH5WebsiteData() async { + try { + await _h5CacheChannel.invokeMethod('clearAllWebsiteData'); + } catch (_) { + // Older native shells may not expose the cache channel yet. + } + } + + Future _clearCookiesSafely() async { + try { + await WebViewCookieManager().clearCookies(); + } catch (_) { + // Cookie clearing is best-effort across platform WebView implementations. + } + } + + Future _clearControllerStorage(WebViewController controller) async { + try { + await controller.clearCache(); + } catch (_) {} + + try { + await controller.clearLocalStorage(); + } catch (_) {} + } + + Future _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> _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> _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 _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 _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 _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 _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 _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().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 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 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 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 left, List 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 _runJavaScriptSafely(int lineIndex, String source) async { try { await _lineSlots[lineIndex].controller.runJavaScript(source); @@ -252,6 +673,7 @@ class _H5ShellPageState extends State { await _installRouteObserver(lineIndex); await _syncKeyboardState(lineIndex); final slot = _lineSlots[lineIndex]; + slot.availability = _LineAvailability.available; slot.progress = 100; if (mounted && lineIndex == _currentLineIndex && @@ -548,6 +970,64 @@ class _H5ShellPageState extends State { ); } + 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 _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 _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) { if (url == null || lineIndex < 0 || lineIndex >= _lineSlots.length) { return; @@ -560,9 +1040,12 @@ class _H5ShellPageState extends State { } } - Future _ensureLineLoaded(int lineIndex) async { + Future _loadLineHomeRequest( + int lineIndex, { + bool force = false, + }) async { final slot = _lineSlots[lineIndex]; - if (slot.hasLoadedInitialRequest) { + if (slot.hasLoadedInitialRequest && !force) { return; } @@ -596,19 +1079,23 @@ class _H5ShellPageState extends State { if (slot.hasLoadedInitialRequest) { await slot.controller.reload(); } else { - await _ensureLineLoaded(_currentLineIndex); + await _loadLineHomeRequest(_currentLineIndex); } } - Future _loadLine(int index) async { + Future _loadLine(int index, {bool forceReload = false}) async { final safeIndex = index < 0 || index >= _h5Lines.length ? 0 : index; + if (_lineSlots[safeIndex].availability == _LineAvailability.unavailable) { + return; + } + if (mounted) { setState(() { _currentLineIndex = safeIndex; }); } - await _ensureLineLoaded(safeIndex); + await _loadLineHomeRequest(safeIndex, force: forceReload); await _syncKeyboardState(safeIndex); } @@ -634,7 +1121,13 @@ class _H5ShellPageState extends State { ); } - void _showLineSwitcher() { + Future _showLineSwitcher() async { + await _clearAllH5Caches(); + await _refreshAllLineAvailability(); + if (!mounted) { + return; + } + showModalBottomSheet( context: context, useSafeArea: true, @@ -646,12 +1139,14 @@ class _H5ShellPageState extends State { builder: (sheetContext) { return _LineSwitcherSheet( lines: _h5Lines, + slots: _lineSlots, currentIndex: _currentLineIndex, onSelect: (index) { - Navigator.of(sheetContext).pop(); - if (index != _currentLineIndex) { - unawaited(_loadLine(index)); + if (_lineSlots[index].availability != _LineAvailability.available) { + return; } + Navigator.of(sheetContext).pop(); + unawaited(_loadLine(index, forceReload: true)); }, ); }, @@ -695,12 +1190,14 @@ class _H5ShellPageState extends State { final topInset = MediaQuery.paddingOf(context).top; final bottomInset = MediaQuery.viewInsetsOf(context).bottom; _scheduleKeyboardStateSync(bottomInset); - final showLineSwitch = !currentSlot.showShellCover && + final showLineSwitch = !_isPreparingInitialLine && + !currentSlot.showShellCover && currentSlot.loadError == null && currentSlot.isLoginPage && bottomInset == 0; final shouldPaintShellFallback = - currentSlot.isAwaitingFirstScreen && currentSlot.loadError == null; + (_isPreparingInitialLine || currentSlot.isAwaitingFirstScreen) && + currentSlot.loadError == null; return PopScope( canPop: false, @@ -760,7 +1257,7 @@ class _H5ShellPageState extends State { bottom: MediaQuery.paddingOf(context).bottom + 14, child: _LineSwitchBar( currentLine: _currentLine, - onSwitch: _showLineSwitcher, + onSwitch: () => unawaited(_showLineSwitcher()), ), ), ], @@ -806,6 +1303,7 @@ class _H5LineWebViewSlot { int progress = 0; String? loadError; + _LineAvailability availability = _LineAvailability.checking; bool showShellCover = true; bool hasPresentedFirstScreen = false; bool hasLoadedInitialRequest = false; @@ -960,11 +1458,13 @@ class _LineSwitchBar extends StatelessWidget { class _LineSwitcherSheet extends StatelessWidget { const _LineSwitcherSheet({ required this.lines, + required this.slots, required this.currentIndex, required this.onSelect, }); final List lines; + final List<_H5LineWebViewSlot> slots; final int currentIndex; final ValueChanged onSelect; @@ -998,10 +1498,12 @@ class _LineSwitcherSheet extends StatelessWidget { separatorBuilder: (context, index) => const SizedBox(height: 10), itemBuilder: (context, index) { final line = lines[index]; + final availability = slots[index].availability; final selected = index == currentIndex; return _LineOptionTile( line: line, + availability: availability, selected: selected, onTap: () => onSelect(index), ); @@ -1017,29 +1519,43 @@ class _LineSwitcherSheet extends StatelessWidget { class _LineOptionTile extends StatelessWidget { const _LineOptionTile({ required this.line, + required this.availability, required this.selected, required this.onTap, }); final H5Line line; + final _LineAvailability availability; final bool selected; final VoidCallback onTap; @override 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( - color: selected ? const Color(0xFFEFF7FF) : Colors.white, + color: selected && enabled + ? const Color(0xFFEFF7FF) + : enabled + ? Colors.white + : const Color(0xFFF4F6F9), borderRadius: BorderRadius.circular(8), child: InkWell( - onTap: onTap, + onTap: enabled ? onTap : null, borderRadius: BorderRadius.circular(8), child: Container( - constraints: const BoxConstraints(minHeight: 64), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: const BoxConstraints(minHeight: 58), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all( - color: selected ? _shellAccent : const Color(0xFFE1EAF5), + color: borderColor, ), ), child: Row( @@ -1048,7 +1564,7 @@ class _LineOptionTile extends StatelessWidget { selected ? Icons.radio_button_checked_rounded : Icons.radio_button_unchecked_rounded, - color: selected ? _shellAccent : _shellSubText, + color: selected && enabled ? _shellAccent : _shellSubText, size: 22, ), const SizedBox(width: 12), @@ -1061,34 +1577,21 @@ class _LineOptionTile extends StatelessWidget { line.label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xFF17233D), + style: TextStyle( + color: foregroundColor, fontSize: 16, height: 1.2, fontWeight: FontWeight.w700, 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 Icon( + Icon( Icons.chevron_right_rounded, - color: _shellSubText, + color: enabled ? _shellSubText : const Color(0xFFC1CBD8), size: 22, ), ], diff --git a/openim_common/lib/openim_common.dart b/openim_common/lib/openim_common.dart deleted file mode 100644 index b6b61a0..0000000 --- a/openim_common/lib/openim_common.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'src/config.dart'; - diff --git a/openim_common/lib/src/config.dart b/openim_common/lib/src/config.dart deleted file mode 100644 index 47a7206..0000000 --- a/openim_common/lib/src/config.dart +++ /dev/null @@ -1,9 +0,0 @@ -class Config { - static const List defaultEnvironmentHosts = [ - 'h5-test.imharry.work', - ]; - - static List get environmentHosts { - return defaultEnvironmentHosts; - } -} diff --git a/openim_common/pubspec.yaml b/openim_common/pubspec.yaml deleted file mode 100644 index 7ff1d22..0000000 --- a/openim_common/pubspec.yaml +++ /dev/null @@ -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" - diff --git a/pubspec.lock b/pubspec.lock index 8209563..aaf6250 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: flutter @@ -128,13 +144,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" - openim_common: - dependency: "direct main" - description: - path: openim_common - relative: true - source: path - version: "0.0.0" path: dependency: transitive description: @@ -143,6 +152,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -191,6 +224,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -199,6 +240,62 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter @@ -372,6 +469,14 @@ packages: url: "https://pub.dev" source: hosted 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: dart: ">=3.10.0 <4.0.0" flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index c012f79..90411ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,12 +32,11 @@ dependencies: sdk: flutter permission_handler: ^12.0.1 + shared_preferences: ^2.5.5 url_launcher: ^6.3.2 webview_flutter: ^4.13.1 webview_flutter_android: ^4.10.5 webview_flutter_wkwebview: ^3.23.1 - openim_common: - path: openim_common dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 0914530..eda1033 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -11,108 +11,71 @@ void main() { ); }); - test('loads the configured H5 root URL without shell URL params', () { - 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', () { + test('exposes bootstrap H5 URL as Flutter shell lines', () { final lines = AppConfig.h5Lines; expect(lines, isNotEmpty); expect(lines.first.label, '线路1'); - expect(lines.first.url, AppConfig.homeUrl(lineIndex: 0)); - expect(Uri.parse(lines.first.url).host, 'h5-test.imharry.work'); + expect(Uri.parse(lines.first.url).scheme, 'https'); + expect(Uri.parse(lines.first.url).path, '/'); }); - test('falls back to the first H5 line for invalid line indexes', () { - expect(AppConfig.homeUrl(lineIndex: -1), AppConfig.h5Lines.first.url); - expect(AppConfig.homeUrl(lineIndex: 99), AppConfig.h5Lines.first.url); + test('uses fixed client config query parameters', () { + expect(AppConfig.clientConfigQueryPayload, { + 'device': 'h5', + 'serialNo': 'h5-domain', + }); + expect( + AppConfig.clientConfigQueryUris.first.path, + '/client_config/query', + ); }); - test('matches runtime URLs back to their Flutter shell line', () { - final lineUrl = AppConfig.h5Lines.first.url; + test('splits client config resourceUrl into indexed H5 lines', () { + 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', () { + expect(AppConfig.isLoginPageUrl('https://line-one.example/login'), isTrue); expect( - AppConfig.isLoginPageUrl('https://h5-test.imharry.work/login'), isTrue); - expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/app/login'), - isTrue); - expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/#/login'), - isTrue); - expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/'), isFalse); - expect(AppConfig.isLoginPageUrl('https://h5-test.imharry.work/contact'), - 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); + AppConfig.isLoginPageUrl('https://line-one.example/app/login'), isTrue); + expect( + AppConfig.isLoginPageUrl('https://line-one.example/#/login'), isTrue); + expect(AppConfig.isLoginPageUrl('https://line-one.example/'), isFalse); + expect( + AppConfig.isLoginPageUrl('https://line-one.example/contact'), isFalse); + expect( + AppConfig.isLoginPageUrl('https://line-one.example/getCode'), isFalse); }); }