Persist H5 WebView login state
This commit is contained in:
269
lib/main.dart
269
lib/main.dart
@@ -17,28 +17,16 @@ const _shellAccent = Color(0xFF168CFF);
|
||||
const _shellAccentDeep = Color(0xFF0066D9);
|
||||
const _shellInk = Color(0xFF17233D);
|
||||
const _shellSubText = Color(0xFF7C8AA3);
|
||||
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 _shellBrandingChannel = MethodChannel(
|
||||
'io.openim.flutter.im_webview_app/shell_branding',
|
||||
);
|
||||
const _androidFilePickerChannel = MethodChannel(
|
||||
'io.openim.flutter.openim/file_picker',
|
||||
);
|
||||
const _keyboardAnimationDuration = Duration(milliseconds: 250);
|
||||
const _initialH5CacheClearedKey = 'initial_h5_cache_cleared_v1';
|
||||
const _h5DomainCacheDateKey = 'h5_random_domain_date_v1';
|
||||
const _h5DomainCacheSessionKey = 'h5_random_domain_session_v1';
|
||||
const _h5DomainCacheUrlKey = 'h5_random_domain_url_v1';
|
||||
const _lineProbeTimeout = Duration(seconds: 5);
|
||||
const _maxGeneratedLineAttempts = 6;
|
||||
final String _h5DomainProcessSession =
|
||||
'session-${DateTime.now().microsecondsSinceEpoch}';
|
||||
|
||||
String _domainDateKey(DateTime value) {
|
||||
final year = value.year.toString().padLeft(4, '0');
|
||||
final month = value.month.toString().padLeft(2, '0');
|
||||
final day = value.day.toString().padLeft(2, '0');
|
||||
return '$year-$month-$day';
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -54,10 +42,7 @@ Future<void> main() async {
|
||||
}
|
||||
|
||||
class ShellBranding {
|
||||
const ShellBranding({
|
||||
required this.appName,
|
||||
required this.appLogo,
|
||||
});
|
||||
const ShellBranding({required this.appName, required this.appLogo});
|
||||
|
||||
static const fallback = ShellBranding(
|
||||
appName: AppConfig.appName,
|
||||
@@ -69,8 +54,9 @@ class ShellBranding {
|
||||
|
||||
static Future<ShellBranding> load() async {
|
||||
try {
|
||||
final data = await _shellBrandingChannel
|
||||
.invokeMapMethod<String, String>('getShellBranding');
|
||||
final data = await _shellBrandingChannel.invokeMapMethod<String, String>(
|
||||
'getShellBranding',
|
||||
);
|
||||
final appName = _trim(data?['appName']);
|
||||
final appLogo = _trim(data?['appLogo']);
|
||||
|
||||
@@ -86,17 +72,10 @@ class ShellBranding {
|
||||
static String _trim(String? value) => value?.trim() ?? '';
|
||||
}
|
||||
|
||||
enum _LineAvailability {
|
||||
checking,
|
||||
available,
|
||||
unavailable,
|
||||
}
|
||||
enum _LineAvailability { checking, available, unavailable }
|
||||
|
||||
class ImWebViewApp extends StatelessWidget {
|
||||
const ImWebViewApp({
|
||||
super.key,
|
||||
this.shellBranding = ShellBranding.fallback,
|
||||
});
|
||||
const ImWebViewApp({super.key, this.shellBranding = ShellBranding.fallback});
|
||||
|
||||
final ShellBranding shellBranding;
|
||||
|
||||
@@ -137,9 +116,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
int _lineGeneration = 0;
|
||||
int _h5LineAttempt = 0;
|
||||
late ShellBranding _shellBranding;
|
||||
Future<void>? _cacheClearTask;
|
||||
Timer? _domainExpiryTimer;
|
||||
String _activeDomainDateKey = '';
|
||||
|
||||
_H5LineWebViewSlot get _currentSlot => _lineSlot;
|
||||
|
||||
@@ -150,7 +126,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
_h5Line = AppConfig.h5Line;
|
||||
_shellBranding = widget.initialShellBranding;
|
||||
_lineSlot = _createLineSlot(_h5Line);
|
||||
_scheduleDomainExpiryTimer();
|
||||
unawaited(_initializeLines());
|
||||
}
|
||||
|
||||
@@ -171,17 +146,13 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'OpenIMShell',
|
||||
onMessageReceived: (message) => _handleShellMessage(
|
||||
generation,
|
||||
message,
|
||||
),
|
||||
onMessageReceived: (message) =>
|
||||
_handleShellMessage(generation, message),
|
||||
)
|
||||
..addJavaScriptChannel(
|
||||
'OpenIMFlutterShell',
|
||||
onMessageReceived: (message) => _handleFlutterShellMessage(
|
||||
generation,
|
||||
message,
|
||||
),
|
||||
onMessageReceived: (message) =>
|
||||
_handleFlutterShellMessage(generation, message),
|
||||
)
|
||||
..setBackgroundColor(Colors.white)
|
||||
..setNavigationDelegate(
|
||||
@@ -252,14 +223,12 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await _androidFilePickerChannel.invokeListMethod<String>(
|
||||
'pickFiles',
|
||||
{
|
||||
'acceptTypes': params.acceptTypes,
|
||||
'allowMultiple': params.mode == FileSelectorMode.openMultiple,
|
||||
'capture': params.isCaptureEnabled,
|
||||
},
|
||||
);
|
||||
final result = await _androidFilePickerChannel
|
||||
.invokeListMethod<String>('pickFiles', {
|
||||
'acceptTypes': params.acceptTypes,
|
||||
'allowMultiple': params.mode == FileSelectorMode.openMultiple,
|
||||
'capture': params.isCaptureEnabled,
|
||||
});
|
||||
return result ?? <String>[];
|
||||
} catch (_) {
|
||||
return <String>[];
|
||||
@@ -269,7 +238,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_domainExpiryTimer?.cancel();
|
||||
_lineSlot.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -277,12 +245,13 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
unawaited(_refreshExpiredH5LinesIfNeeded());
|
||||
unawaited(_syncShellRequestUrl());
|
||||
unawaited(_syncShellBranding());
|
||||
unawaited(_syncKeyboardState());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeLines() async {
|
||||
await _clearInitialH5CachesIfNeeded();
|
||||
await _prepareAndLoadH5Line(forceNew: false);
|
||||
}
|
||||
|
||||
@@ -307,7 +276,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
_isPreparingInitialLine = false;
|
||||
});
|
||||
await _loadCurrentLine(forceReload: true);
|
||||
_scheduleDomainExpiryTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -323,40 +291,25 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
_currentSlot.loadError = '当前线路暂不可用,请稍后重试';
|
||||
_currentSlot.showShellCover = false;
|
||||
});
|
||||
_scheduleDomainExpiryTimer();
|
||||
}
|
||||
|
||||
Future<H5Line> _loadCachedOrCreateH5Line({
|
||||
required bool forceNew,
|
||||
}) async {
|
||||
final today = _domainDateKey(DateTime.now());
|
||||
_activeDomainDateKey = today;
|
||||
|
||||
Future<H5Line> _loadCachedOrCreateH5Line({required bool forceNew}) async {
|
||||
try {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final cachedUrl = preferences.getString(_h5DomainCacheUrlKey);
|
||||
final cachedLine =
|
||||
cachedUrl == null ? null : AppConfig.h5LineFromUrl(cachedUrl);
|
||||
final canUseCachedLine = !forceNew &&
|
||||
preferences.getString(_h5DomainCacheDateKey) == today &&
|
||||
preferences.getString(_h5DomainCacheSessionKey) ==
|
||||
_h5DomainProcessSession &&
|
||||
cachedLine != null;
|
||||
final canUseCachedLine = !forceNew && cachedLine != null;
|
||||
|
||||
if (canUseCachedLine) {
|
||||
_logH5Line('复用本进程当天随机线路', cachedLine);
|
||||
_logH5Line('复用已保存 H5 线路', cachedLine);
|
||||
return cachedLine;
|
||||
}
|
||||
|
||||
final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt);
|
||||
_h5LineAttempt += 1;
|
||||
await preferences.setString(_h5DomainCacheDateKey, today);
|
||||
await preferences.setString(
|
||||
_h5DomainCacheSessionKey,
|
||||
_h5DomainProcessSession,
|
||||
);
|
||||
await preferences.setString(_h5DomainCacheUrlKey, line.url);
|
||||
_logH5Line('生成当天随机线路', line);
|
||||
_logH5Line('生成并保存 H5 线路', line);
|
||||
return line;
|
||||
} catch (_) {
|
||||
final line = AppConfig.createRandomH5Line(attempt: _h5LineAttempt);
|
||||
@@ -366,132 +319,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshExpiredH5LinesIfNeeded() async {
|
||||
final today = _domainDateKey(DateTime.now());
|
||||
if (_isPreparingInitialLine || _activeDomainDateKey == today) {
|
||||
_scheduleDomainExpiryTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPreparingInitialLine = true;
|
||||
});
|
||||
}
|
||||
|
||||
await _clearAllH5Caches();
|
||||
await _prepareAndLoadH5Line(forceNew: true);
|
||||
}
|
||||
|
||||
void _scheduleDomainExpiryTimer() {
|
||||
_domainExpiryTimer?.cancel();
|
||||
|
||||
final now = DateTime.now();
|
||||
final nextMidnight = DateTime(now.year, now.month, now.day + 1);
|
||||
final delay = nextMidnight.difference(now) + const Duration(seconds: 1);
|
||||
_domainExpiryTimer = Timer(
|
||||
delay,
|
||||
() => unawaited(_refreshExpiredH5LinesIfNeeded()),
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
await _clearPageStorage(_lineSlot.controller);
|
||||
await _clearControllerStorage(_lineSlot.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<bool> _probeCurrentLine() async {
|
||||
final generation = _lineGeneration;
|
||||
final line = _lineSlot.line;
|
||||
@@ -907,7 +734,6 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
|
||||
_isReplacingUnavailableLine = true;
|
||||
try {
|
||||
await _clearAllH5Caches();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPreparingInitialLine = true;
|
||||
@@ -1050,9 +876,7 @@ class _H5ShellPageState extends State<H5ShellPage> with WidgetsBindingObserver {
|
||||
Positioned.fill(
|
||||
child: _ShellFallback(progress: currentSlot.progress),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: _buildWebViewWidget(),
|
||||
),
|
||||
Positioned.fill(child: _buildWebViewWidget()),
|
||||
if (currentSlot.showShellCover &&
|
||||
!currentSlot.hasPresentedFirstScreen)
|
||||
Positioned.fill(
|
||||
@@ -1107,10 +931,7 @@ class _NativeMediaPermissionResult {
|
||||
}
|
||||
|
||||
class _H5LineWebViewSlot {
|
||||
_H5LineWebViewSlot({
|
||||
required this.line,
|
||||
required this.controller,
|
||||
});
|
||||
_H5LineWebViewSlot({required this.line, required this.controller});
|
||||
|
||||
final H5Line line;
|
||||
final WebViewController controller;
|
||||
@@ -1159,10 +980,7 @@ class _ErrorPanel extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('重新加载'),
|
||||
),
|
||||
FilledButton(onPressed: onRetry, child: const Text('重新加载')),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1188,8 +1006,10 @@ class _ShellFallback extends StatelessWidget {
|
||||
builder: (context, constraints) {
|
||||
final markSize = (constraints.maxWidth * 0.34).clamp(116.0, 148.0);
|
||||
final contentTop = constraints.maxHeight * 0.28;
|
||||
final progressWidth =
|
||||
(constraints.maxWidth * 0.32).clamp(116.0, 152.0);
|
||||
final progressWidth = (constraints.maxWidth * 0.32).clamp(
|
||||
116.0,
|
||||
152.0,
|
||||
);
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
@@ -1318,11 +1138,7 @@ class _H5LoadingBackgroundPainter extends CustomPainter {
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFEAF7FF),
|
||||
Color(0xFFF8FCFF),
|
||||
Colors.white,
|
||||
],
|
||||
colors: [Color(0xFFEAF7FF), Color(0xFFF8FCFF), Colors.white],
|
||||
stops: [0, 0.52, 1],
|
||||
).createShader(pageRect);
|
||||
canvas.drawRect(pageRect, pagePaint);
|
||||
@@ -1471,10 +1287,7 @@ class _BubbleCheckPainter extends CustomPainter {
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFFE8FBF8),
|
||||
Color(0xFFAEECE4),
|
||||
],
|
||||
colors: [Color(0xFFE8FBF8), Color(0xFFAEECE4)],
|
||||
).createShader(backBubble.outerRect);
|
||||
canvas.drawRRect(backBubble, backBubblePaint);
|
||||
|
||||
@@ -1499,11 +1312,7 @@ class _BubbleCheckPainter extends CustomPainter {
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFFA8DDFF),
|
||||
_shellAccent,
|
||||
_shellAccentDeep,
|
||||
],
|
||||
colors: [Color(0xFFA8DDFF), _shellAccent, _shellAccentDeep],
|
||||
stops: [0, 0.48, 1],
|
||||
).createShader(frontRect);
|
||||
canvas.drawPath(frontTail, bubblePaint);
|
||||
|
||||
Reference in New Issue
Block a user