243 lines
7.8 KiB
Dart
243 lines
7.8 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';
|
|
|
|
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 '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 });
|
|
|
|
// 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' });
|
|
}
|