diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index f9c464f..d41083d 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:contacts_plus_plus/apis/session_api.dart'; +import 'package:contacts_plus_plus/crypto_helper.dart'; import 'package:contacts_plus_plus/hub_manager.dart'; -import 'package:contacts_plus_plus/models/users/online_status.dart'; +import 'package:contacts_plus_plus/models/session.dart'; import 'package:contacts_plus_plus/models/users/user_status.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -58,13 +60,14 @@ class MessagingClient extends ChangeNotifier { final Logger _logger = Logger("Messaging"); final NotificationClient _notificationClient; final HubManager _hubManager = HubManager(); + final Map _sessionMap = {}; Friend? selectedFriend; Timer? _notifyOnlineTimer; Timer? _autoRefresh; Timer? _unreadSafeguard; String? _initStatus; - UserStatus _userStatus = UserStatus.empty().copyWith(onlineStatus: OnlineStatus.online); + UserStatus _userStatus = UserStatus.initial(); UserStatus get userStatus => _userStatus; @@ -73,9 +76,14 @@ class MessagingClient extends ChangeNotifier { _notificationClient = notificationClient { debugPrint("mClient created: $hashCode"); Hive.openBox(_messageBoxKey).then((box) async { - box.delete(_lastUpdateKey); + await box.delete(_lastUpdateKey); + final activeSessions = await SessionApi.getSessions(apiClient); + for (final session in activeSessions) { + final idHash = CryptoHelper.idHash(session.id + _userStatus.hashSalt); + _sessionMap[idHash] = session; + } + _setupHub(); }); - _setupHub(); } @override @@ -105,6 +113,8 @@ class MessagingClient extends ChangeNotifier { MessageCache _createUserMessageCache(String userId) => MessageCache(apiClient: _apiClient, userId: userId); + Session? getSessionInfo(String idHash) => _sessionMap[idHash]; + Future refreshFriendsListWithErrorHandler() async { try { await refreshFriendsList(); @@ -147,17 +157,19 @@ class MessagingClient extends ChangeNotifier { _userStatus = status.copyWith( appVersion: "${pkginfo.version} of ${pkginfo.appName}", - isMobile: true, lastStatusChange: DateTime.now(), ); - _hubManager.send("BroadcastStatus", arguments: [ - _userStatus.toMap(), - { - "group": 0, - "targetIds": [], - } - ]); + _hubManager.send( + "BroadcastStatus", + arguments: [ + _userStatus.toMap(), + { + "group": 0, + "targetIds": [], + } + ], + ); final self = getAsFriend(_apiClient.userId); await _updateContact(self!.copyWith(userStatus: _userStatus)); @@ -272,6 +284,7 @@ class MessagingClient extends ChangeNotifier { _hubManager.setHandler(EventTarget.receiveSessionUpdate, _onReceiveSessionUpdate); await _hubManager.start(); + setUserStatus(userStatus); _hubManager.send( "InitializeStatus", responseHandler: (Map data) async { @@ -335,5 +348,12 @@ class MessagingClient extends ChangeNotifier { notifyListeners(); } - void _onReceiveSessionUpdate(List args) {} + void _onReceiveSessionUpdate(List args) { + for (final sessionUpdate in args) { + final session = Session.fromMap(sessionUpdate); + final idHash = CryptoHelper.idHash(session.id + _userStatus.hashSalt); + _sessionMap[idHash] = session; + } + notifyListeners(); + } } diff --git a/lib/crypto_helper.dart b/lib/crypto_helper.dart new file mode 100644 index 0000000..3bc9e15 --- /dev/null +++ b/lib/crypto_helper.dart @@ -0,0 +1,14 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +class CryptoHelper { + static final Random _random = Random.secure(); + + static List randomBytes(int length) => List.generate(length, (i) => _random.nextInt(256)); + + static String cryptoToken([int length = 128]) => base64UrlEncode(randomBytes(length)).replaceAll("/", "_"); + + static String idHash(String id) => sha256.convert(utf8.encode(id)).toString().replaceAll("-", "").toUpperCase(); +} diff --git a/lib/models/session.dart b/lib/models/session.dart index 61d9c03..ba112e9 100644 --- a/lib/models/session.dart +++ b/lib/models/session.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:contacts_plus_plus/string_formatter.dart'; +import 'package:crypto/crypto.dart'; class Session { final String id; @@ -96,15 +99,15 @@ class Session { enum SessionAccessLevel { unknown, private, - friends, - friendsOfFriends, + contacts, + contactsPlus, anyone; static const _readableNamesMap = { SessionAccessLevel.unknown: "Unknown", SessionAccessLevel.private: "Private", - SessionAccessLevel.friends: "Contacts", - SessionAccessLevel.friendsOfFriends: "Contacts+", + SessionAccessLevel.contacts: "Contacts", + SessionAccessLevel.contactsPlus: "Contacts+", SessionAccessLevel.anyone: "Anyone", }; diff --git a/lib/models/session_metadata.dart b/lib/models/session_metadata.dart new file mode 100644 index 0000000..ae95eb3 --- /dev/null +++ b/lib/models/session_metadata.dart @@ -0,0 +1,38 @@ +import 'package:contacts_plus_plus/models/session.dart'; +import 'package:intl/intl.dart'; + +class SessionMetadata { + final String sessionHash; + final SessionAccessLevel accessLevel; + final bool sessionHidden; + final bool? isHost; + final String? broadcastKey; + + SessionMetadata({ + required this.sessionHash, + required this.accessLevel, + required this.sessionHidden, + required this.isHost, + required this.broadcastKey, + }); + + factory SessionMetadata.fromMap(Map map) { + return SessionMetadata( + sessionHash: map["sessionHash"], + accessLevel: SessionAccessLevel.fromName(map["accessLevel"]), + sessionHidden: map["sessionHidden"], + isHost: map["ishost"], + broadcastKey: map["broadcastKey"], + ); + } + + Map toMap() { + return { + "sessionHash": sessionHash, + "accessLevel": toBeginningOfSentenceCase(accessLevel.name), + "sessionHidden": sessionHidden, + "isHost": isHost, + "broadcastKey": broadcastKey, + }; + } +} diff --git a/lib/models/users/user_status.dart b/lib/models/users/user_status.dart index 697480a..b482b01 100644 --- a/lib/models/users/user_status.dart +++ b/lib/models/users/user_status.dart @@ -1,5 +1,9 @@ -import 'package:contacts_plus_plus/models/session.dart'; +import 'dart:convert'; + +import 'package:contacts_plus_plus/crypto_helper.dart'; +import 'package:contacts_plus_plus/models/session_metadata.dart'; import 'package:contacts_plus_plus/models/users/online_status.dart'; +import 'package:crypto/crypto.dart'; class UserStatus { final OnlineStatus onlineStatus; @@ -7,56 +11,67 @@ class UserStatus { final int currentSessionAccessLevel; final bool currentSessionHidden; final bool currentHosting; - final Session currentSession; - final List activeSessions; + final int currentSessionIndex; + final List sessions; final String appVersion; final String outputDevice; final bool isMobile; final String compatibilityHash; + final String hashSalt; const UserStatus({ required this.onlineStatus, required this.lastStatusChange, - required this.currentSession, + required this.currentSessionIndex, required this.currentSessionAccessLevel, required this.currentSessionHidden, required this.currentHosting, - required this.activeSessions, + required this.sessions, required this.appVersion, required this.outputDevice, required this.isMobile, required this.compatibilityHash, + required this.hashSalt, }); + factory UserStatus.initial() => UserStatus.empty().copyWith( + onlineStatus: OnlineStatus.online, + hashSalt: CryptoHelper.cryptoToken(), + outputDevice: "Mobile", + ); + factory UserStatus.empty() => UserStatus( onlineStatus: OnlineStatus.offline, lastStatusChange: DateTime.now(), currentSessionAccessLevel: 0, currentSessionHidden: false, currentHosting: false, - currentSession: Session.none(), - activeSessions: [], + currentSessionIndex: -1, + sessions: [], appVersion: "", outputDevice: "Unknown", isMobile: false, compatibilityHash: "", + hashSalt: "", ); factory UserStatus.fromMap(Map map) { final statusString = map["onlineStatus"].toString(); final status = OnlineStatus.fromString(statusString); return UserStatus( - onlineStatus: status, - lastStatusChange: DateTime.parse(map["lastStatusChange"]), - currentSessionAccessLevel: map["currentSessionAccessLevel"] ?? 0, - currentSessionHidden: map["currentSessionHidden"] ?? false, - currentHosting: map["currentHosting"] ?? false, - currentSession: Session.fromMap(map["currentSession"]), - activeSessions: (map["activeSessions"] as List? ?? []).map((e) => Session.fromMap(e)).toList(), - appVersion: map["appVersion"] ?? "", - outputDevice: map["outputDevice"] ?? "Unknown", - isMobile: map["isMobile"] ?? false, - compatibilityHash: map["compatabilityHash"] ?? ""); + onlineStatus: status, + lastStatusChange: DateTime.parse(map["lastStatusChange"]), + currentSessionAccessLevel: map["currentSessionAccessLevel"] ?? 0, + currentSessionHidden: map["currentSessionHidden"] ?? false, + currentHosting: map["currentHosting"] ?? false, + currentSessionIndex: map["currentSessionIndex"] ?? -1, + sessions: (map["sessions"] as List? ?? []).map((e) => SessionMetadata.fromMap(e)).toList(), + appVersion: map["appVersion"] ?? "", + outputDevice: map["outputDevice"] ?? "Unknown", + isMobile: map["isMobile"] ?? false, + compatibilityHash: map["compatabilityHash"] ?? "", + hashSalt: map["hashSalt"] ?? "", + ); } Map toMap({bool shallow = false}) { @@ -66,10 +81,10 @@ class UserStatus { "currentSessionAccessLevel": currentSessionAccessLevel, "currentSessionHidden": currentSessionHidden, "currentHosting": currentHosting, - "currentSession": currentSession.isNone || shallow ? null : currentSession.toMap(), - "activeSessions": shallow + "currentSessionIndex": currentSessionIndex, + "sessions": shallow ? [] - : activeSessions + : sessions .map( (e) => e.toMap(), ) @@ -87,12 +102,13 @@ class UserStatus { int? currentSessionAccessLevel, bool? currentSessionHidden, bool? currentHosting, - Session? currentSession, - List? activeSessions, + int? currentSessionIndex, + List? sessions, String? appVersion, String? outputDevice, bool? isMobile, String? compatibilityHash, + String? hashSalt, }) => UserStatus( onlineStatus: onlineStatus ?? this.onlineStatus, @@ -100,11 +116,12 @@ class UserStatus { currentSessionAccessLevel: currentSessionAccessLevel ?? this.currentSessionAccessLevel, currentSessionHidden: currentSessionHidden ?? this.currentSessionHidden, currentHosting: currentHosting ?? this.currentHosting, - currentSession: currentSession ?? this.currentSession, - activeSessions: activeSessions ?? this.activeSessions, + currentSessionIndex: currentSessionIndex ?? this.currentSessionIndex, + sessions: sessions ?? this.sessions, appVersion: appVersion ?? this.appVersion, outputDevice: outputDevice ?? this.outputDevice, isMobile: isMobile ?? this.isMobile, compatibilityHash: compatibilityHash ?? this.compatibilityHash, + hashSalt: hashSalt ?? this.hashSalt, ); } diff --git a/lib/widgets/friends/friend_list_tile.dart b/lib/widgets/friends/friend_list_tile.dart index c750849..cdef4cb 100644 --- a/lib/widgets/friends/friend_list_tile.dart +++ b/lib/widgets/friends/friend_list_tile.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/models/users/friend.dart'; @@ -21,6 +23,9 @@ class FriendListTile extends StatelessWidget { Widget build(BuildContext context) { final imageUri = Aux.resdbToHttp(friend.userProfile.iconUrl); final theme = Theme.of(context); + final mClient = Provider.of(context, listen: false); + final currentSessionMetadata = friend.userStatus.sessions.elementAtOrNull(max(0, friend.userStatus.currentSessionIndex)); + final currentSession = mClient.getSessionInfo(currentSessionMetadata?.sessionHash ?? ""); return ListTile( leading: GenericAvatar( imageUri: imageUri, @@ -54,11 +59,11 @@ class FriendListTile extends StatelessWidget { width: 4, ), Text(toBeginningOfSentenceCase(friend.userStatus.onlineStatus.name) ?? "Unknown"), - if (!friend.userStatus.currentSession.isNone) ...[ + if (currentSession != null) ...[ const Text(" in "), Expanded( child: FormattedText( - friend.userStatus.currentSession.formattedName, + currentSession.formattedName, overflow: TextOverflow.ellipsis, maxLines: 1, )) @@ -67,7 +72,6 @@ class FriendListTile extends StatelessWidget { ), onTap: () async { onTap?.call(); - final mClient = Provider.of(context, listen: false); mClient.loadUserMessageCache(friend.id); final unreads = mClient.getUnreadsForFriend(friend); if (unreads.isNotEmpty) { diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 24df0af..2c5aa5d 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -6,7 +6,6 @@ import 'package:contacts_plus_plus/widgets/friends/friend_online_status_indicato import 'package:contacts_plus_plus/widgets/messages/message_input_bar.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'message_bubble.dart'; @@ -57,7 +56,7 @@ class _MessagesListState extends State with SingleTickerProviderSt return Consumer(builder: (context, mClient, _) { final friend = mClient.selectedFriend ?? Friend.empty(); final cache = mClient.getUserMessageCache(friend.id); - final sessions = friend.userStatus.activeSessions; + final sessions = friend.userStatus.sessions; return Scaffold( appBar: AppBar( title: Row( @@ -121,7 +120,12 @@ class _MessagesListState extends State with SingleTickerProviderSt controller: _sessionListScrollController, scrollDirection: Axis.horizontal, itemCount: sessions.length, - itemBuilder: (context, index) => SessionTile(session: sessions[index]), + itemBuilder: (context, index) { + final currentSessionMetadata = sessions[index]; + final currentSession = mClient.getSessionInfo(currentSessionMetadata.sessionHash); + if (currentSession == null) return null; + return SessionTile(session: currentSession); + }, ), AnimatedOpacity( opacity: _shevronOpacity, diff --git a/lib/widgets/messages/messages_session_header.dart b/lib/widgets/messages/messages_session_header.dart index 738a937..8eb1148 100644 --- a/lib/widgets/messages/messages_session_header.dart +++ b/lib/widgets/messages/messages_session_header.dart @@ -6,128 +6,6 @@ import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; import 'package:contacts_plus_plus/widgets/sessions/session_view.dart'; import 'package:flutter/material.dart'; -class SessionPopup extends StatelessWidget { - const SessionPopup({required this.session, super.key}); - - final Session session; - - @override - Widget build(BuildContext context) { - final ScrollController userListScrollController = ScrollController(); - final thumbnailUri = Aux.resdbToHttp(session.thumbnailUrl); - return Dialog( - insetPadding: const EdgeInsets.all(32), - child: Container( - constraints: const BoxConstraints(maxHeight: 400), - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: ListView( - children: [ - FormattedText(session.formattedName, style: Theme.of(context).textTheme.titleMedium), - session.formattedDescription.isEmpty - ? const Text("No description") - : FormattedText(session.formattedDescription, - style: Theme.of(context).textTheme.labelMedium), - Text( - "Tags: ${session.tags.isEmpty ? "None" : session.tags.join(", ")}", - style: Theme.of(context).textTheme.labelMedium, - softWrap: true, - ), - Text("Access: ${session.accessLevel.toReadableString()}", - style: Theme.of(context).textTheme.labelMedium), - Text("Users: ${session.sessionUsers.length}", style: Theme.of(context).textTheme.labelMedium), - Text("Maximum users: ${session.maxUsers}", style: Theme.of(context).textTheme.labelMedium), - Text("Headless: ${session.headlessHost ? "Yes" : "No"}", - style: Theme.of(context).textTheme.labelMedium), - ], - ), - ), - if (session.sessionUsers.isNotEmpty) - Expanded( - child: Scrollbar( - trackVisibility: true, - controller: userListScrollController, - thumbVisibility: true, - child: ListView.builder( - controller: userListScrollController, - shrinkWrap: true, - itemCount: session.sessionUsers.length, - itemBuilder: (context, index) { - final user = session.sessionUsers[index]; - return ListTile( - dense: true, - title: Text( - user.username, - textAlign: TextAlign.end, - ), - subtitle: Text( - user.isPresent ? "Active" : "Inactive", - textAlign: TextAlign.end, - ), - ); - }, - ), - ), - ) - else - const Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.person_remove_alt_1_rounded), - Padding( - padding: EdgeInsets.all(16.0), - child: Text( - "No one is currently playing.", - textAlign: TextAlign.center, - ), - ) - ], - ), - ), - ), - ], - ), - ), - Expanded( - child: Center( - child: CachedNetworkImage( - imageUrl: thumbnailUri, - placeholder: (context, url) { - return const CircularProgressIndicator(); - }, - errorWidget: (context, error, what) => const Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.no_photography), - Padding( - padding: EdgeInsets.all(16.0), - child: Text("Failed to load Image"), - ) - ], - ), - ), - ), - ) - ], - ), - ), - ); - } -} - class SessionTile extends StatelessWidget { const SessionTile({required this.session, super.key});