Fix contact status loading
This commit is contained in:
parent
f305fcd23c
commit
b1f2d65ab8
8 changed files with 262 additions and 192 deletions
|
@ -22,7 +22,7 @@ class ContactApi {
|
||||||
ownerId: client.userId,
|
ownerId: client.userId,
|
||||||
userStatus: UserStatus.empty(),
|
userStatus: UserStatus.empty(),
|
||||||
userProfile: UserProfile.empty(),
|
userProfile: UserProfile.empty(),
|
||||||
friendStatus: FriendStatus.accepted,
|
contactStatus: FriendStatus.accepted,
|
||||||
latestMessageTime: DateTime.now(),
|
latestMessageTime: DateTime.now(),
|
||||||
);
|
);
|
||||||
final body = jsonEncode(friend.toMap(shallow: true));
|
final body = jsonEncode(friend.toMap(shallow: true));
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'package:contacts_plus_plus/hub_manager.dart';
|
||||||
import 'dart:io';
|
import 'package:contacts_plus_plus/models/users/user_status.dart';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
@ -13,9 +12,7 @@ import 'package:contacts_plus_plus/apis/user_api.dart';
|
||||||
import 'package:contacts_plus_plus/clients/notification_client.dart';
|
import 'package:contacts_plus_plus/clients/notification_client.dart';
|
||||||
import 'package:contacts_plus_plus/models/users/friend.dart';
|
import 'package:contacts_plus_plus/models/users/friend.dart';
|
||||||
import 'package:contacts_plus_plus/clients/api_client.dart';
|
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||||
import 'package:contacts_plus_plus/config.dart';
|
|
||||||
import 'package:contacts_plus_plus/models/message.dart';
|
import 'package:contacts_plus_plus/models/message.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
enum EventType {
|
enum EventType {
|
||||||
undefined,
|
undefined,
|
||||||
|
@ -39,16 +36,14 @@ enum EventTarget {
|
||||||
|
|
||||||
factory EventTarget.parse(String? text) {
|
factory EventTarget.parse(String? text) {
|
||||||
if (text == null) return EventTarget.unknown;
|
if (text == null) return EventTarget.unknown;
|
||||||
return EventTarget.values.firstWhere((element) => element.name.toLowerCase() == text.toLowerCase(),
|
return EventTarget.values.firstWhere(
|
||||||
|
(element) => element.name.toLowerCase() == text.toLowerCase(),
|
||||||
orElse: () => EventTarget.unknown,
|
orElse: () => EventTarget.unknown,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MessagingClient extends ChangeNotifier {
|
class MessagingClient extends ChangeNotifier {
|
||||||
static const String _eofChar = "";
|
|
||||||
static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$_eofChar";
|
|
||||||
static const List<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
|
|
||||||
static const Duration _autoRefreshDuration = Duration(seconds: 10);
|
static const Duration _autoRefreshDuration = Duration(seconds: 10);
|
||||||
static const Duration _unreadSafeguardDuration = Duration(seconds: 120);
|
static const Duration _unreadSafeguardDuration = Duration(seconds: 120);
|
||||||
static const String _messageBoxKey = "message-box";
|
static const String _messageBoxKey = "message-box";
|
||||||
|
@ -58,28 +53,24 @@ class MessagingClient extends ChangeNotifier {
|
||||||
final List<Friend> _sortedFriendsCache = []; // Keep a sorted copy so as to not have to sort during build()
|
final List<Friend> _sortedFriendsCache = []; // Keep a sorted copy so as to not have to sort during build()
|
||||||
final Map<String, MessageCache> _messageCache = {};
|
final Map<String, MessageCache> _messageCache = {};
|
||||||
final Map<String, List<Message>> _unreads = {};
|
final Map<String, List<Message>> _unreads = {};
|
||||||
final Logger _logger = Logger("NeosHub");
|
final Logger _logger = Logger("Messaging");
|
||||||
final NotificationClient _notificationClient;
|
final NotificationClient _notificationClient;
|
||||||
|
final HubManager _hubManager = HubManager();
|
||||||
|
|
||||||
Friend? selectedFriend;
|
Friend? selectedFriend;
|
||||||
Timer? _notifyOnlineTimer;
|
Timer? _notifyOnlineTimer;
|
||||||
Timer? _autoRefresh;
|
Timer? _autoRefresh;
|
||||||
Timer? _unreadSafeguard;
|
Timer? _unreadSafeguard;
|
||||||
int _attempts = 0;
|
|
||||||
WebSocket? _wsChannel;
|
|
||||||
bool _isConnecting = false;
|
|
||||||
String? _initStatus;
|
String? _initStatus;
|
||||||
|
|
||||||
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient})
|
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient})
|
||||||
: _apiClient = apiClient, _notificationClient = notificationClient {
|
: _apiClient = apiClient,
|
||||||
|
_notificationClient = notificationClient {
|
||||||
debugPrint("mClient created: $hashCode");
|
debugPrint("mClient created: $hashCode");
|
||||||
Hive.openBox(_messageBoxKey).then((box) async {
|
Hive.openBox(_messageBoxKey).then((box) async {
|
||||||
box.delete(_lastUpdateKey);
|
box.delete(_lastUpdateKey);
|
||||||
await refreshFriendsListWithErrorHandler();
|
|
||||||
await _refreshUnreads();
|
|
||||||
_unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads());
|
|
||||||
});
|
});
|
||||||
_startWebsocket();
|
_setupHub();
|
||||||
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async {
|
_notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async {
|
||||||
// We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this
|
// We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this
|
||||||
// but I don't feel like implementing that right now.
|
// but I don't feel like implementing that right now.
|
||||||
|
@ -93,21 +84,20 @@ class MessagingClient extends ChangeNotifier {
|
||||||
_autoRefresh?.cancel();
|
_autoRefresh?.cancel();
|
||||||
_notifyOnlineTimer?.cancel();
|
_notifyOnlineTimer?.cancel();
|
||||||
_unreadSafeguard?.cancel();
|
_unreadSafeguard?.cancel();
|
||||||
_wsChannel?.close();
|
_hubManager.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? get initStatus => _initStatus;
|
String? get initStatus => _initStatus;
|
||||||
|
|
||||||
bool get websocketConnected => _wsChannel != null;
|
|
||||||
|
|
||||||
List<Friend> get cachedFriends => _sortedFriendsCache;
|
List<Friend> get cachedFriends => _sortedFriendsCache;
|
||||||
|
|
||||||
List<Message> getUnreadsForFriend(Friend friend) => _unreads[friend.id] ?? [];
|
List<Message> getUnreadsForFriend(Friend friend) => _unreads[friend.id] ?? [];
|
||||||
|
|
||||||
bool friendHasUnreads(Friend friend) => _unreads.containsKey(friend.id);
|
bool friendHasUnreads(Friend friend) => _unreads.containsKey(friend.id);
|
||||||
|
|
||||||
bool messageIsUnread(Message message) => _unreads[message.senderId]?.any((element) => element.id == message.id) ?? false;
|
bool messageIsUnread(Message message) =>
|
||||||
|
_unreads[message.senderId]?.any((element) => element.id == message.id) ?? false;
|
||||||
|
|
||||||
Friend? getAsFriend(String userId) => Friend.fromMapOrNull(Hive.box(_messageBoxKey).get(userId));
|
Friend? getAsFriend(String userId) => Friend.fromMapOrNull(Hive.box(_messageBoxKey).get(userId));
|
||||||
|
|
||||||
|
@ -115,8 +105,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
|
|
||||||
MessageCache _createUserMessageCache(String userId) => MessageCache(apiClient: _apiClient, userId: userId);
|
MessageCache _createUserMessageCache(String userId) => MessageCache(apiClient: _apiClient, userId: userId);
|
||||||
|
|
||||||
|
Future<void> refreshFriendsListWithErrorHandler() async {
|
||||||
Future<void> refreshFriendsListWithErrorHandler () async {
|
|
||||||
try {
|
try {
|
||||||
await refreshFriendsList();
|
await refreshFriendsList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -132,7 +121,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
|
|
||||||
final friends = await ContactApi.getFriendsList(_apiClient, lastStatusUpdate: lastUpdateUtc);
|
final friends = await ContactApi.getFriendsList(_apiClient, lastStatusUpdate: lastUpdateUtc);
|
||||||
for (final friend in friends) {
|
for (final friend in friends) {
|
||||||
await _updateFriend(friend);
|
await _updateContact(friend);
|
||||||
}
|
}
|
||||||
|
|
||||||
_initStatus = "";
|
_initStatus = "";
|
||||||
|
@ -141,7 +130,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
|
|
||||||
void sendMessage(Message message) {
|
void sendMessage(Message message) {
|
||||||
final msgBody = message.toMap();
|
final msgBody = message.toMap();
|
||||||
_send("SendMessage", body: msgBody);
|
_hubManager.send("SendMessage", arguments: [msgBody]);
|
||||||
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
|
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
|
||||||
cache.addMessage(message);
|
cache.addMessage(message);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
@ -149,7 +138,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
|
|
||||||
void markMessagesRead(MarkReadBatch batch) {
|
void markMessagesRead(MarkReadBatch batch) {
|
||||||
final msgBody = batch.toMap();
|
final msgBody = batch.toMap();
|
||||||
_send("MarkMessagesRead", body: msgBody);
|
_hubManager.send("MarkMessagesRead", arguments: [msgBody]);
|
||||||
clearUnreadsForUser(batch.senderId);
|
clearUnreadsForUser(batch.senderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -201,7 +190,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
final friend = getAsFriend(userId);
|
final friend = getAsFriend(userId);
|
||||||
if (friend == null) return;
|
if (friend == null) return;
|
||||||
final newStatus = await UserApi.getUserStatus(_apiClient, userId: userId);
|
final newStatus = await UserApi.getUserStatus(_apiClient, userId: userId);
|
||||||
await _updateFriend(friend.copyWith(userStatus: newStatus));
|
await _updateContact(friend.copyWith(userStatus: newStatus));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,7 +217,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateFriend(Friend friend) async {
|
Future<void> _updateContact(Friend friend) async {
|
||||||
final box = Hive.box(_messageBoxKey);
|
final box = Hive.box(_messageBoxKey);
|
||||||
box.put(friend.id, friend.toMap());
|
box.put(friend.id, friend.toMap());
|
||||||
final lastStatusUpdate = box.get(_lastUpdateKey);
|
final lastStatusUpdate = box.get(_lastUpdateKey);
|
||||||
|
@ -247,144 +236,79 @@ class MessagingClient extends ChangeNotifier {
|
||||||
_sortFriendsCache();
|
_sortFriendsCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Websocket Stuff =====
|
Future<void> _setupHub() async {
|
||||||
|
|
||||||
void _onDisconnected(error) async {
|
|
||||||
_wsChannel = null;
|
|
||||||
_logger.warning("Neos Hub connection died with error '$error', reconnecting...");
|
|
||||||
await _startWebsocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _startWebsocket() async {
|
|
||||||
if (!_apiClient.isAuthenticated) {
|
if (!_apiClient.isAuthenticated) {
|
||||||
_logger.info("Tried to connect to Neos Hub without authentication, this is probably fine for now.");
|
_logger.info("Tried to connect to Neos Hub without authentication, this is probably fine for now.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_isConnecting) {
|
_hubManager.setHeaders(_apiClient.authorizationHeader);
|
||||||
return;
|
|
||||||
}
|
_hubManager.setHandler(EventTarget.messageSent, _onMessageSent);
|
||||||
_isConnecting = true;
|
_hubManager.setHandler(EventTarget.receiveMessage, _onReceiveMessage);
|
||||||
_wsChannel = await _tryConnect();
|
_hubManager.setHandler(EventTarget.messagesRead, _onMessagesRead);
|
||||||
_isConnecting = false;
|
_hubManager.setHandler(EventTarget.receiveStatusUpdate, _onReceiveStatusUpdate);
|
||||||
_logger.info("Connected to Neos Hub.");
|
|
||||||
_wsChannel!.done.then((error) => _onDisconnected(error));
|
await _hubManager.start();
|
||||||
_wsChannel!.listen(_handleEvent, onDone: () => _onDisconnected("Connection closed."), onError: _onDisconnected);
|
_hubManager.send(
|
||||||
_wsChannel!.add(_negotiationPacket);
|
"InitializeStatus",
|
||||||
_send("InitializeStatus");
|
responseHandler: (Map data) async {
|
||||||
|
final rawContacts = data["contacts"] as List;
|
||||||
|
final contacts = rawContacts.map((e) => Friend.fromMap(e)).toList();
|
||||||
|
for (final contact in contacts) {
|
||||||
|
await _updateContact(contact);
|
||||||
|
}
|
||||||
|
_initStatus = "";
|
||||||
|
notifyListeners();
|
||||||
|
await _refreshUnreads();
|
||||||
|
_unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads());
|
||||||
|
_hubManager.send("RequestStatus", arguments: [null, false]);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<WebSocket> _tryConnect() async {
|
void _onMessageSent(List args) {
|
||||||
while (true) {
|
final msg = args[0];
|
||||||
try {
|
final message = Message.fromMap(msg, withState: MessageState.sent);
|
||||||
final ws = await WebSocket.connect(Config.resoniteHubUrl.replaceFirst("https://", "wss://"), headers: _apiClient.authorizationHeader);
|
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
|
||||||
_attempts = 0;
|
cache.addMessage(message);
|
||||||
return ws;
|
notifyListeners();
|
||||||
} catch (e) {
|
}
|
||||||
final timeout = _reconnectTimeoutsSeconds[_attempts.clamp(0, _reconnectTimeoutsSeconds.length - 1)];
|
|
||||||
_logger.severe(e);
|
void _onReceiveMessage(List args) {
|
||||||
_logger.severe("Retrying in $timeout seconds");
|
final msg = args[0];
|
||||||
await Future.delayed(Duration(seconds: timeout));
|
final message = Message.fromMap(msg);
|
||||||
_attempts++;
|
final cache = getUserMessageCache(message.senderId) ?? _createUserMessageCache(message.senderId);
|
||||||
|
cache.addMessage(message);
|
||||||
|
if (message.senderId != selectedFriend?.id) {
|
||||||
|
addUnread(message);
|
||||||
|
updateFriendStatus(message.senderId);
|
||||||
|
} else {
|
||||||
|
markMessagesRead(MarkReadBatch(senderId: message.senderId, ids: [message.id], readTime: DateTime.now()));
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMessagesRead(List args) {
|
||||||
|
final messageIds = args[0]["ids"] as List;
|
||||||
|
final recipientId = args[0]["recipientId"];
|
||||||
|
if (recipientId == null) return;
|
||||||
|
final cache = getUserMessageCache(recipientId);
|
||||||
|
if (cache == null) return;
|
||||||
|
for (var id in messageIds) {
|
||||||
|
cache.setMessageState(id, MessageState.read);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onReceiveStatusUpdate(List args) {
|
||||||
|
for (final statusUpdate in args) {
|
||||||
|
final status = UserStatus.fromMap(statusUpdate);
|
||||||
|
var friend = getAsFriend(statusUpdate["userId"]);
|
||||||
|
friend = friend?.copyWith(userStatus: status);
|
||||||
|
if (friend != null) {
|
||||||
|
_updateContact(friend);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
void _handleEvent(event) {
|
|
||||||
final body = jsonDecode((event.toString().replaceAll(_eofChar, "")));
|
|
||||||
final int? rawType = body["type"];
|
|
||||||
if (rawType == null) {
|
|
||||||
_logger.warning("Received empty event, content was $event");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (rawType > EventType.values.length) {
|
|
||||||
_logger.info("Unhandled event type $rawType: $body");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (EventType.values[rawType]) {
|
|
||||||
case EventType.streamItem:
|
|
||||||
case EventType.completion:
|
|
||||||
case EventType.streamInvocation:
|
|
||||||
case EventType.cancelInvocation:
|
|
||||||
case EventType.undefined:
|
|
||||||
_logger.info("Received unhandled event: $rawType: $body");
|
|
||||||
break;
|
|
||||||
case EventType.invocation:
|
|
||||||
_logger.info("Received invocation-event.");
|
|
||||||
_handleInvocation(body);
|
|
||||||
break;
|
|
||||||
case EventType.ping:
|
|
||||||
_logger.info("Received keep-alive.");
|
|
||||||
break;
|
|
||||||
case EventType.close:
|
|
||||||
_logger.severe("Received close-event: ${body["error"]}");
|
|
||||||
// Should we trigger a manual reconnect here or just let the remote service close the connection?
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleInvocation(body) async {
|
|
||||||
final target = EventTarget.parse(body["target"]);
|
|
||||||
final args = body["arguments"];
|
|
||||||
switch (target) {
|
|
||||||
case EventTarget.unknown:
|
|
||||||
_logger.info("Unknown event-target in message: $body");
|
|
||||||
return;
|
|
||||||
case EventTarget.messageSent:
|
|
||||||
final msg = args[0];
|
|
||||||
final message = Message.fromMap(msg, withState: MessageState.sent);
|
|
||||||
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
|
|
||||||
cache.addMessage(message);
|
|
||||||
notifyListeners();
|
|
||||||
break;
|
|
||||||
case EventTarget.receiveMessage:
|
|
||||||
final msg = args[0];
|
|
||||||
final message = Message.fromMap(msg);
|
|
||||||
final cache = getUserMessageCache(message.senderId) ?? _createUserMessageCache(message.senderId);
|
|
||||||
cache.addMessage(message);
|
|
||||||
if (message.senderId != selectedFriend?.id) {
|
|
||||||
addUnread(message);
|
|
||||||
updateFriendStatus(message.senderId);
|
|
||||||
} else {
|
|
||||||
markMessagesRead(MarkReadBatch(senderId: message.senderId, ids: [message.id], readTime: DateTime.now()));
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
break;
|
|
||||||
case EventTarget.messagesRead:
|
|
||||||
final messageIds = args[0]["ids"] as List;
|
|
||||||
final recipientId = args[0]["recipientId"];
|
|
||||||
if (recipientId == null) break;
|
|
||||||
final cache = getUserMessageCache(recipientId);
|
|
||||||
if (cache == null) break;
|
|
||||||
for (var id in messageIds) {
|
|
||||||
cache.setMessageState(id, MessageState.read);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
break;
|
|
||||||
case EventTarget.receiveStatusUpdate:
|
|
||||||
|
|
||||||
break;
|
|
||||||
case EventTarget.removeSession:
|
|
||||||
case EventTarget.receiveSessionUpdate:
|
|
||||||
// TODO: Handle session updates
|
|
||||||
_logger.info("Received unhandled invocation event.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _send(String target, {Map? body}) {
|
|
||||||
final invocationId = const Uuid().v4();
|
|
||||||
final data = {
|
|
||||||
"type": EventType.invocation.index,
|
|
||||||
"invocationId": invocationId,
|
|
||||||
"target": target,
|
|
||||||
"arguments": [
|
|
||||||
if (body != null)
|
|
||||||
body
|
|
||||||
],
|
|
||||||
};
|
|
||||||
if (_wsChannel == null) throw "Neos Hub is not connected";
|
|
||||||
_wsChannel!.add(jsonEncode(data)+_eofChar);
|
|
||||||
return invocationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
134
lib/hub_manager.dart
Normal file
134
lib/hub_manager.dart
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
||||||
|
import 'package:contacts_plus_plus/config.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
class HubManager {
|
||||||
|
static const String _eofChar = "";
|
||||||
|
static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$_eofChar";
|
||||||
|
static const List<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
|
||||||
|
|
||||||
|
final Logger _logger = Logger("Hub");
|
||||||
|
final Map<String, dynamic> _headers = {};
|
||||||
|
final Map<EventTarget, dynamic Function(List arguments)> _handlers = {};
|
||||||
|
final Map<String, dynamic Function(Map result)> _responseHandlers = {};
|
||||||
|
WebSocket? _wsChannel;
|
||||||
|
bool _isConnecting = false;
|
||||||
|
int _attempts = 0;
|
||||||
|
|
||||||
|
void setHandler(EventTarget target, Function(List args) function) {
|
||||||
|
_handlers[target] = function;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHeaders(Map<String, dynamic> headers) {
|
||||||
|
_headers.addAll(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDisconnected(error) async {
|
||||||
|
_wsChannel = null;
|
||||||
|
_logger.warning("Neos Hub connection died with error '$error', reconnecting...");
|
||||||
|
await start();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<WebSocket> _tryConnect() async {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
final ws = await WebSocket.connect(Config.resoniteHubUrl.replaceFirst("https://", "wss://"), headers: _headers);
|
||||||
|
_attempts = 0;
|
||||||
|
return ws;
|
||||||
|
} catch (e) {
|
||||||
|
final timeout = _reconnectTimeoutsSeconds[_attempts.clamp(0, _reconnectTimeoutsSeconds.length - 1)];
|
||||||
|
_logger.severe(e);
|
||||||
|
_logger.severe("Retrying in $timeout seconds");
|
||||||
|
await Future.delayed(Duration(seconds: timeout));
|
||||||
|
_attempts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleEvent(event) {
|
||||||
|
final bodies = event.toString().split(_eofChar);
|
||||||
|
final eventBodies = bodies.whereNot((element) => element.isEmpty).map((e) => jsonDecode(e));
|
||||||
|
for (final body in eventBodies) {
|
||||||
|
final int? rawType = body["type"];
|
||||||
|
if (rawType == null) {
|
||||||
|
_logger.warning("Received empty event, content was $event");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawType > EventType.values.length) {
|
||||||
|
_logger.info("Unhandled event type $rawType: $body");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (EventType.values[rawType]) {
|
||||||
|
case EventType.streamItem:
|
||||||
|
case EventType.completion:
|
||||||
|
final handler = _responseHandlers[body["invocationId"]];
|
||||||
|
handler?.call(body["result"] ?? {});
|
||||||
|
break;
|
||||||
|
case EventType.cancelInvocation:
|
||||||
|
case EventType.undefined:
|
||||||
|
_logger.info("Received unhandled event: $rawType: $body");
|
||||||
|
break;
|
||||||
|
case EventType.streamInvocation:
|
||||||
|
case EventType.invocation:
|
||||||
|
_logger.info("Received invocation-event.");
|
||||||
|
_handleInvocation(body);
|
||||||
|
break;
|
||||||
|
case EventType.ping:
|
||||||
|
_logger.info("Received keep-alive.");
|
||||||
|
break;
|
||||||
|
case EventType.close:
|
||||||
|
_logger.severe("Received close-event: ${body["error"]}");
|
||||||
|
// Should we trigger a manual reconnect here or just let the remote service close the connection?
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleInvocation(body) async {
|
||||||
|
final target = EventTarget.parse(body["target"]);
|
||||||
|
final args = body["arguments"] ?? [];
|
||||||
|
final handler = _handlers[target];
|
||||||
|
if (handler == null) {
|
||||||
|
_logger.info("Unhandled event received");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handler(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
void send(String target, {List arguments = const [], Function(Map data)? responseHandler}) {
|
||||||
|
final invocationId = const Uuid().v4();
|
||||||
|
final data = {
|
||||||
|
"type": EventType.invocation.index,
|
||||||
|
"invocationId": invocationId,
|
||||||
|
"target": target,
|
||||||
|
"arguments": arguments,
|
||||||
|
};
|
||||||
|
if (responseHandler != null) {
|
||||||
|
_responseHandlers[invocationId] = responseHandler;
|
||||||
|
}
|
||||||
|
if (_wsChannel == null) throw "Neos Hub is not connected";
|
||||||
|
_wsChannel!.add(jsonEncode(data) + _eofChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_wsChannel?.close();
|
||||||
|
}
|
||||||
|
}
|
|
@ -177,7 +177,6 @@ class SessionFilterSettings {
|
||||||
String buildRequestString() => "?includeEmptyHeadless=$includeEmptyHeadless"
|
String buildRequestString() => "?includeEmptyHeadless=$includeEmptyHeadless"
|
||||||
"${"&includeEnded=$includeEnded"}"
|
"${"&includeEnded=$includeEnded"}"
|
||||||
"${name.isNotEmpty ? "&name=$name" : ""}"
|
"${name.isNotEmpty ? "&name=$name" : ""}"
|
||||||
"${!includeIncompatible ? "&compatibilityHash=${Uri.encodeComponent(Config.latestCompatHash)}" : ""}"
|
|
||||||
"${hostName.isNotEmpty ? "&hostName=$hostName" : ""}"
|
"${hostName.isNotEmpty ? "&hostName=$hostName" : ""}"
|
||||||
"${minActiveUsers > 0 ? "&minActiveUsers=$minActiveUsers" : ""}";
|
"${minActiveUsers > 0 ? "&minActiveUsers=$minActiveUsers" : ""}";
|
||||||
|
|
||||||
|
|
|
@ -3,26 +3,27 @@ import 'package:contacts_plus_plus/models/users/user_profile.dart';
|
||||||
import 'package:contacts_plus_plus/models/users/friend_status.dart';
|
import 'package:contacts_plus_plus/models/users/friend_status.dart';
|
||||||
import 'package:contacts_plus_plus/models/users/online_status.dart';
|
import 'package:contacts_plus_plus/models/users/online_status.dart';
|
||||||
import 'package:contacts_plus_plus/models/users/user_status.dart';
|
import 'package:contacts_plus_plus/models/users/user_status.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class Friend implements Comparable {
|
class Friend implements Comparable {
|
||||||
static const _emptyId = "-1";
|
static const _emptyId = "-1";
|
||||||
static const _neosBotId = "U-Neos";
|
static const _neosBotId = "U-Resonite";
|
||||||
final String id;
|
final String id;
|
||||||
final String username;
|
final String username;
|
||||||
final String ownerId;
|
final String ownerId;
|
||||||
final UserStatus userStatus;
|
final UserStatus userStatus;
|
||||||
final UserProfile userProfile;
|
final UserProfile userProfile;
|
||||||
final FriendStatus friendStatus;
|
final FriendStatus contactStatus;
|
||||||
final DateTime latestMessageTime;
|
final DateTime latestMessageTime;
|
||||||
|
|
||||||
const Friend({required this.id, required this.username, required this.ownerId, required this.userStatus, required this.userProfile,
|
const Friend({required this.id, required this.username, required this.ownerId, required this.userStatus, required this.userProfile,
|
||||||
required this.friendStatus, required this.latestMessageTime,
|
required this.contactStatus, required this.latestMessageTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isHeadless => userStatus.activeSessions.any((session) => session.headlessHost == true && session.hostUserId == id);
|
bool get isHeadless => userStatus.activeSessions.any((session) => session.headlessHost == true && session.hostUserId == id);
|
||||||
|
|
||||||
factory Friend.fromMap(Map map) {
|
factory Friend.fromMap(Map map) {
|
||||||
final userStatus = map["userStatus"] == null ? UserStatus.empty() : UserStatus.fromMap(map["userStatus"]);
|
var userStatus = map["userStatus"] == null ? UserStatus.empty() : UserStatus.fromMap(map["userStatus"]);
|
||||||
return Friend(
|
return Friend(
|
||||||
id: map["id"],
|
id: map["id"],
|
||||||
username: map["contactUsername"],
|
username: map["contactUsername"],
|
||||||
|
@ -30,7 +31,7 @@ class Friend implements Comparable {
|
||||||
// Neos bot status is always offline but should be displayed as online
|
// Neos bot status is always offline but should be displayed as online
|
||||||
userStatus: map["id"] == _neosBotId ? userStatus.copyWith(onlineStatus: OnlineStatus.online) : userStatus,
|
userStatus: map["id"] == _neosBotId ? userStatus.copyWith(onlineStatus: OnlineStatus.online) : userStatus,
|
||||||
userProfile: UserProfile.fromMap(map["profile"] ?? {}),
|
userProfile: UserProfile.fromMap(map["profile"] ?? {}),
|
||||||
friendStatus: FriendStatus.fromString(map["contactStatus"]),
|
contactStatus: FriendStatus.fromString(map["contactStatus"]),
|
||||||
latestMessageTime: map["latestMessageTime"] == null
|
latestMessageTime: map["latestMessageTime"] == null
|
||||||
? DateTime.fromMillisecondsSinceEpoch(0) : DateTime.parse(map["latestMessageTime"]),
|
? DateTime.fromMillisecondsSinceEpoch(0) : DateTime.parse(map["latestMessageTime"]),
|
||||||
);
|
);
|
||||||
|
@ -48,7 +49,7 @@ class Friend implements Comparable {
|
||||||
ownerId: "",
|
ownerId: "",
|
||||||
userStatus: UserStatus.empty(),
|
userStatus: UserStatus.empty(),
|
||||||
userProfile: UserProfile.empty(),
|
userProfile: UserProfile.empty(),
|
||||||
friendStatus: FriendStatus.none,
|
contactStatus: FriendStatus.none,
|
||||||
latestMessageTime: DateTimeX.epoch
|
latestMessageTime: DateTimeX.epoch
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -64,7 +65,7 @@ class Friend implements Comparable {
|
||||||
ownerId: ownerId ?? this.ownerId,
|
ownerId: ownerId ?? this.ownerId,
|
||||||
userStatus: userStatus ?? this.userStatus,
|
userStatus: userStatus ?? this.userStatus,
|
||||||
userProfile: userProfile ?? this.userProfile,
|
userProfile: userProfile ?? this.userProfile,
|
||||||
friendStatus: friendStatus ?? this.friendStatus,
|
contactStatus: friendStatus ?? this.contactStatus,
|
||||||
latestMessageTime: latestMessageTime ?? this.latestMessageTime,
|
latestMessageTime: latestMessageTime ?? this.latestMessageTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -72,11 +73,11 @@ class Friend implements Comparable {
|
||||||
Map toMap({bool shallow=false}) {
|
Map toMap({bool shallow=false}) {
|
||||||
return {
|
return {
|
||||||
"id": id,
|
"id": id,
|
||||||
"username": username,
|
"contactUsername": username,
|
||||||
"ownerId": ownerId,
|
"ownerId": ownerId,
|
||||||
"userStatus": userStatus.toMap(shallow: shallow),
|
"userStatus": userStatus.toMap(shallow: shallow),
|
||||||
"profile": userProfile.toMap(),
|
"profile": userProfile.toMap(),
|
||||||
"friendStatus": friendStatus.name,
|
"contactStatus": contactStatus.name,
|
||||||
"latestMessageTime": latestMessageTime.toIso8601String(),
|
"latestMessageTime": latestMessageTime.toIso8601String(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ enum OnlineStatus {
|
||||||
|
|
||||||
factory OnlineStatus.fromString(String? text) {
|
factory OnlineStatus.fromString(String? text) {
|
||||||
return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
|
return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(),
|
||||||
orElse: () => OnlineStatus.offline,
|
orElse: () => OnlineStatus.online,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:contacts_plus_plus/config.dart';
|
||||||
import 'package:contacts_plus_plus/models/session.dart';
|
import 'package:contacts_plus_plus/models/session.dart';
|
||||||
import 'package:contacts_plus_plus/models/users/online_status.dart';
|
import 'package:contacts_plus_plus/models/users/online_status.dart';
|
||||||
|
|
||||||
|
@ -9,20 +10,26 @@ class UserStatus {
|
||||||
final bool currentHosting;
|
final bool currentHosting;
|
||||||
final Session currentSession;
|
final Session currentSession;
|
||||||
final List<Session> activeSessions;
|
final List<Session> activeSessions;
|
||||||
final String neosVersion;
|
final String appVersion;
|
||||||
final String outputDevice;
|
final String outputDevice;
|
||||||
final bool isMobile;
|
final bool isMobile;
|
||||||
final String compatibilityHash;
|
final String compatibilityHash;
|
||||||
|
|
||||||
const UserStatus(
|
const UserStatus({
|
||||||
{required this.onlineStatus, required this.lastStatusChange, required this.currentSession,
|
required this.onlineStatus,
|
||||||
required this.currentSessionAccessLevel, required this.currentSessionHidden, required this.currentHosting,
|
required this.lastStatusChange,
|
||||||
required this.activeSessions, required this.neosVersion, required this.outputDevice, required this.isMobile,
|
required this.currentSession,
|
||||||
required this.compatibilityHash,
|
required this.currentSessionAccessLevel,
|
||||||
});
|
required this.currentSessionHidden,
|
||||||
|
required this.currentHosting,
|
||||||
|
required this.activeSessions,
|
||||||
|
required this.appVersion,
|
||||||
|
required this.outputDevice,
|
||||||
|
required this.isMobile,
|
||||||
|
required this.compatibilityHash,
|
||||||
|
});
|
||||||
|
|
||||||
factory UserStatus.empty() =>
|
factory UserStatus.empty() => UserStatus(
|
||||||
UserStatus(
|
|
||||||
onlineStatus: OnlineStatus.offline,
|
onlineStatus: OnlineStatus.offline,
|
||||||
lastStatusChange: DateTime.now(),
|
lastStatusChange: DateTime.now(),
|
||||||
currentSessionAccessLevel: 0,
|
currentSessionAccessLevel: 0,
|
||||||
|
@ -30,14 +37,14 @@ class UserStatus {
|
||||||
currentHosting: false,
|
currentHosting: false,
|
||||||
currentSession: Session.none(),
|
currentSession: Session.none(),
|
||||||
activeSessions: [],
|
activeSessions: [],
|
||||||
neosVersion: "",
|
appVersion: "",
|
||||||
outputDevice: "Unknown",
|
outputDevice: "Unknown",
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
compatibilityHash: "",
|
compatibilityHash: "",
|
||||||
);
|
);
|
||||||
|
|
||||||
factory UserStatus.fromMap(Map map) {
|
factory UserStatus.fromMap(Map map) {
|
||||||
final statusString = map["onlineStatus"] as String?;
|
final statusString = map["onlineStatus"].toString();
|
||||||
final status = OnlineStatus.fromString(statusString);
|
final status = OnlineStatus.fromString(statusString);
|
||||||
return UserStatus(
|
return UserStatus(
|
||||||
onlineStatus: status,
|
onlineStatus: status,
|
||||||
|
@ -47,11 +54,10 @@ class UserStatus {
|
||||||
currentHosting: map["currentHosting"] ?? false,
|
currentHosting: map["currentHosting"] ?? false,
|
||||||
currentSession: Session.fromMap(map["currentSession"]),
|
currentSession: Session.fromMap(map["currentSession"]),
|
||||||
activeSessions: (map["activeSessions"] as List? ?? []).map((e) => Session.fromMap(e)).toList(),
|
activeSessions: (map["activeSessions"] as List? ?? []).map((e) => Session.fromMap(e)).toList(),
|
||||||
neosVersion: map["neosVersion"] ?? "",
|
appVersion: map["appVersion"] ?? "",
|
||||||
outputDevice: map["outputDevice"] ?? "Unknown",
|
outputDevice: map["outputDevice"] ?? "Unknown",
|
||||||
isMobile: map["isMobile"] ?? false,
|
isMobile: map["isMobile"] ?? false,
|
||||||
compatibilityHash: map["compatabilityHash"] ?? ""
|
compatibilityHash: map["compatabilityHash"] ?? "");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map toMap({bool shallow = false}) {
|
Map toMap({bool shallow = false}) {
|
||||||
|
@ -62,8 +68,14 @@ class UserStatus {
|
||||||
"currentSessionHidden": currentSessionHidden,
|
"currentSessionHidden": currentSessionHidden,
|
||||||
"currentHosting": currentHosting,
|
"currentHosting": currentHosting,
|
||||||
"currentSession": currentSession.isNone || shallow ? null : currentSession.toMap(),
|
"currentSession": currentSession.isNone || shallow ? null : currentSession.toMap(),
|
||||||
"activeSessions": shallow ? [] : activeSessions.map((e) => e.toMap(),).toList(),
|
"activeSessions": shallow
|
||||||
"neosVersion": neosVersion,
|
? []
|
||||||
|
: activeSessions
|
||||||
|
.map(
|
||||||
|
(e) => e.toMap(),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
"neosVersion": appVersion,
|
||||||
"outputDevice": outputDevice,
|
"outputDevice": outputDevice,
|
||||||
"isMobile": isMobile,
|
"isMobile": isMobile,
|
||||||
"compatibilityHash": compatibilityHash,
|
"compatibilityHash": compatibilityHash,
|
||||||
|
@ -91,9 +103,9 @@ class UserStatus {
|
||||||
currentHosting: currentHosting ?? this.currentHosting,
|
currentHosting: currentHosting ?? this.currentHosting,
|
||||||
currentSession: currentSession ?? this.currentSession,
|
currentSession: currentSession ?? this.currentSession,
|
||||||
activeSessions: activeSessions ?? this.activeSessions,
|
activeSessions: activeSessions ?? this.activeSessions,
|
||||||
neosVersion: neosVersion ?? this.neosVersion,
|
appVersion: neosVersion ?? this.appVersion,
|
||||||
outputDevice: outputDevice ?? this.outputDevice,
|
outputDevice: outputDevice ?? this.outputDevice,
|
||||||
isMobile: isMobile ?? this.isMobile,
|
isMobile: isMobile ?? this.isMobile,
|
||||||
compatibilityHash: compatibilityHash ?? this.compatibilityHash,
|
compatibilityHash: compatibilityHash ?? this.compatibilityHash,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ class FriendOnlineStatusIndicator extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return userStatus.neosVersion.contains("Contacts++") && userStatus.onlineStatus != OnlineStatus.offline
|
return userStatus.appVersion.contains("Contacts++") && userStatus.onlineStatus != OnlineStatus.offline
|
||||||
? SizedBox.square(
|
? SizedBox.square(
|
||||||
dimension: 10,
|
dimension: 10,
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
|
|
Loading…
Reference in a new issue