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 6fe1592..db65e1f 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 @@ -11,18 +11,22 @@ import android.net.Uri import android.os.Build import android.util.Base64 import android.util.Log +import android.webkit.ServiceWorkerController +import android.webkit.WebSettings import androidx.core.content.FileProvider import io.flutter.embedding.android.FlutterActivity import org.conscrypt.Conscrypt import java.security.Security import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel +import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi import java.io.ByteArrayOutputStream import java.io.File 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 WEBVIEW_CACHE_CHANNEL = "io.openim.flutter.im_webview_app/webview_cache" private val TAG = "MainActivity" private val MAX_BRANDING_ICON_SIZE = 192 @@ -107,6 +111,55 @@ class MainActivity : FlutterActivity() { } } } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WEBVIEW_CACHE_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "configureNoCache" -> { + val webViewId = readLongArgument(call.argument("webViewId")) + if (webViewId == null) { + result.error("INVALID_ARGUMENT", "webViewId 不能为空", null) + return@setMethodCallHandler + } + + try { + result.success(configureWebViewNoCache(flutterEngine, webViewId)) + } catch (e: Exception) { + result.error("ERROR", "无法配置 WebView 缓存策略: ${e.message}", null) + } + } + else -> { + result.notImplemented() + } + } + } + } + + private fun readLongArgument(value: Any?): Long? { + return when (value) { + is Int -> value.toLong() + is Long -> value + is Number -> value.toLong() + else -> null + } + } + + private fun configureWebViewNoCache(flutterEngine: FlutterEngine, webViewId: Long): Boolean { + val webView = WebViewFlutterAndroidExternalApi.getWebView(flutterEngine, webViewId) ?: return false + + webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE + webView.clearCache(true) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + try { + ServiceWorkerController + .getInstance() + .serviceWorkerWebSettings + .cacheMode = WebSettings.LOAD_NO_CACHE + } catch (e: Exception) { + Log.w(TAG, "ServiceWorker cache mode configure failed: ${e.message}") + } + } + + return true } private fun getCurrentAppName(): String { diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 7cc4ef3..31cbefc 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -28,6 +28,14 @@ class AppConfig { ); } + 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(); if (value.startsWith('http://') || value.startsWith('https://')) { diff --git a/lib/main.dart b/lib/main.dart index ab010cf..04597eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,89 @@ const _noCacheHeaders = { }; const _shellBrandingChannel = MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); +const _shellWebViewCacheChannel = + MethodChannel('io.openim.flutter.im_webview_app/webview_cache'); +const _runtimeCachePurgeFallbackDuration = Duration(seconds: 2); +const _purgeWebRuntimeCacheScript = r''' +(() => { + if (window.__openimShellRuntimeCachePurgeStarted) { + return; + } + + window.__openimShellRuntimeCachePurgeStarted = true; + + const targetUrl = () => { + try { + const target = new URL(window.location.href); + target.searchParams.set('shell_cache_bust', Date.now().toString()); + target.searchParams.set('shell_runtime_cache_purged', '1'); + return target.toString(); + } catch (_) { + return window.location.href; + } + }; + + const notifyFlutter = (payload) => { + const channel = window.OpenIMFlutterShell; + if (channel && typeof channel.postMessage === 'function') { + channel.postMessage(JSON.stringify(payload)); + return true; + } + return false; + }; + + const purgeRuntimeCache = async () => { + const result = { + cacheStorageKeys: 0, + serviceWorkerRegistrations: 0, + }; + + try { + if ('caches' in window && caches.keys) { + const keys = await caches.keys(); + result.cacheStorageKeys = keys.length; + await Promise.all(keys.map((key) => caches.delete(key))); + } + } catch (_) {} + + try { + if ( + navigator.serviceWorker && + typeof navigator.serviceWorker.getRegistrations === 'function' + ) { + const registrations = await navigator.serviceWorker.getRegistrations(); + result.serviceWorkerRegistrations = registrations.length; + await Promise.all( + registrations.map((registration) => registration.unregister()) + ); + } + } catch (_) {} + + return result; + }; + + purgeRuntimeCache() + .then((result) => { + const payload = { + type: 'runtimeCachePurged', + url: targetUrl(), + result, + }; + if (!notifyFlutter(payload)) { + window.location.replace(payload.url); + } + }) + .catch(() => { + const payload = { + type: 'runtimeCachePurged', + url: targetUrl(), + }; + if (!notifyFlutter(payload)) { + window.location.replace(payload.url); + } + }); +})(); +'''; const _shellMediaPermissionBridgeScript = r''' (() => { if (window.__openimShellMediaBridgeInstalled) { @@ -211,21 +294,19 @@ class H5ShellPage extends StatefulWidget { class _H5ShellPageState extends State with WidgetsBindingObserver { late final WebViewController _controller; - late final String _homeUrl; int _progress = 0; String? _loadError; bool _showShellCover = true; + bool _runtimeCachePurgeStarted = false; + bool _runtimeCachePurgeReloaded = false; Timer? _shellCoverTimer; + Timer? _runtimeCachePurgeFallbackTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _homeUrl = AppConfig.homeUrl( - appName: widget.shellBranding.appName, - appLogo: widget.shellBranding.appLogo, - ); _controller = _buildController(); unawaited(_loadHome()); } @@ -233,6 +314,7 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { @override void dispose() { _shellCoverTimer?.cancel(); + _runtimeCachePurgeFallbackTimer?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -292,14 +374,7 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { } }, onPageFinished: (_) { - unawaited(_syncShellBranding()); - unawaited(_injectShellMediaPermissionBridge()); - if (mounted) { - setState(() { - _progress = 100; - _showShellCover = false; - }); - } + unawaited(_handlePageFinished()); }, onWebResourceError: (error) { if (error.isForMainFrame ?? true) { @@ -356,6 +431,9 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { if (type == 'openAppSettings') { unawaited(openAppSettings()); } + if (type == 'runtimeCachePurged' && payload != null) { + unawaited(_reloadAfterRuntimeCachePurge(payload['url'] as String?)); + } if (type == 'requestMediaPermissions' && payload != null) { unawaited(_handleMediaPermissionBridgeRequest(payload)); } @@ -420,6 +498,118 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { } } + String _freshHomeUrl() { + return AppConfig.homeUrl( + appName: widget.shellBranding.appName, + appLogo: widget.shellBranding.appLogo, + ); + } + + String _freshShellUrl(String? url) { + final value = url?.trim(); + if (value == null || value.isEmpty) { + return _freshHomeUrl(); + } + + try { + return AppConfig.withFreshShellCacheBust(value); + } catch (_) { + return _freshHomeUrl(); + } + } + + Future _handlePageFinished() async { + final waitingForRuntimeCacheReload = + await _purgeWebRuntimeCacheAndReloadIfNeeded(); + if (waitingForRuntimeCacheReload) { + return; + } + + unawaited(_syncShellBranding()); + unawaited(_injectShellMediaPermissionBridge()); + if (mounted) { + setState(() { + _progress = 100; + _showShellCover = false; + }); + } + } + + Future _purgeWebRuntimeCacheAndReloadIfNeeded() async { + if (_runtimeCachePurgeStarted) { + return false; + } + + _runtimeCachePurgeStarted = true; + try { + await _controller.runJavaScript(_purgeWebRuntimeCacheScript); + } catch (_) { + _runtimeCachePurgeStarted = false; + return false; + } + + _runtimeCachePurgeFallbackTimer?.cancel(); + _runtimeCachePurgeFallbackTimer = Timer( + _runtimeCachePurgeFallbackDuration, + () { + if (!_runtimeCachePurgeReloaded) { + unawaited(_reloadAfterRuntimeCachePurge(null)); + } + }, + ); + return true; + } + + Future _reloadAfterRuntimeCachePurge(String? url) async { + if (_runtimeCachePurgeReloaded) { + return; + } + + _runtimeCachePurgeReloaded = true; + _runtimeCachePurgeFallbackTimer?.cancel(); + if (mounted) { + setState(() { + _loadError = null; + _progress = 0; + _showShellCover = true; + }); + } + + String? currentUrl; + if (url == null || url.trim().isEmpty) { + try { + currentUrl = await _controller.currentUrl(); + } catch (_) {} + } + + await _loadUrl(_freshShellUrl(url ?? currentUrl)); + } + + Future _prepareWebViewForFreshLoad() async { + await _configureAndroidNoCache(); + try { + await _controller.clearCache(); + } catch (_) { + // Some WebView implementations can reject cache operations during setup. + } + } + + Future _configureAndroidNoCache() async { + final platformController = _controller.platform; + if (platformController is! AndroidWebViewController) { + return; + } + + try { + await _shellWebViewCacheChannel.invokeMethod( + 'configureNoCache', + {'webViewId': platformController.webViewIdentifier}, + ); + } catch (_) { + // Older shells may not expose the native cache channel yet. + } + } + Future _syncShellBranding() { final payload = jsonEncode({ 'name': widget.shellBranding.appName, @@ -446,6 +636,10 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { } Future _loadHome() async { + await _loadUrl(_freshHomeUrl()); + } + + Future _loadUrl(String url) async { if (mounted) { setState(() { _loadError = null; @@ -454,14 +648,10 @@ class _H5ShellPageState extends State with WidgetsBindingObserver { }); } - try { - await _controller.clearCache(); - } catch (_) { - // Some WebView implementations can reject cache operations during setup. - } + await _prepareWebViewForFreshLoad(); await _controller.loadRequest( - Uri.parse(_homeUrl), + Uri.parse(url), headers: _noCacheHeaders, ); }