Implemented per stream subscription recovery with polling fallback
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user