import 'dart:async'; import 'dart:convert'; import 'package:get/get.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../config/app_config.dart'; import 'contacts_service.dart'; import 'firebase_service.dart'; enum WsStatus { disconnected, connecting, connected, waReady } class WhatsAppService extends GetxService { // ── State ──────────────────────────────────────────────────────────────── final status = WsStatus.disconnected.obs; final qrData = Rx(null); final isWaReady = false.obs; final activeChatId = Rx(null); // ── Internal ───────────────────────────────────────────────────────────── WebSocketChannel? _channel; StreamSubscription? _sub; int _reconnectCount = 0; Timer? _reconnectTimer; int _requestCounter = 0; // Pending requests: requestId → Completer final Map>> _pending = {}; // Event streams for push events (new messages, ack updates) final _eventController = StreamController>.broadcast(); Stream> get events => _eventController.stream; // ── Lifecycle ──────────────────────────────────────────────────────────── @override void onInit() { super.onInit(); connect(); } @override void onClose() { _sub?.cancel(); _reconnectTimer?.cancel(); _channel?.sink.close(); _eventController.close(); super.onClose(); } // ── Connection ─────────────────────────────────────────────────────────── void connect() { if (status.value == WsStatus.connecting || status.value == WsStatus.connected || status.value == WsStatus.waReady) return; status.value = WsStatus.connecting; _reconnectTimer?.cancel(); print('[WS] Attempting to connect to ${AppConfig.wsUrl}...'); try { _channel = WebSocketChannel.connect(Uri.parse(AppConfig.wsUrl)); _sub?.cancel(); _sub = _channel!.stream.listen( _onData, onError: _onError, onDone: _onDone, ); status.value = WsStatus.connected; _reconnectCount = 0; print('[WS] Connected successfully.'); // Request initial status check ping(); } catch (e) { print('[WS] Connection exception: $e'); _scheduleReconnect(); } } void _onData(dynamic raw) { print('[WS RECV] $raw'); Map data; try { data = jsonDecode(raw as String); } catch (_) { return; } final type = data['type'] as String?; final requestId = data['requestId'] as String?; // Resolve pending request if (requestId != null && _pending.containsKey(requestId)) { _pending.remove(requestId)!.complete(data); return; } // Push events switch (type) { case 'new_message': // Trigger a local notification if the app is open (WebSocket connected) final chatId = data['chatId']; final msgData = data['data']; if (msgData != null && msgData['fromMe'] != true) { String body = msgData['body'] ?? ''; if (body.isEmpty && msgData['hasMedia'] == true) { body = '📷 Media/Audio message'; } try { final String cleanNumber = chatId?.split('@')[0] ?? ''; final String senderName = Get.find().getContactName( cleanNumber, cleanNumber.isNotEmpty ? '+$cleanNumber' : 'WhatsApp', ); Get.find().showLocalNotificationFromData({ 'chatId': chatId, 'name': senderName, 'body': body, }); } catch (e) { print('[LOCAL NOTIF ERROR] $e'); } } break; case 'qr': qrData.value = data['qr']; isWaReady.value = false; if (status.value == WsStatus.waReady) { status.value = WsStatus.connected; } break; case 'authenticated': qrData.value = null; break; case 'ready': isWaReady.value = true; status.value = WsStatus.waReady; qrData.value = null; break; case 'disconnected': isWaReady.value = false; status.value = WsStatus.connected; break; case 'status': if (data['ready'] == true) { isWaReady.value = true; status.value = WsStatus.waReady; qrData.value = null; } else { isWaReady.value = false; if (status.value == WsStatus.waReady) { status.value = WsStatus.connected; } } break; } // Broadcast all events to listeners _eventController.add(data); } void _onError(Object err) { print('[WS ERROR] $err'); _handleDisconnect(); } void _onDone() { print('[WS DONE] Connection closed by server'); _handleDisconnect(); } void _handleDisconnect() { status.value = WsStatus.disconnected; isWaReady.value = false; // Reject all pending requests with error final pendingKeys = List.from(_pending.keys); for (final key in pendingKeys) { _pending.remove(key)?.completeError(Exception('Connection lost')); } _scheduleReconnect(); } void _scheduleReconnect() { _reconnectTimer?.cancel(); _reconnectCount++; // Progressive backoff: starting at reconnectDelay (3s) up to 15s maximum final delaySec = (AppConfig.reconnectDelay.inSeconds * (_reconnectCount > 5 ? 5 : _reconnectCount)).clamp(3, 15); final delay = Duration(seconds: delaySec); print('[WS] Reconnecting in ${delay.inSeconds} seconds (attempt $_reconnectCount)...'); _reconnectTimer = Timer(delay, connect); } // ── Request/Response ───────────────────────────────────────────────────── Future> _request(Map payload) { final id = (_requestCounter++).toString(); payload['requestId'] = id; final completer = Completer>(); if (status.value == WsStatus.disconnected) { completer.completeError(Exception('WebSocket is disconnected')); return completer.future; } _pending[id] = completer; try { final jsonMsg = jsonEncode(payload); print('[WS SEND] $jsonMsg'); _channel?.sink.add(jsonMsg); } catch (e) { _pending.remove(id); completer.completeError(e); return completer.future; } // Timeout after 60s return completer.future.timeout( const Duration(seconds: 60), onTimeout: () { _pending.remove(id); throw TimeoutException('Request timed out: ${payload['type']}'); }, ); } // ── Public API ─────────────────────────────────────────────────────────── Future> getConversations({ int limit = 50, int offset = 0, }) => _request({ 'type': 'get_conversations', 'limit': limit, 'offset': offset }); Future> getMessages(String chatId, {int limit = 50}) => _request({ 'type': 'get_messages', 'chatId': chatId, 'limit': limit }); Future> sendMessage(String chatId, String text) => _request({ 'type': 'send_message', 'chatId': chatId, 'text': text }); Future> markRead(String chatId) => _request({ 'type': 'mark_read', 'chatId': chatId }); Future> searchConversations(String query) => _request({ 'type': 'search_conversations', 'query': query }); Future> sendFcmToken(String token) => _request({ 'type': 'register_fcm', 'token': token }); Future> getMedia(String messageId) => _request({ 'type': 'get_media', 'messageId': messageId }); Future> sendMedia(String chatId, String base64, String mimetype, String filename, {String? caption}) => _request({ 'type': 'send_media', 'chatId': chatId, 'base64': base64, 'mimetype': mimetype, 'filename': filename, 'caption': caption ?? '' }); // Cache downloaded media: messageId -> base64 final RxMap mediaCache = {}.obs; Future downloadAndCacheMedia(String messageId) async { if (mediaCache.containsKey(messageId)) return mediaCache[messageId]; try { final res = await getMedia(messageId); if (res['type'] == 'media' && res['data'] != null) { final String base64Data = res['data']; mediaCache[messageId] = base64Data; return base64Data; } } catch (e) { print('[MEDIA DOWNLOAD ERROR] Failed to download message media: $e'); } return null; } Future> ping() => _request({ 'type': 'ping' }); }