import 'dart:async'; import 'package:dsbridge_flutter/dsbridge_flutter.dart'; import 'package:flutter/foundation.dart' show TargetPlatform, debugPrint, defaultTargetPlatform, kIsWeb, kReleaseMode; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; import '../../../common/util/app_link_util.dart'; import '../../auth/application/auth_controller.dart'; import '../../auth/application/login_launcher.dart'; import '../../auth/data/auth_models.dart'; import '../../mall/application/mall_tab_config.dart'; import '../../mall/data/mall_auth_api.dart'; import '../application/mall_bridge_state.dart'; import '../application/mall_web_host_controller.dart'; import '../application/web_back_navigation.dart'; import '../application/web_navigation_policy.dart'; import '../application/web_reload_providers.dart'; import 'js_api.dart'; /// Process-lifetime resident mall WebView. The controller, bridge contract and /// first-load ordering intentionally match the Purse implementation. class MallWebHost extends ConsumerStatefulWidget { const MallWebHost({ super.key, required this.url, required this.visible, required this.onRequestExit, }); final String url; final bool visible; final VoidCallback onRequestExit; @override ConsumerState createState() => _MallWebHostState(); } class _MallWebHostState extends ConsumerState with WidgetsBindingObserver { static const _defaultQiyuStatusBarColor = Color(0xFF3B82F6); static const _mallSystemUiOverlayStyle = SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, statusBarBrightness: Brightness.light, ); static const _webFilePickerChannel = MethodChannel( 'com.jinzhi.butler/web_file_picker', ); late final DWebViewController _controller; String? _loadedUrl; String? _errorText; int _progress = 0; bool _hasFinishedFirstPage = false; bool _hasDisplayedPage = false; bool _isHandlingSystemBack = false; bool _isHandlingLoginRequest = false; bool _isShowingQiyuPage = false; bool _hasEmbeddedQiyuFrame = false; Color _qiyuStatusBarColor = _defaultQiyuStatusBarColor; String? _qiyuUrlLoadedWithMallReferer; final _pendingH5Events = []; Future? _androidWebViewConfiguration; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); ref.listenManual>(webReloadPProvider, (_, next) { next.whenData(_callH5Handler); }); ref.listenManual>(authProvider, (previous, next) { final previousAuth = previous?.valueOrNull; final nextAuth = next.valueOrNull; final wasLogged = previousAuth is LoggedInAuthState; final logged = nextAuth is LoggedInAuthState; final tokenChanged = wasLogged && logged && previousAuth.token != nextAuth.token; if (wasLogged == logged && !tokenChanged) return; if (logged) { unawaited(_notifyH5MallEnteredForCurrentUrl()); } else { unawaited(_notifyH5LoggedOut()); } }); _controller = _createController(); MallBridgeController.instance.attach( activateTab: _switchTab, goBack: _goBack, ); MallWebHostController.instance.attachCacheClearer(_clearWebViewCache); MallWebHostController.instance.attachSystemBackHandler(_handleSystemBack); _configurePlatformWebView(); unawaited(_load(widget.url)); if (widget.visible) unawaited(_notifyH5MallEnteredForCurrentUrl()); } DWebViewController _createController() { late final DWebViewController controller; controller = DWebViewController() // ignore: discarded_futures ..setBackgroundColor(Colors.transparent) // ignore: discarded_futures ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptObject( JsApi( onClose: widget.onRequestExit, onLoginRequested: () => unawaited(_requestMallLogin()), onMallStateChanged: MallBridgeController.instance.update, ), ) // Temporary compatibility with the pre-DSBridge Jinzhi H5 contract. // ignore: discarded_futures ..addJavaScriptChannel( 'JinzhiBridge', onMessageReceived: (message) { if (message.message == 'close') widget.onRequestExit(); }, ) // ignore: discarded_futures ..setNavigationDelegate( NavigationDelegate( onNavigationRequest: _handleNavigationRequest, onProgress: (value) { if (mounted) setState(() => _progress = value); }, onPageStarted: (url) { _updateQiyuPageLayout(url); if (!_hasFinishedFirstPage && mounted) { setState(() { _errorText = null; _progress = 0; }); } }, onUrlChange: (change) => _updateQiyuPageLayout(change.url), onPageFinished: (url) { _hasFinishedFirstPage = true; _hasDisplayedPage = true; _updateQiyuPageLayout(url); if (_isShowingQiyuPage) { _applySystemUiOverlayStyle(); } unawaited(_refreshEmbeddedQiyuPresence()); _flushPendingH5Events(); if (mounted) setState(() => _progress = 100); }, onWebResourceError: (error) { if (error.isForMainFrame != true || _hasFinishedFirstPage) return; if (mounted) { setState(() { _errorText = error.description; _progress = 100; }); } }, ), ); return controller; } @override void dispose() { WidgetsBinding.instance.removeObserver(this); MallWebHostController.instance.setShowingQiyuPage(false); SystemChrome.setSystemUIOverlayStyle(_mallSystemUiOverlayStyle); MallWebHostController.instance.detachCacheClearer(); MallWebHostController.instance.detachSystemBackHandler(); MallBridgeController.instance.detach(); super.dispose(); } @override void didUpdateWidget(covariant MallWebHost oldWidget) { super.didUpdateWidget(oldWidget); if (MallWebHostController.cacheKeyFor(oldWidget.url) != MallWebHostController.cacheKeyFor(widget.url)) { unawaited(_load(widget.url)); } if (!oldWidget.visible && widget.visible) { unawaited(_notifyH5MallEnteredForCurrentUrl()); } else if (oldWidget.url != widget.url && widget.visible) { unawaited(_notifyH5MallEnteredForCurrentUrl()); } } Future _switchTab(MallBridgeTab tab, bool reselect) async => _controller.callHandler('mall.switchTab', args: [tab.name, reselect]); Future _goBack() async => _controller.callHandler('mall.back'); Future _handleNavigationRequest( NavigationRequest request, ) async { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android && isTrustedQiyuUrl(request.url)) { return _openQiyuWithMallReferer(request.url); } if (!shouldOpenWebUrlExternally(request.url)) { return NavigationDecision.navigate; } final target = Uri.tryParse(request.url); if (target == null) return NavigationDecision.prevent; final targetApp = AppLinkUtil.targetAppFromUrl(request.url); final isHttpUrl = target.scheme == 'http' || target.scheme == 'https'; final launched = isHttpUrl && targetApp.isNotEmpty ? await AppLinkUtil.openExternal(target, packageName: targetApp) : await launchUrl( sanitizeSystemCommunicationUri(target), mode: LaunchMode.externalApplication, ); if (launched && mounted) widget.onRequestExit(); return NavigationDecision.prevent; } Future _handleSystemBack() async { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android || !_hasFinishedFirstPage || _errorText != null || _isHandlingSystemBack) { return; } if (MallBridgeController.instance.state.value.canGoBack) { await _goBack(); return; } _isHandlingSystemBack = true; try { await tryGoBackInWebHistory( canGoBack: _controller.canGoBack, goBack: _controller.goBack, ); } finally { _isHandlingSystemBack = false; } } Future _openQiyuWithMallReferer(String rawUrl) async { final mallUri = Uri.tryParse(_loadedUrl ?? ''); final qiyuUri = Uri.tryParse(rawUrl); if (!isQiyuClientEntryUrl(rawUrl) || _qiyuUrlLoadedWithMallReferer == rawUrl || mallUri == null || qiyuUri == null) { return NavigationDecision.navigate; } _qiyuUrlLoadedWithMallReferer = rawUrl; try { await _controller.loadRequest( qiyuUri, headers: {'Referer': '${mallUri.origin}/'}, ); return NavigationDecision.prevent; } catch (error) { _qiyuUrlLoadedWithMallReferer = null; debugPrint('[Mall] Failed to reload Qiyu with mall referrer: $error'); return NavigationDecision.navigate; } } Future _load(String raw) async { final uri = Uri.tryParse(raw); if (!_isAllowedMallUri(uri) || _loadedUrl == raw) return; _loadedUrl = raw; _qiyuUrlLoadedWithMallReferer = null; _hasFinishedFirstPage = false; _errorText = null; _progress = 0; _isShowingQiyuPage = false; _hasEmbeddedQiyuFrame = false; MallWebHostController.instance.setShowingQiyuPage(false); MallWebHostController.instance.setQiyuKeyboardActive(false); _resetQiyuStatusBarColor(); _applySystemUiOverlayStyle(); await _configureAndroidWebViewBeforeFirstLoad(); if (_loadedUrl != raw) return; await _controller.loadRequest( uri!, headers: const {'Cache-Control': 'no-cache'}, ); } bool _isAllowedMallUri(Uri? uri) { if (uri == null || uri.host.isEmpty) return false; if (uri.scheme == 'https') return true; return !kReleaseMode && uri.scheme == 'http' && const {'localhost', '127.0.0.1', '::1'}.contains(uri.host); } Future _clearWebViewCache() => _controller.clearCache(); void _configurePlatformWebView() { final platform = _controller.platform; if (platform is WebKitWebViewController) { unawaited(platform.setAllowsBackForwardNavigationGestures(true)); } else if (platform is AndroidWebViewController) { _androidWebViewConfiguration ??= _configureAndroidWebView(platform); } } Future _configureAndroidWebViewBeforeFirstLoad() { final platform = _controller.platform; if (platform is! AndroidWebViewController) return Future.value(); return _androidWebViewConfiguration ??= _configureAndroidWebView(platform); } Future _configureAndroidWebView( AndroidWebViewController controller, ) async { await controller.setOnPlatformPermissionRequest( _onAndroidWebPermissionRequest, ); await controller.setOnShowFileSelector(_selectWebViewFiles); try { await AndroidWebViewCookieManager( const PlatformWebViewCookieManagerCreationParams(), ).setAcceptThirdPartyCookies(controller, true); } catch (_) {} try { final userAgent = await controller.getUserAgent(); if (userAgent == null) return; final browserUserAgent = userAgent .replaceAll(RegExp(r';\s*wv'), '') .replaceAll(RegExp(r'\bVersion/4\.0\s*'), '') .trim(); if (browserUserAgent != userAgent) { await controller.setUserAgent(browserUserAgent); } } catch (_) {} } void _onAndroidWebPermissionRequest( PlatformWebViewPermissionRequest request, ) { unawaited(_grantAndroidWebPermissions(request)); } Future _grantAndroidWebPermissions( PlatformWebViewPermissionRequest request, ) async { if (!await _canGrantWebMediaPermission(request.types)) { await request.deny(); return; } final permissions = [ if (request.types.contains(WebViewPermissionResourceType.camera)) Permission.camera, if (request.types.contains(WebViewPermissionResourceType.microphone)) Permission.microphone, ]; if (permissions.isEmpty) { await request.deny(); return; } final statuses = await permissions.request(); if (statuses.values.every((status) => status.isGranted)) { await request.grant(); } else { await request.deny(); } } Future _canGrantWebMediaPermission( Set types, ) async { switch (await _detectWebMediaCaptureContext()) { case _WebMediaCaptureContext.qiyu: return true; case _WebMediaCaptureContext.faceVerification: return types.contains(WebViewPermissionResourceType.camera) && !types.contains(WebViewPermissionResourceType.microphone); case _WebMediaCaptureContext.none: return false; } } Future _canGrantQiyuWebMediaPermission() async => await _detectWebMediaCaptureContext() == _WebMediaCaptureContext.qiyu; Future<_WebMediaCaptureContext> _detectWebMediaCaptureContext() async { if (_isShowingQiyuPage) return _WebMediaCaptureContext.qiyu; const script = ''' (() => { const isQiyu = Array.from(document.querySelectorAll('iframe[src]')).some((frame) => { try { const host = new URL(frame.src, document.baseURI).hostname.toLowerCase(); return host === 'qiyukf.com' || host.endsWith('.qiyukf.com') || host === 'qiyukf.cn' || host.endsWith('.qiyukf.cn'); } catch (_) { return false; } }); if (isQiyu) return 'qiyu'; const isFaceVerification = Array.from(document.scripts).some((script) => { try { const url = new URL(script.src || '', document.baseURI); return url.hostname === 'g.alicdn.com' && url.pathname.includes('/captcha-frontend/FeiLin/'); } catch (_) { return false; } }); return isFaceVerification ? 'face_verification' : 'none'; })(); '''; try { final result = await _controller.runJavaScriptReturningResult(script); return switch (result.toString().replaceAll('"', '')) { 'qiyu' => _WebMediaCaptureContext.qiyu, 'face_verification' => _WebMediaCaptureContext.faceVerification, _ => _WebMediaCaptureContext.none, }; } catch (_) { return _WebMediaCaptureContext.none; } } Future> _selectWebViewFiles(FileSelectorParams params) async { final isQiyuMediaUpload = _isImageOrVideoUpload(params.acceptTypes) && await _canGrantQiyuWebMediaPermission(); final captureEnabled = isQiyuMediaUpload ? await _requestQiyuUploadPermissions(params.acceptTypes) : params.isCaptureEnabled; try { return (await _webFilePickerChannel .invokeListMethod('pickFiles', { 'acceptTypes': params.acceptTypes, 'allowMultiple': params.mode == FileSelectorMode.openMultiple, 'captureEnabled': captureEnabled, 'isQiyuSession': isQiyuMediaUpload, })) ?? const []; } on PlatformException { return const []; } } bool _isImageOrVideoUpload(List acceptTypes) => acceptTypes.isNotEmpty && acceptTypes.every( (type) => type.startsWith('image/') || type.startsWith('video/'), ); Future _requestQiyuUploadPermissions(List acceptTypes) async { final uploadsVideo = acceptTypes.any((type) => type.startsWith('video/')); final statuses = await [ Permission.camera, if (uploadsVideo) Permission.microphone, ].request(); return statuses.values.every((status) => status.isGranted); } SystemUiOverlayStyle get _qiyuSystemUiOverlayStyle => SystemUiOverlayStyle( statusBarColor: _qiyuStatusBarColor, statusBarIconBrightness: Brightness.light, statusBarBrightness: Brightness.dark, ); void _applySystemUiOverlayStyle() { SystemChrome.setSystemUIOverlayStyle( _isShowingQiyuPage ? _qiyuSystemUiOverlayStyle : _mallSystemUiOverlayStyle, ); } void _resetQiyuStatusBarColor() { _qiyuStatusBarColor = _defaultQiyuStatusBarColor; } void _setQiyuPageLayout(bool isQiyuPage) { if (_isShowingQiyuPage == isQiyuPage) return; _isShowingQiyuPage = isQiyuPage; MallWebHostController.instance.setShowingQiyuPage(isQiyuPage); if (!isQiyuPage) _resetQiyuStatusBarColor(); _applySystemUiOverlayStyle(); if (mounted) setState(() {}); _syncQiyuKeyboardAvoidance(); } void _updateQiyuPageLayout(String? rawUrl) => _setQiyuPageLayout(shouldApplyQiyuSafeArea(rawUrl ?? '')); @override void didChangeMetrics() { super.didChangeMetrics(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _syncQiyuKeyboardAvoidance(); }); } void _syncQiyuKeyboardAvoidance() { if (!mounted) return; final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; MallWebHostController.instance.setQiyuKeyboardActive( keyboardOpen && (_isShowingQiyuPage || _hasEmbeddedQiyuFrame), ); } Future _refreshEmbeddedQiyuPresence() async { const script = ''' (() => Array.from(document.querySelectorAll('iframe[src]')).some((frame) => { try { const host = new URL(frame.src, document.baseURI).hostname.toLowerCase(); return host === 'qiyukf.com' || host.endsWith('.qiyukf.com') || host === 'qiyukf.cn' || host.endsWith('.qiyukf.cn'); } catch (_) { return false; } }))(); '''; try { final result = await _controller.runJavaScriptReturningResult(script); if (!mounted) return; _hasEmbeddedQiyuFrame = result.toString().replaceAll('"', '') == 'true'; _syncQiyuKeyboardAvoidance(); } catch (_) {} } Future _retry() async { final url = _loadedUrl; if (url == null) return; _loadedUrl = null; await _load(url); } Future _requestMallLogin() async { if (_isLoggedIn) { await _notifyH5MallEnteredForCurrentUrl(); return; } if (_isHandlingLoginRequest || !mounted) return; _isHandlingLoginRequest = true; try { await LoginLauncher.start(context, ref); } finally { _isHandlingLoginRequest = false; } } bool get _isLoggedIn => ref.read(authProvider).valueOrNull is LoggedInAuthState; Future _notifyH5LoggedOut() async { _isHandlingLoginRequest = false; _callH5Handler(const JsEvent('logout')); try { await _controller.runJavaScript( 'window.__chartProLogout&&window.__chartProLogout()', ); } catch (_) {} } Future _notifyH5MallEnteredForCurrentUrl() async { final rawUrl = MallWebHostController.instance.state.value.url ?? _loadedUrl; final uri = rawUrl == null ? null : Uri.tryParse(rawUrl); if (!_isAllowedMallUri(uri)) return; final logged = _isLoggedIn; final mallAuth = logged ? await _issueMallAuthToken() : null; if (!mounted) return; final arguments = buildEnterWebMallBridgeArguments( currentUri: uri!, logged: logged, isDark: Theme.of(context).brightness == Brightness.dark, authToken: mallAuth?.authToken, scene: mallAuth?.scene, sourceApp: mallAuth?.sourceApp, attribution: ref.read(mallClickAttributionProvider), ); _callH5Handler(JsEvent('enterWebMall', [arguments])); } Future _issueMallAuthToken() async { try { return await ref.read(mallAuthApiProvider).issueAppMallAuthToken(); } catch (error) { debugPrint('[Mall] Failed to issue H5 auth token: $error'); return null; } } void _callH5Handler(JsEvent event) { if (!_hasFinishedFirstPage) { _pendingH5Events.add(event); return; } WidgetsBinding.instance.addPostFrameCallback((_) => _sendH5Handler(event)); } void _flushPendingH5Events() { if (_pendingH5Events.isEmpty) return; final events = List.of(_pendingH5Events); _pendingH5Events.clear(); for (final event in events) { _sendH5Handler(event); } } void _sendH5Handler(JsEvent event) { try { _controller.callHandler(event.method, args: event.args); } catch (error) { debugPrint('[Mall] H5 bridge event failed: ${event.method}; $error'); } } @override Widget build(BuildContext context) { // Qiyu already consumes CSS safe-area values. Adding Flutter's status-bar // inset here shifts its own blue navigation bar down a second time. const topInset = 0.0; final bottomInset = qiyuWebViewBottomInset( isShowingQiyuPage: _isShowingQiyuPage || _hasEmbeddedQiyuFrame, platform: defaultTargetPlatform, keyboardInset: MediaQuery.viewInsetsOf(context).bottom, isWeb: kIsWeb, ); final showLoading = shouldShowMallLoading( hasError: _errorText != null, hasDisplayedPage: _hasDisplayedPage, progress: _progress, ); final content = Stack( children: [ if (_errorText == null) Positioned( top: topInset, left: 0, right: 0, bottom: bottomInset, child: WebViewWidget(controller: _controller), ), if (showLoading) const MallLoadingIndicator(), if (_errorText != null) Center( child: TextButton( onPressed: () => unawaited(_retry()), child: const Text('商城加载失败,点击重试'), ), ), ], ); return _isShowingQiyuPage ? AnnotatedRegion( value: _qiyuSystemUiOverlayStyle, child: content, ) : content; } } enum _WebMediaCaptureContext { none, qiyu, faceVerification } /// Keep Qiyu's Android WebView above the IME. Its delayed focus-scroll must /// not stack with Android WebView's visual-viewport resize; regular mall pages /// and iOS retain their native keyboard behavior. double qiyuWebViewBottomInset({ required bool isShowingQiyuPage, required TargetPlatform platform, required double keyboardInset, required bool isWeb, }) { if (isWeb || !isShowingQiyuPage || platform != TargetPlatform.android || keyboardInset <= 0) { return 0; } return keyboardInset; } bool shouldShowMallLoading({ required bool hasError, required bool hasDisplayedPage, required int progress, }) => !hasError && !hasDisplayedPage && progress < 100; class MallLoadingIndicator extends StatelessWidget { const MallLoadingIndicator({super.key}); @override Widget build(BuildContext context) { final color = ShadTheme.of(context).colorScheme.primary; return Center( child: SizedBox( width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2, color: color), ), ); } }