2023-05-01 11:34:34 -04:00
|
|
|
|
import 'dart:convert';
|
2023-05-03 11:51:18 -04:00
|
|
|
|
import 'dart:io';
|
2023-05-03 14:03:46 -04:00
|
|
|
|
import 'package:contacts_plus_plus/apis/message_api.dart';
|
2023-05-05 06:45:00 -04:00
|
|
|
|
import 'package:contacts_plus_plus/clients/notification_client.dart';
|
2023-05-04 07:13:24 -04:00
|
|
|
|
import 'package:contacts_plus_plus/models/authentication_data.dart';
|
2023-05-04 13:04:33 -04:00
|
|
|
|
import 'package:contacts_plus_plus/models/friend.dart';
|
2023-05-01 11:34:34 -04:00
|
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
|
|
2023-05-03 15:55:34 -04:00
|
|
|
|
import 'package:contacts_plus_plus/clients/api_client.dart';
|
2023-05-01 13:13:40 -04:00
|
|
|
|
import 'package:contacts_plus_plus/config.dart';
|
|
|
|
|
import 'package:contacts_plus_plus/models/message.dart';
|
2023-05-03 11:51:18 -04:00
|
|
|
|
import 'package:logging/logging.dart';
|
2023-05-04 07:13:24 -04:00
|
|
|
|
import 'package:workmanager/workmanager.dart';
|
2023-05-01 11:34:34 -04:00
|
|
|
|
|
|
|
|
|
enum EventType {
|
|
|
|
|
unknown,
|
|
|
|
|
message,
|
2023-05-05 09:05:06 -04:00
|
|
|
|
unknown1,
|
|
|
|
|
unknown2,
|
|
|
|
|
unknown3,
|
|
|
|
|
unknown4,
|
|
|
|
|
keepAlive,
|
|
|
|
|
error;
|
2023-05-01 11:34:34 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum EventTarget {
|
|
|
|
|
unknown,
|
|
|
|
|
messageSent,
|
2023-05-05 05:29:54 -04:00
|
|
|
|
receiveMessage,
|
2023-05-01 11:34:34 -04:00
|
|
|
|
messagesRead;
|
|
|
|
|
|
|
|
|
|
factory EventTarget.parse(String? text) {
|
|
|
|
|
if (text == null) return EventTarget.unknown;
|
|
|
|
|
return EventTarget.values.firstWhere((element) => element.name.toLowerCase() == text.toLowerCase(),
|
|
|
|
|
orElse: () => EventTarget.unknown,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-04 13:04:33 -04:00
|
|
|
|
class MessagingClient {
|
2023-05-01 11:34:34 -04:00
|
|
|
|
static const String eofChar = "";
|
|
|
|
|
static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar";
|
2023-05-03 11:51:18 -04:00
|
|
|
|
static const List<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
|
2023-05-04 07:13:24 -04:00
|
|
|
|
static const String taskName = "periodic-unread-check";
|
2023-05-02 04:04:54 -04:00
|
|
|
|
final ApiClient _apiClient;
|
2023-05-04 13:04:33 -04:00
|
|
|
|
final Map<String, Friend> _friendsCache = {};
|
2023-05-02 04:04:54 -04:00
|
|
|
|
final Map<String, MessageCache> _messageCache = {};
|
2023-05-05 06:40:19 -04:00
|
|
|
|
final Map<String, Function> _messageUpdateListeners = {};
|
|
|
|
|
final Map<String, List<Message>> _unreads = {};
|
2023-05-03 11:51:18 -04:00
|
|
|
|
final Logger _logger = Logger("NeosHub");
|
2023-05-04 07:13:24 -04:00
|
|
|
|
final Workmanager _workmanager = Workmanager();
|
2023-05-05 05:29:54 -04:00
|
|
|
|
final NotificationClient _notificationClient;
|
2023-05-05 09:05:06 -04:00
|
|
|
|
int _attempts = 0;
|
2023-05-05 06:40:19 -04:00
|
|
|
|
Function? _unreadsUpdateListener;
|
2023-05-03 11:51:18 -04:00
|
|
|
|
WebSocket? _wsChannel;
|
|
|
|
|
bool _isConnecting = false;
|
2023-05-01 11:34:34 -04:00
|
|
|
|
|
2023-05-05 05:29:54 -04:00
|
|
|
|
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient})
|
|
|
|
|
: _apiClient = apiClient, _notificationClient = notificationClient {
|
2023-05-01 11:34:34 -04:00
|
|
|
|
start();
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-03 14:03:46 -04:00
|
|
|
|
void _sendData(data) {
|
|
|
|
|
if (_wsChannel == null) throw "Neos Hub is not connected";
|
|
|
|
|
_wsChannel!.add(jsonEncode(data)+eofChar);
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-04 13:04:33 -04:00
|
|
|
|
void updateFriendsCache(List<Friend> friends) {
|
|
|
|
|
_friendsCache.clear();
|
|
|
|
|
for (final friend in friends) {
|
|
|
|
|
_friendsCache[friend.id] = friend;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-05 06:40:19 -04:00
|
|
|
|
void updateAllUnreads(List<Message> messages) {
|
|
|
|
|
_unreads.clear();
|
|
|
|
|
for (final msg in messages) {
|
|
|
|
|
if (msg.senderId != _apiClient.userId) {
|
|
|
|
|
final value = _unreads[msg.senderId];
|
|
|
|
|
if (value == null) {
|
|
|
|
|
_unreads[msg.senderId] = [msg];
|
|
|
|
|
} else {
|
|
|
|
|
value.add(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void addUnread(Message message) {
|
|
|
|
|
var messages = _unreads[message.senderId];
|
|
|
|
|
if (messages == null) {
|
|
|
|
|
messages = [message];
|
|
|
|
|
_unreads[message.senderId] = messages;
|
|
|
|
|
} else {
|
|
|
|
|
messages.add(message);
|
|
|
|
|
}
|
|
|
|
|
messages.sort();
|
|
|
|
|
_notificationClient.showUnreadMessagesNotification(messages.reversed);
|
|
|
|
|
notifyUnreadListener();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void clearUnreadsForFriend(Friend friend) {
|
|
|
|
|
_unreads[friend.id]?.clear();
|
|
|
|
|
notifyUnreadListener();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<Message> getUnreadsForFriend(Friend friend) => _unreads[friend.id] ?? [];
|
|
|
|
|
|
|
|
|
|
bool friendHasUnreads(Friend friend) => _unreads.containsKey(friend.id);
|
|
|
|
|
|
|
|
|
|
bool messageIsUnread(Message message) {
|
|
|
|
|
return _unreads[message.senderId]?.any((element) => element.id == message.id) ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-04 13:04:33 -04:00
|
|
|
|
Friend? getAsFriend(String userId) => _friendsCache[userId];
|
|
|
|
|
|
|
|
|
|
Future<MessageCache> getMessageCache(String userId) async {
|
2023-05-02 04:04:54 -04:00
|
|
|
|
var cache = _messageCache[userId];
|
|
|
|
|
if (cache == null){
|
|
|
|
|
cache = MessageCache(apiClient: _apiClient, userId: userId);
|
|
|
|
|
await cache.loadInitialMessages();
|
|
|
|
|
_messageCache[userId] = cache;
|
|
|
|
|
}
|
|
|
|
|
return cache;
|
2023-05-01 11:34:34 -04:00
|
|
|
|
}
|
|
|
|
|
|
2023-05-04 07:13:24 -04:00
|
|
|
|
static Future<void> backgroundCheckUnreads(Map<String, dynamic>? inputData) async {
|
|
|
|
|
if (inputData == null) return;
|
|
|
|
|
final auth = AuthenticationData.fromMap(inputData);
|
|
|
|
|
final unreads = await MessageApi.getUserMessages(ApiClient(authenticationData: auth), unreadOnly: true);
|
2023-05-03 14:03:46 -04:00
|
|
|
|
for (var message in unreads) {
|
|
|
|
|
throw UnimplementedError();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-04 07:13:24 -04:00
|
|
|
|
Future<void> _updateNotificationTask(int minuteInterval) async {
|
|
|
|
|
final auth = _apiClient.authenticationData;
|
|
|
|
|
if (!auth.isAuthenticated) throw "Unauthenticated";
|
|
|
|
|
await _workmanager.cancelByUniqueName(taskName);
|
|
|
|
|
_workmanager.registerPeriodicTask(
|
|
|
|
|
taskName,
|
|
|
|
|
taskName,
|
|
|
|
|
frequency: Duration(minutes: minuteInterval),
|
|
|
|
|
inputData: auth.toMap(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-05 09:05:06 -04:00
|
|
|
|
void _onDisconnected(error) async {
|
2023-05-03 11:51:18 -04:00
|
|
|
|
_logger.warning("Neos Hub connection died with error '$error', reconnecting...");
|
2023-05-05 09:05:06 -04:00
|
|
|
|
await start();
|
2023-05-03 11:51:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
2023-05-01 11:34:34 -04:00
|
|
|
|
Future<void> start() async {
|
2023-05-02 04:04:54 -04:00
|
|
|
|
if (!_apiClient.isAuthenticated) {
|
2023-05-03 11:51:18 -04:00
|
|
|
|
_logger.info("Tried to connect to Neos Hub without authentication, this is probably fine for now.");
|
2023-05-01 11:34:34 -04:00
|
|
|
|
return;
|
|
|
|
|
}
|
2023-05-03 11:51:18 -04:00
|
|
|
|
if (_isConnecting) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_isConnecting = true;
|
|
|
|
|
_wsChannel = await _tryConnect();
|
|
|
|
|
_isConnecting = false;
|
|
|
|
|
_logger.info("Connected to Neos Hub.");
|
|
|
|
|
_wsChannel!.done.then((error) => _onDisconnected(error));
|
|
|
|
|
_wsChannel!.listen(_handleEvent, onDone: () => _onDisconnected("Connection closed."), onError: _onDisconnected);
|
|
|
|
|
_wsChannel!.add(_negotiationPacket);
|
|
|
|
|
}
|
2023-05-01 11:34:34 -04:00
|
|
|
|
|
2023-05-03 11:51:18 -04:00
|
|
|
|
Future<WebSocket> _tryConnect() async {
|
|
|
|
|
while (true) {
|
|
|
|
|
try {
|
|
|
|
|
final http.Response response;
|
|
|
|
|
try {
|
|
|
|
|
response = await http.post(
|
|
|
|
|
Uri.parse("${Config.neosHubUrl}/negotiate"),
|
|
|
|
|
headers: _apiClient.authorizationHeader,
|
|
|
|
|
);
|
|
|
|
|
ApiClient.checkResponse(response);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
throw "Failed to acquire connection info from Neos API: $e";
|
|
|
|
|
}
|
|
|
|
|
final body = jsonDecode(response.body);
|
|
|
|
|
final url = (body["url"] as String?)?.replaceFirst("https://", "wss://");
|
|
|
|
|
final wsToken = body["accessToken"];
|
2023-05-01 11:34:34 -04:00
|
|
|
|
|
2023-05-03 11:51:18 -04:00
|
|
|
|
if (url == null || wsToken == null) {
|
|
|
|
|
throw "Invalid response from server.";
|
|
|
|
|
}
|
2023-05-05 09:05:06 -04:00
|
|
|
|
final ws = await WebSocket.connect("$url&access_token=$wsToken");
|
|
|
|
|
_attempts = 0;
|
|
|
|
|
return ws;
|
2023-05-03 11:51:18 -04:00
|
|
|
|
} catch (e) {
|
2023-05-05 09:05:06 -04:00
|
|
|
|
final timeout = _reconnectTimeoutsSeconds[_attempts.clamp(0, _reconnectTimeoutsSeconds.length - 1)];
|
2023-05-03 11:51:18 -04:00
|
|
|
|
_logger.severe(e);
|
|
|
|
|
_logger.severe("Retrying in $timeout seconds");
|
|
|
|
|
await Future.delayed(Duration(seconds: timeout));
|
2023-05-05 09:05:06 -04:00
|
|
|
|
_attempts++;
|
2023-05-03 11:51:18 -04:00
|
|
|
|
}
|
2023-05-01 11:34:34 -04:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-05 06:40:19 -04:00
|
|
|
|
void registerMessageListener(String userId, Function function) => _messageUpdateListeners[userId] = function;
|
|
|
|
|
void unregisterMessageListener(String userId) => _messageUpdateListeners.remove(userId);
|
|
|
|
|
void notifyMessageListener(String userId) => _messageUpdateListeners[userId]?.call();
|
|
|
|
|
|
|
|
|
|
void registerUnreadListener(Function function) => _unreadsUpdateListener = function;
|
|
|
|
|
void unregisterUnreadListener() => _unreadsUpdateListener = null;
|
|
|
|
|
void notifyUnreadListener() => _unreadsUpdateListener?.call();
|
2023-05-01 11:34:34 -04:00
|
|
|
|
|
|
|
|
|
void _handleEvent(event) {
|
|
|
|
|
final body = jsonDecode((event.toString().replaceAll(eofChar, "")));
|
|
|
|
|
final int rawType = body["type"] ?? 0;
|
|
|
|
|
if (rawType > EventType.values.length) {
|
2023-05-03 11:51:18 -04:00
|
|
|
|
_logger.info("Unhandled event type $rawType: $body");
|
2023-05-01 11:34:34 -04:00
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
switch (EventType.values[rawType]) {
|
2023-05-05 09:05:06 -04:00
|
|
|
|
case EventType.unknown1:
|
|
|
|
|
case EventType.unknown2:
|
|
|
|
|
case EventType.unknown3:
|
|
|
|
|
case EventType.unknown4:
|
2023-05-01 11:34:34 -04:00
|
|
|
|
case EventType.unknown:
|
2023-05-05 09:05:06 -04:00
|
|
|
|
_logger.info("Received unknown event: $rawType: $body");
|
2023-05-01 11:34:34 -04:00
|
|
|
|
break;
|
|
|
|
|
case EventType.message:
|
2023-05-05 09:05:06 -04:00
|
|
|
|
_logger.info("Received message-event.");
|
2023-05-01 11:34:34 -04:00
|
|
|
|
_handleMessageEvent(body);
|
|
|
|
|
break;
|
2023-05-05 09:05:06 -04:00
|
|
|
|
case EventType.keepAlive:
|
|
|
|
|
_logger.info("Received keep-alive.");
|
|
|
|
|
break;
|
|
|
|
|
case EventType.error:
|
|
|
|
|
_logger.severe("Received error-event: ${body["error"]}");
|
|
|
|
|
// Should we trigger a manual reconnect here or just let the remote service close the connection?
|
|
|
|
|
break;
|
2023-05-01 11:34:34 -04:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-02 04:04:54 -04:00
|
|
|
|
void _handleMessageEvent(body) async {
|
2023-05-01 11:34:34 -04:00
|
|
|
|
final target = EventTarget.parse(body["target"]);
|
|
|
|
|
final args = body["arguments"];
|
|
|
|
|
switch (target) {
|
|
|
|
|
case EventTarget.unknown:
|
2023-05-03 11:51:18 -04:00
|
|
|
|
_logger.info("Unknown event-target in message: $body");
|
2023-05-01 11:34:34 -04:00
|
|
|
|
return;
|
|
|
|
|
case EventTarget.messageSent:
|
|
|
|
|
final msg = args[0];
|
|
|
|
|
final message = Message.fromMap(msg, withState: MessageState.sent);
|
2023-05-04 13:04:33 -04:00
|
|
|
|
final cache = await getMessageCache(message.recipientId);
|
2023-05-02 04:04:54 -04:00
|
|
|
|
cache.addMessage(message);
|
2023-05-05 06:40:19 -04:00
|
|
|
|
notifyMessageListener(message.recipientId);
|
2023-05-01 11:34:34 -04:00
|
|
|
|
break;
|
2023-05-05 05:29:54 -04:00
|
|
|
|
case EventTarget.receiveMessage:
|
2023-05-01 11:34:34 -04:00
|
|
|
|
final msg = args[0];
|
|
|
|
|
final message = Message.fromMap(msg);
|
2023-05-04 13:04:33 -04:00
|
|
|
|
final cache = await getMessageCache(message.senderId);
|
2023-05-02 04:04:54 -04:00
|
|
|
|
cache.addMessage(message);
|
2023-05-05 06:40:19 -04:00
|
|
|
|
if (!_messageUpdateListeners.containsKey(message.senderId)) {
|
|
|
|
|
addUnread(message);
|
2023-05-05 05:29:54 -04:00
|
|
|
|
}
|
2023-05-05 06:40:19 -04:00
|
|
|
|
notifyMessageListener(message.senderId);
|
2023-05-01 11:34:34 -04:00
|
|
|
|
break;
|
|
|
|
|
case EventTarget.messagesRead:
|
|
|
|
|
final messageIds = args[0]["ids"] as List;
|
|
|
|
|
final recipientId = args[0]["recipientId"];
|
2023-05-04 13:04:33 -04:00
|
|
|
|
final cache = await getMessageCache(recipientId ?? "");
|
2023-05-01 11:34:34 -04:00
|
|
|
|
for (var id in messageIds) {
|
2023-05-02 04:04:54 -04:00
|
|
|
|
cache.setMessageState(id, MessageState.read);
|
2023-05-01 11:34:34 -04:00
|
|
|
|
}
|
2023-05-05 06:40:19 -04:00
|
|
|
|
notifyMessageListener(recipientId);
|
2023-05-01 11:34:34 -04:00
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-02 04:04:54 -04:00
|
|
|
|
void sendMessage(Message message) async {
|
2023-05-01 11:34:34 -04:00
|
|
|
|
final msgBody = message.toMap();
|
|
|
|
|
final data = {
|
|
|
|
|
"type": EventType.message.index,
|
|
|
|
|
"target": "SendMessage",
|
|
|
|
|
"arguments": [
|
|
|
|
|
msgBody
|
|
|
|
|
],
|
|
|
|
|
};
|
2023-05-03 14:03:46 -04:00
|
|
|
|
_sendData(data);
|
2023-05-04 13:04:33 -04:00
|
|
|
|
final cache = await getMessageCache(message.recipientId);
|
2023-05-02 04:04:54 -04:00
|
|
|
|
cache.messages.add(message);
|
2023-05-05 06:40:19 -04:00
|
|
|
|
notifyMessageListener(message.recipientId);
|
2023-05-01 11:34:34 -04:00
|
|
|
|
}
|
2023-05-03 14:03:46 -04:00
|
|
|
|
|
|
|
|
|
void markMessagesRead(MarkReadBatch batch) {
|
|
|
|
|
final msgBody = batch.toMap();
|
|
|
|
|
final data = {
|
|
|
|
|
"type": EventType.message.index,
|
|
|
|
|
"target": "MarkMessagesRead",
|
|
|
|
|
"arguments": [
|
|
|
|
|
msgBody
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
_sendData(data);
|
|
|
|
|
}
|
2023-05-01 11:34:34 -04:00
|
|
|
|
}
|