Files
mywhatsapp/whatsapp_app/lib/services/whatsapp_service.dart

275 lines
8.9 KiB
Dart

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 'firebase_service.dart';
enum WsStatus { disconnected, connecting, connected, waReady }
class WhatsAppService extends GetxService {
// ── State ────────────────────────────────────────────────────────────────
final status = WsStatus.disconnected.obs;
final qrData = Rx<String?>(null);
final isWaReady = false.obs;
final activeChatId = Rx<String?>(null);
// ── Internal ─────────────────────────────────────────────────────────────
WebSocketChannel? _channel;
StreamSubscription? _sub;
int _reconnectCount = 0;
Timer? _reconnectTimer;
int _requestCounter = 0;
// Pending requests: requestId → Completer
final Map<String, Completer<Map<String, dynamic>>> _pending = {};
// Event streams for push events (new messages, ack updates)
final _eventController = StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> 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<String, dynamic> 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 {
Get.find<FirebaseService>().showLocalNotificationFromData({
'chatId': chatId,
'name': chatId?.split('@')[0] ?? 'WhatsApp',
'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();
if (_reconnectCount >= AppConfig.maxReconnectAttempts) {
print('[WS] Max reconnect attempts reached');
return;
}
_reconnectCount++;
_reconnectTimer = Timer(AppConfig.reconnectDelay, connect);
}
// ── Request/Response ─────────────────────────────────────────────────────
Future<Map<String, dynamic>> _request(Map<String, dynamic> payload) {
final id = (_requestCounter++).toString();
payload['requestId'] = id;
final completer = Completer<Map<String, dynamic>>();
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<Map<String, dynamic>> getConversations({
int limit = 50,
int offset = 0,
}) => _request({ 'type': 'get_conversations', 'limit': limit, 'offset': offset });
Future<Map<String, dynamic>> getMessages(String chatId, {int limit = 50}) =>
_request({ 'type': 'get_messages', 'chatId': chatId, 'limit': limit });
Future<Map<String, dynamic>> sendMessage(String chatId, String text) =>
_request({ 'type': 'send_message', 'chatId': chatId, 'text': text });
Future<Map<String, dynamic>> markRead(String chatId) =>
_request({ 'type': 'mark_read', 'chatId': chatId });
Future<Map<String, dynamic>> searchConversations(String query) =>
_request({ 'type': 'search_conversations', 'query': query });
Future<Map<String, dynamic>> sendFcmToken(String token) =>
_request({ 'type': 'register_fcm', 'token': token });
Future<Map<String, dynamic>> getMedia(String messageId) =>
_request({ 'type': 'get_media', 'messageId': messageId });
Future<Map<String, dynamic>> 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<String, String> mediaCache = <String, String>{}.obs;
Future<String?> 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<Map<String, dynamic>> ping() =>
_request({ 'type': 'ping' });
}