import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/nostr/nostr_service.dart'; import '../../data/nostr/models/nostr_relay.dart'; import '../../data/sync/sync_engine.dart'; import '../../core/logger.dart'; /// Controller for managing Nostr relay UI state and operations. /// /// This controller separates business logic from UI presentation, /// making the UI module testable and modular. class RelayManagementController extends ChangeNotifier { /// Nostr service for relay operations. final NostrService nostrService; /// Sync engine for triggering manual syncs. final SyncEngine? syncEngine; /// List of relays being managed. List _relays = []; /// Whether a sync operation is in progress. bool _isSyncing = false; /// Error message if any operation fails. String? _error; /// Whether health check is in progress. bool _isCheckingHealth = false; /// Creates a [RelayManagementController] instance. RelayManagementController({ required this.nostrService, this.syncEngine, }) { _loadRelays(); } /// List of relays. List get relays => List.unmodifiable(_relays); /// Whether sync is in progress. bool get isSyncing => _isSyncing; /// Current error message, if any. String? get error => _error; /// Whether health check is in progress. bool get isCheckingHealth => _isCheckingHealth; /// Loads relays from the Nostr service. void _loadRelays() { // Create a new list with new relay objects to ensure Flutter detects the change final serviceRelays = nostrService.getRelays(); _relays = serviceRelays.map((relay) => NostrRelay( url: relay.url, isConnected: relay.isConnected, isEnabled: relay.isEnabled, retryCount: relay.retryCount, )).toList(); notifyListeners(); } /// Adds a relay to the service. /// /// [relayUrl] - The WebSocket URL of the relay. /// /// Tests the connection and enables the relay if successful. /// /// Returns true if the relay was added successfully, false if it already exists or connection failed. Future addRelay(String relayUrl) async { try { _error = null; // Validate URL format if (!relayUrl.startsWith('wss://') && !relayUrl.startsWith('ws://')) { _error = 'Invalid relay URL. Must start with wss:// or ws://'; notifyListeners(); return false; } // Add the relay (it will be disabled by default) nostrService.addRelay(relayUrl); _loadRelays(); // Test the connection Logger.info('Testing connection to relay: $relayUrl'); try { final stream = await nostrService .connectRelay(relayUrl) .timeout( const Duration(seconds: 3), onTimeout: () { Logger.warning('Connection timeout for relay: $relayUrl'); throw Exception('Connection timeout'); }, ); Logger.debug('WebSocket stream created for relay: $relayUrl'); // Wait a bit to see if connection actually works (check for errors) Logger.debug('Setting up stream listener for relay: $relayUrl'); final completer = Completer(); late StreamSubscription subscription; bool gotError = false; subscription = stream.listen( (data) { // Got data - connection is working Logger.info('Received data from relay $relayUrl during add - connection confirmed'); if (!completer.isCompleted) { completer.complete(true); } }, onError: (error) { // Connection error occurred Logger.error('Stream error for relay $relayUrl during add', error); gotError = true; if (!completer.isCompleted) { completer.complete(false); } }, onDone: () { // Stream closed - connection failed Logger.warning('Stream closed for relay $relayUrl during add - connection failed'); if (!completer.isCompleted) { completer.complete(false); } }, ); // Wait for either data (success) or error/timeout (failure) Logger.debug('Waiting for connection confirmation for relay: $relayUrl (timeout: 2 seconds)'); final connected = await completer.future.timeout( const Duration(seconds: 2), onTimeout: () { Logger.warning('Timeout waiting for connection confirmation for relay: $relayUrl during add'); subscription.cancel(); // If no error occurred but no data either, check if relay is marked as connected _loadRelays(); final relay = _relays.firstWhere( (r) => r.url == relayUrl, orElse: () => throw Exception('Relay not found'), ); Logger.debug('Relay $relayUrl connection state during add: isConnected=${relay.isConnected}, isEnabled=${relay.isEnabled}'); return relay.isConnected; }, ); subscription.cancel(); if (connected && !gotError) { // Connection successful - enable the relay Logger.info('Connection successful for relay: $relayUrl - enabling relay'); nostrService.setRelayEnabled(relayUrl, true); _loadRelays(); return true; } else { // Connection failed - leave it disabled Logger.warning('Connection failed for relay: $relayUrl (connected=$connected, gotError=$gotError) - leaving disabled'); nostrService.disconnectRelay(relayUrl); nostrService.setRelayEnabled(relayUrl, false); _loadRelays(); _error = 'Failed to connect to relay'; notifyListeners(); return false; } } catch (e) { // Connection test failed - leave relay disabled Logger.error('Exception during connection test for relay: $relayUrl', e); try { nostrService.disconnectRelay(relayUrl); nostrService.setRelayEnabled(relayUrl, false); _loadRelays(); } catch (_) { // Ignore disconnect errors } _error = 'Failed to connect to relay: ${e.toString().replaceAll('Exception: ', '')}'; notifyListeners(); return false; } } catch (e) { _error = 'Failed to add relay: $e'; notifyListeners(); return false; } } /// Removes a relay from the service. /// /// [relayUrl] - The URL of the relay to remove. void removeRelay(String relayUrl) { try { _error = null; nostrService.removeRelay(relayUrl); _loadRelays(); } catch (e) { _error = 'Failed to remove relay: $e'; notifyListeners(); } } /// Connects to a relay and updates status. /// /// [relayUrl] - The URL of the relay to connect to. /// /// Throws if connection fails (for use in health checks where we want to catch individual failures). Future connectRelay(String relayUrl) async { _error = null; await nostrService.connectRelay(relayUrl); _loadRelays(); } /// Disconnects from a relay. /// /// [relayUrl] - The URL of the relay to disconnect from. void disconnectRelay(String relayUrl) { try { _error = null; nostrService.disconnectRelay(relayUrl); _loadRelays(); } catch (e) { _error = 'Failed to disconnect from relay: $e'; notifyListeners(); } } /// Tests connectivity to a single relay. /// /// [relayUrl] - The URL of the relay to test. /// /// Returns true if connection successful, false otherwise. Future testRelay(String relayUrl) async { _error = null; notifyListeners(); try { final stream = await nostrService .connectRelay(relayUrl) .timeout( const Duration(seconds: 3), onTimeout: () { throw Exception('Connection timeout'); }, ); _loadRelays(); // Cancel the stream subscription to clean up stream.listen(null).cancel(); return true; } catch (e) { // Connection failed - disconnect and disable the relay try { nostrService.disconnectRelay(relayUrl); nostrService.setRelayEnabled(relayUrl, false); // Reload and notify to update UI _loadRelays(); notifyListeners(); } catch (_) { // Ignore disconnect errors } return false; } } /// Toggles a relay on/off (enables/disables it). /// /// [relayUrl] - The URL of the relay to toggle. /// /// When enabling, automatically attempts to connect to the relay. /// If connection fails, automatically disables the relay (toggle moves back to OFF). /// Always attempts to reconnect when toggling on, even if previously failed. /// /// The toggle responds immediately for better UX, then reverts if connection fails. Future toggleRelay(String relayUrl) async { try { _error = null; final relay = _relays.firstWhere((r) => r.url == relayUrl); final newEnabledState = !relay.isEnabled; // If disabling, just disconnect (no test needed) - update UI immediately if (!newEnabledState) { try { nostrService.setRelayEnabled(relayUrl, false); nostrService.disconnectRelay(relayUrl); _loadRelays(); } catch (_) { // Ignore disconnect errors } return; } // If enabling, update UI immediately (optimistic update) // Disconnect first to ensure a fresh connection attempt Logger.debug('Disconnecting existing connection for relay: $relayUrl (if any)'); try { nostrService.disconnectRelay(relayUrl); } catch (_) { // Ignore if not connected } // Enable the relay immediately and update UI (optimistic update) Logger.info('Toggling relay ON: $relayUrl - updating UI immediately'); nostrService.setRelayEnabled(relayUrl, true); // Update UI immediately by updating the relay in our list directly // This bypasses the auto-disable logic in getRelays() during connection attempt final relayIndex = _relays.indexWhere((r) => r.url == relayUrl); if (relayIndex != -1) { _relays[relayIndex] = NostrRelay( url: relayUrl, isConnected: _relays[relayIndex].isConnected, isEnabled: true, ); notifyListeners(); // Update UI immediately } // Now attempt to connect in the background // If connection fails, we'll toggle it back to OFF Logger.info('Attempting to connect to relay: $relayUrl (background)'); try { final stream = await nostrService .connectRelay(relayUrl) .timeout( const Duration(seconds: 5), onTimeout: () { Logger.warning('Connection timeout for relay: $relayUrl (5 seconds)'); throw Exception('Connection timeout'); }, ); Logger.debug('WebSocket stream created for relay: $relayUrl'); // Wait a short time to see if connection is established (no errors) // The service marks connection as established after 500ms if no errors occur Logger.debug('Waiting for connection confirmation for relay: $relayUrl (timeout: 1 second)'); await Future.delayed(const Duration(seconds: 1)); // Cancel the stream subscription to clean up stream.listen(null).cancel(); // Check if connection was established (no errors occurred) _loadRelays(); final updatedRelay = _relays.firstWhere( (r) => r.url == relayUrl, orElse: () => throw Exception('Relay not found'), ); Logger.debug('Relay $relayUrl connection state: isConnected=${updatedRelay.isConnected}, isEnabled=${updatedRelay.isEnabled}'); if (!updatedRelay.isConnected) { // Connection failed - toggle back to OFF Logger.warning('Connection failed for relay: $relayUrl - toggling back to OFF'); nostrService.disconnectRelay(relayUrl); nostrService.setRelayEnabled(relayUrl, false); _loadRelays(); _error = 'Failed to connect to relay'; notifyListeners(); } else { // Connection successful - keep it enabled and connected Logger.info('Connection successful for relay: $relayUrl - keeping enabled'); _loadRelays(); } } catch (e) { // Connection failed - toggle back to OFF Logger.error('Exception during toggle connection for relay: $relayUrl', e); try { nostrService.disconnectRelay(relayUrl); nostrService.setRelayEnabled(relayUrl, false); _loadRelays(); _error = 'Failed to connect to relay: ${e.toString().replaceAll('Exception: ', '')}'; notifyListeners(); } catch (_) { // Ignore disconnect errors } } } catch (e) { _error = 'Failed to toggle relay: $e'; notifyListeners(); } } /// Turns all disabled relays ON. /// /// Only affects relays that are currently disabled. /// Automatically attempts to connect to newly enabled relays. Future turnAllOn() async { try { _error = null; // Find all disabled relays final disabledRelays = _relays.where((r) => !r.isEnabled).toList(); if (disabledRelays.isEmpty) { Logger.info('No disabled relays to turn on'); return; } Logger.info('Turning ${disabledRelays.length} relay(s) ON'); // Disconnect disabled relays first to ensure fresh connection attempts for (final relay in disabledRelays) { try { nostrService.disconnectRelay(relay.url); } catch (_) { // Ignore if not connected } } // Enable disabled relays immediately and update UI (optimistic update) for (final relay in disabledRelays) { nostrService.setRelayEnabled(relay.url, true); // Update UI immediately final relayIndex = _relays.indexWhere((r) => r.url == relay.url); if (relayIndex != -1) { _relays[relayIndex] = NostrRelay( url: relay.url, isConnected: relay.isConnected, isEnabled: true, ); } } notifyListeners(); // Update UI immediately // Capture relay URLs before starting connections (list might change) final relayUrls = disabledRelays.map((r) => r.url).toList(); // Now attempt to connect to all newly enabled relays in parallel final futures = >[]; for (final relayUrl in relayUrls) { futures.add( Future(() async { try { Logger.info('Attempting to connect to relay: $relayUrl'); final stream = await nostrService .connectRelay(relayUrl) .timeout( const Duration(seconds: 5), onTimeout: () { Logger.warning('Connection timeout for relay: $relayUrl (5 seconds)'); throw Exception('Connection timeout'); }, ); Logger.debug('WebSocket stream created for relay: $relayUrl'); // Wait a short time to see if connection is established (no errors) // The service marks connection as established after 500ms if no errors occur Logger.debug('Waiting for connection confirmation for relay: $relayUrl (timeout: 1 second)'); await Future.delayed(const Duration(seconds: 1)); // Cancel the stream subscription to clean up stream.listen(null).cancel(); // Check if connection was established _loadRelays(); final updatedRelay = _relays.firstWhere( (r) => r.url == relayUrl, orElse: () => throw Exception('Relay not found'), ); Logger.debug('Relay $relayUrl connection state: isConnected=${updatedRelay.isConnected}, isEnabled=${updatedRelay.isEnabled}'); if (!updatedRelay.isConnected) { // Connection failed - disable the relay Logger.warning('Connection failed for relay: $relayUrl'); throw Exception('Connection failed'); } // Connection successful Logger.info('Connection successful for relay: $relayUrl'); } catch (e) { // Connection failed - automatically disable the relay Logger.error('Exception during connection for relay: $relayUrl', e); try { nostrService.disconnectRelay(relayUrl); nostrService.setRelayEnabled(relayUrl, false); _loadRelays(); } catch (_) { // Ignore disconnect errors } } }).catchError((error) { // Final safety net - ensure no exceptions escape Logger.error('Error in toggleAllRelays for relay: $relayUrl', error); try { nostrService.disconnectRelay(relayUrl); nostrService.setRelayEnabled(relayUrl, false); _loadRelays(); } catch (_) { // Ignore all errors } return Future.value(); }), ); } // Wait for all connection attempts to complete (or fail gracefully) Logger.info('Waiting for all ${futures.length} relay connection attempts to complete'); await Future.wait(futures, eagerError: false); Logger.info('All relay connection attempts completed'); _loadRelays(); } catch (e) { Logger.error('Failed to turn all relays on', e); _error = 'Failed to turn all relays on: $e'; notifyListeners(); } } /// Turns all enabled relays OFF. /// /// Only affects relays that are currently enabled. Future turnAllOff() async { try { _error = null; // Find all enabled relays final enabledRelays = _relays.where((r) => r.isEnabled).toList(); if (enabledRelays.isEmpty) { Logger.info('No enabled relays to turn off'); return; } Logger.info('Turning ${enabledRelays.length} relay(s) OFF'); // Disconnect and disable all enabled relays for (final relay in enabledRelays) { try { nostrService.setRelayEnabled(relay.url, false); nostrService.disconnectRelay(relay.url); } catch (_) { // Ignore disconnect errors } } _loadRelays(); } catch (e) { Logger.error('Failed to turn all relays off', e); _error = 'Failed to turn all relays off: $e'; notifyListeners(); } } /// Checks health of all relays by attempting to connect. /// /// Updates relay connection status. /// /// Never throws - all connection failures are handled gracefully. Future checkRelayHealth() async { _isCheckingHealth = true; _error = null; notifyListeners(); // Process each relay health check, catching all errors final futures = >[]; for (final relay in _relays) { // Wrap each connection attempt to ensure all exceptions are caught futures.add( Future(() async { try { // Attempt to connect - this may throw synchronously or asynchronously // Wrap in another try-catch to catch synchronous exceptions from WebSocket.connect try { final stream = await nostrService .connectRelay(relay.url) .timeout( const Duration(seconds: 2), onTimeout: () { throw Exception('Connection timeout'); }, ); // If we get here, connection succeeded _loadRelays(); // Cancel the stream subscription to clean up stream.listen(null).cancel(); } catch (e) { // Connection failed - disconnect and disable to mark as unhealthy try { nostrService.disconnectRelay(relay.url); nostrService.setRelayEnabled(relay.url, false); // Reload and notify to update UI _loadRelays(); notifyListeners(); } catch (_) { // Ignore disconnect errors } // Re-throw to be caught by outer catch throw e; } } catch (e) { // Catch all exceptions - connection failures are expected in tests // Disconnect and disable relay to mark as unhealthy try { nostrService.disconnectRelay(relay.url); nostrService.setRelayEnabled(relay.url, false); // Reload and notify to update UI _loadRelays(); notifyListeners(); } catch (_) { // Ignore disconnect errors } // Don't re-throw - this method should never throw } }).catchError((_) { // Final safety net - ensure no exceptions escape try { nostrService.disconnectRelay(relay.url); nostrService.setRelayEnabled(relay.url, false); _loadRelays(); } catch (_) { // Ignore all errors } return Future.value(); }), ); } // Wait for all health checks to complete (or fail gracefully) // Use eagerError: false so one failure doesn't stop others try { await Future.wait(futures, eagerError: false); } catch (_) { // Swallow any errors - all individual failures already handled above } _isCheckingHealth = false; notifyListeners(); } /// Triggers a manual sync using the sync engine. /// /// Returns true if sync was triggered successfully. Future triggerManualSync() async { if (syncEngine == null) { _error = 'Sync engine not configured'; notifyListeners(); return false; } _isSyncing = true; _error = null; notifyListeners(); try { // Trigger sync to all configured relays via Nostr // This is a simplified sync - in a real app, you'd sync specific items await syncEngine!.syncAll(); return true; } catch (e) { _error = 'Sync failed: $e'; return false; } finally { _isSyncing = false; notifyListeners(); } } /// Clears the current error message. void clearError() { _error = null; notifyListeners(); } @override void dispose() { // Don't dispose nostrService or syncEngine - they're managed elsewhere super.dispose(); } }