Implemented per stream subscription recovery with polling fallback

This commit is contained in:
2026-03-01 17:24:04 +08:00
parent e91e7b43d2
commit c9479f01f0
19 changed files with 894 additions and 494 deletions
+41 -80
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@@ -16,31 +15,33 @@ final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
return controller;
});
/// A lightweight controller that attempts to recover the Supabase Realtime
/// connection when the app returns to the foreground or when auth tokens
/// are refreshed.
/// Simplified realtime controller for app-lifecycle awareness.
/// Individual streams now handle their own recovery via [StreamRecoveryWrapper].
/// This controller only coordinates:
/// - App lifecycle transitions (background/foreground)
/// - Auth token refreshes
/// - Global connection state notification (for UI indicators)
class RealtimeController extends ChangeNotifier {
final SupabaseClient _client;
bool isConnecting = false;
bool isFailed = false;
String? lastError;
int attempts = 0;
final int maxAttempts;
bool _disposed = false;
RealtimeController(this._client, {this.maxAttempts = 4}) {
/// Global flag: true if any stream is recovering; used for subtle UI indicator.
bool isAnyStreamRecovering = false;
RealtimeController(this._client) {
_init();
}
void _init() {
try {
// Listen for auth changes and try to recover the realtime connection
// Listen for auth changes; ensure tokens are fresh for realtime.
// Individual streams will handle their own reconnection.
_client.auth.onAuthStateChange.listen((data) {
final event = data.event;
if (event == AuthChangeEvent.tokenRefreshed ||
event == AuthChangeEvent.signedIn) {
recoverConnection();
// Only refresh token on existing session refreshes, not immediately after sign-in
// (sign-in already provides a fresh token)
if (event == AuthChangeEvent.tokenRefreshed) {
_ensureTokenFresh();
}
});
} catch (e) {
@@ -48,77 +49,37 @@ class RealtimeController extends ChangeNotifier {
}
}
/// Try to reconnect the realtime client using a small exponential backoff.
Future<void> recoverConnection() async {
/// Ensure auth token is fresh for upcoming realtime operations.
/// This is called after token refresh events, not immediately after sign-in.
Future<void> _ensureTokenFresh() async {
if (_disposed) return;
if (isConnecting) return;
isFailed = false;
lastError = null;
isConnecting = true;
notifyListeners();
try {
int delaySeconds = 1;
while (attempts < maxAttempts && !_disposed) {
attempts++;
try {
// Best-effort disconnect then connect so the realtime client picks
// up any refreshed tokens.
try {
// Try to refresh session/token if the SDK supports it. Use dynamic
// to avoid depending on a specific SDK version symbol.
try {
await (_client.auth as dynamic).refreshSession?.call();
} catch (_) {}
// Best-effort disconnect then connect so the realtime client picks
// up any refreshed tokens. The realtime connect/disconnect are
// marked internal by the SDK; suppress the lint here since this
// is a deliberate best-effort recovery.
// ignore: invalid_use_of_internal_member
_client.realtime.disconnect();
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 300));
try {
// ignore: invalid_use_of_internal_member
_client.realtime.connect();
} catch (_) {}
// Give the socket a moment to stabilise.
await Future.delayed(const Duration(seconds: 1));
// Success (best-effort). Reset attempt counter and clear failure.
attempts = 0;
isFailed = false;
lastError = null;
break;
} catch (e) {
lastError = e.toString();
if (attempts >= maxAttempts) {
isFailed = true;
break;
}
await Future.delayed(Duration(seconds: delaySeconds));
delaySeconds = delaySeconds * 2;
}
}
} finally {
if (!_disposed) {
isConnecting = false;
notifyListeners();
// Defensive: only refresh if the method exists (SDK version compatibility)
final authDynamic = _client.auth as dynamic;
if (authDynamic.refreshSession != null) {
await authDynamic.refreshSession?.call();
}
} catch (e) {
debugPrint('RealtimeController: token refresh failed: $e');
}
}
/// Retry a failed recovery attempt.
Future<void> retry() async {
if (_disposed) return;
attempts = 0;
isFailed = false;
lastError = null;
notifyListeners();
await recoverConnection();
/// Notify that a stream is starting recovery. Used for global UI indicator.
void markStreamRecovering() {
if (!isAnyStreamRecovering) {
isAnyStreamRecovering = true;
notifyListeners();
}
}
/// Notify that stream recovery completed. If all streams recovered, update state.
void markStreamRecovered() {
// In practice, individual streams notify their own status via statusChanges.
// This is kept for potential future global coordination.
if (isAnyStreamRecovering) {
isAnyStreamRecovering = false;
notifyListeners();
}
}
@override