From 7702dee27fd393cdd090cb792c41300d7b979d80 Mon Sep 17 00:00:00 2001 From: Booker Date: Tue, 26 May 2026 19:31:28 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20WebView=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=AD=98=E5=82=A8=E6=B8=85=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=9B=E4=BC=98=E5=8C=96=20URL=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=92=8C=20H5=20=E8=B7=AF=E7=94=B1=E5=88=B7=E6=96=B0=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E5=93=81=E7=89=8C=E4=BF=9D=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/openim/flutter/openim/MainActivity.kt | 47 ++++++++++- lib/config/app_config.dart | 42 +++++----- lib/main.dart | 81 ++++++++++++++++++- test/widget_test.dart | 19 +++++ 4 files changed, 165 insertions(+), 24 deletions(-) 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 7fc98c2..7ad389c 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 @@ -13,6 +13,7 @@ import android.util.Base64 import android.util.Log import android.webkit.ServiceWorkerController import android.webkit.WebSettings +import android.webkit.WebStorage import android.webkit.WebView import androidx.core.content.FileProvider import io.flutter.embedding.android.FlutterActivity @@ -30,10 +31,14 @@ class MainActivity : FlutterActivity() { private val WEBVIEW_CACHE_CHANNEL = "io.openim.flutter.im_webview_app/webview_cache" private val TAG = "MainActivity" private val MAX_BRANDING_ICON_SIZE = 192 - private val WEBVIEW_DATA_DIRECTORY_SUFFIX = "h5_shell_fresh_profile_v2" + private val WEBVIEW_DATA_DIRECTORY_SUFFIX = "h5_shell_fresh_profile_v3" + private val WEBVIEW_STORAGE_RESET_PREFS = "h5_shell_webview_storage" + private val WEBVIEW_STORAGE_RESET_KEY = "reset_version" + private val WEBVIEW_STORAGE_RESET_VERSION = 3 override fun onCreate(savedInstanceState: android.os.Bundle?) { configureWebViewDataDirectory() + clearWebViewPersistentStorageOnce() // 华为/荣耀/OPPO 等国产设备:在任意网络请求之前同步安装 Conscrypt,修复 SSL 握手失败(无 GMS 时系统 SSL 实现不完整) try { Security.insertProviderAt(Conscrypt.newProvider(), 1) @@ -150,6 +155,46 @@ class MainActivity : FlutterActivity() { } } + private fun clearWebViewPersistentStorageOnce() { + val prefs = getSharedPreferences(WEBVIEW_STORAGE_RESET_PREFS, MODE_PRIVATE) + if (prefs.getInt(WEBVIEW_STORAGE_RESET_KEY, 0) >= WEBVIEW_STORAGE_RESET_VERSION) { + return + } + + try { + WebStorage.getInstance().deleteAllData() + Log.d(TAG, "WebView persistent storage cleared") + } catch (e: Exception) { + Log.w(TAG, "WebView persistent storage clear failed: ${e.message}") + } + + clearKnownWebViewCacheDirectories() + prefs.edit().putInt(WEBVIEW_STORAGE_RESET_KEY, WEBVIEW_STORAGE_RESET_VERSION).apply() + } + + private fun clearKnownWebViewCacheDirectories() { + val cacheDirectoryNames = listOf( + "WebView", + "webview", + "org.chromium.android_webview", + "com.android.webview" + ) + + for (name in cacheDirectoryNames) { + val directory = File(cacheDir, name) + if (!directory.exists()) { + continue + } + try { + if (!directory.deleteRecursively()) { + Log.w(TAG, "WebView cache directory delete returned false: ${directory.absolutePath}") + } + } catch (e: Exception) { + Log.w(TAG, "WebView cache directory delete failed: ${directory.absolutePath}, ${e.message}") + } + } + } + private fun readLongArgument(value: Any?): Long? { return when (value) { is Int -> value.toLong() diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 4de5d4d..f9c9c1b 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -21,33 +21,14 @@ class AppConfig { final host = environmentHosts.isNotEmpty ? environmentHosts.first : 'h5-im.imharry.work'; - return _withShellBranding( + return withFreshShellParams( _normalizeHomeUrl(host), appName: appName, appLogo: appLogo, ); } - static String withFreshShellCacheBust(String url) { - final uri = Uri.parse(url); - final queryParameters = Map.from(uri.queryParameters) - ..['shell_cache_bust'] = DateTime.now().millisecondsSinceEpoch.toString(); - - return uri.replace(queryParameters: queryParameters).toString(); - } - - static String _normalizeHomeUrl(String host) { - final value = host.trim(); - final normalized = - value.startsWith('http://') || value.startsWith('https://') - ? value - : 'https://$value'; - final uri = Uri.parse(normalized); - final path = uri.path.isEmpty ? '/' : uri.path; - return uri.replace(path: path).toString(); - } - - static String _withShellBranding( + static String withFreshShellParams( String url, { String? appName, String? appLogo, @@ -77,4 +58,23 @@ class AppConfig { ) .toString(); } + + static String withFreshShellCacheBust(String url) { + final uri = Uri.parse(url); + final queryParameters = Map.from(uri.queryParameters) + ..['shell_cache_bust'] = DateTime.now().millisecondsSinceEpoch.toString(); + + return uri.replace(queryParameters: queryParameters).toString(); + } + + static String _normalizeHomeUrl(String host) { + final value = host.trim(); + final normalized = + value.startsWith('http://') || value.startsWith('https://') + ? value + : 'https://$value'; + final uri = Uri.parse(normalized); + final path = uri.path.isEmpty ? '/' : uri.path; + return uri.replace(path: path).toString(); + } } diff --git a/lib/main.dart b/lib/main.dart index 04597eb..8176390 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -105,6 +105,45 @@ const _purgeWebRuntimeCacheScript = r''' }); })(); '''; +const _inspectH5SnapshotScript = r''' +(() => { + const toAbsoluteUrl = (value) => { + try { + return new URL(value, window.location.href).href; + } catch (_) { + return value || ''; + } + }; + + const shrinkAssetUrl = (value) => { + const absolute = toAbsoluteUrl(value); + const match = absolute.match(/\/assets\/[^?#]+/); + return match ? match[0] : absolute; + }; + + const scripts = Array.from(document.scripts) + .map((script) => script.src) + .filter(Boolean) + .map(shrinkAssetUrl); + const links = Array.from(document.querySelectorAll('link[href]')) + .map((link) => link.href) + .filter(Boolean) + .map(shrinkAssetUrl); + const bodyText = (document.body?.innerText || '') + .replace(/\s+/g, ' ') + .slice(0, 300); + + return JSON.stringify({ + type: 'h5Snapshot', + href: window.location.href, + title: document.title, + scripts, + links, + bodyText, + userAgent: navigator.userAgent, + }); +})(); +'''; const _shellMediaPermissionBridgeScript = r''' (() => { if (window.__openimShellMediaBridgeInstalled) { @@ -395,7 +434,10 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { final platformController = controller.platform; if (platformController is AndroidWebViewController) { - AndroidWebViewController.enableDebugging(false); + assert(() { + AndroidWebViewController.enableDebugging(true); + return true; + }()); unawaited(platformController.setMediaPlaybackRequiresUserGesture(false)); unawaited(platformController.setGeolocationEnabled(true)); unawaited( @@ -432,6 +474,7 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { unawaited(openAppSettings()); } if (type == 'runtimeCachePurged' && payload != null) { + debugPrint('[H5Shell] runtime cache purged: ${jsonEncode(payload)}'); unawaited(_reloadAfterRuntimeCachePurge(payload['url'] as String?)); } if (type == 'requestMediaPermissions' && payload != null) { @@ -512,7 +555,11 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { } try { - return AppConfig.withFreshShellCacheBust(value); + return AppConfig.withFreshShellParams( + value, + appName: widget.shellBranding.appName, + appLogo: widget.shellBranding.appLogo, + ); } catch (_) { return _freshHomeUrl(); } @@ -527,6 +574,7 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { unawaited(_syncShellBranding()); unawaited(_injectShellMediaPermissionBridge()); + unawaited(_logLoadedH5Snapshot()); if (mounted) { setState(() { _progress = 100; @@ -635,6 +683,35 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { return _runJavaScriptSafely(_stopWebMediaScript); } + Future _logLoadedH5Snapshot() async { + try { + final result = await _controller.runJavaScriptReturningResult( + _inspectH5SnapshotScript, + ); + debugPrint( + '[H5Shell] loaded H5 snapshot: ${_decodeJavaScriptStringResult(result)}', + ); + } catch (error) { + debugPrint('[H5Shell] loaded H5 snapshot failed: $error'); + } + } + + String _decodeJavaScriptStringResult(Object? result) { + if (result == null) { + return ''; + } + if (result is String) { + try { + final decoded = jsonDecode(result); + if (decoded is String) { + return decoded; + } + } catch (_) {} + return result; + } + return result.toString(); + } + Future _loadHome() async { await _loadUrl(_freshHomeUrl()); } diff --git a/test/widget_test.dart b/test/widget_test.dart index f500283..de0dab6 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -28,4 +28,23 @@ void main() { expect(uri.toString(), isNot(contains('%25'))); expect(Uri.splitQueryString(uri.fragment)['shell_app_logo'], logo); }); + + test('keeps shell branding when refreshing an H5 route URL', () { + const logo = 'data:image/png;base64,routeLogo+/='; + + final uri = Uri.parse( + AppConfig.withFreshShellParams( + 'https://h5-im.imharry.work/login?from=runtime', + appName: 'Shell Route', + appLogo: logo, + ), + ); + + expect(uri.path, '/login'); + expect(uri.queryParameters['from'], 'runtime'); + expect(uri.queryParameters['flutter_shell'], '1'); + expect(uri.queryParameters['shell_app_name'], 'Shell Route'); + expect(uri.queryParameters['shell_cache_bust'], isNotEmpty); + expect(Uri.splitQueryString(uri.fragment)['shell_app_logo'], logo); + }); }