Add preliminary hash based session handling

This commit is contained in:
Nutcake 2023-10-01 21:14:38 +02:00
parent c1da0da897
commit 56ed403d79
8 changed files with 148 additions and 170 deletions

View file

@ -1,6 +1,8 @@
import 'dart:async'; 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/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: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';
@ -58,13 +60,14 @@ class MessagingClient extends ChangeNotifier {
final Logger _logger = Logger("Messaging"); final Logger _logger = Logger("Messaging");
final NotificationClient _notificationClient; final NotificationClient _notificationClient;
final HubManager _hubManager = HubManager(); final HubManager _hubManager = HubManager();
final Map<String, Session> _sessionMap = {};
Friend? selectedFriend; Friend? selectedFriend;
Timer? _notifyOnlineTimer; Timer? _notifyOnlineTimer;
Timer? _autoRefresh; Timer? _autoRefresh;
Timer? _unreadSafeguard; Timer? _unreadSafeguard;
String? _initStatus; String? _initStatus;
UserStatus _userStatus = UserStatus.empty().copyWith(onlineStatus: OnlineStatus.online); UserStatus _userStatus = UserStatus.initial();
UserStatus get userStatus => _userStatus; UserStatus get userStatus => _userStatus;
@ -73,9 +76,14 @@ class MessagingClient extends ChangeNotifier {
_notificationClient = notificationClient { _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); 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 @override
@ -105,6 +113,8 @@ class MessagingClient extends ChangeNotifier {
MessageCache _createUserMessageCache(String userId) => MessageCache(apiClient: _apiClient, userId: userId); MessageCache _createUserMessageCache(String userId) => MessageCache(apiClient: _apiClient, userId: userId);
Session? getSessionInfo(String idHash) => _sessionMap[idHash];
Future<void> refreshFriendsListWithErrorHandler() async { Future<void> refreshFriendsListWithErrorHandler() async {
try { try {
await refreshFriendsList(); await refreshFriendsList();
@ -147,17 +157,19 @@ class MessagingClient extends ChangeNotifier {
_userStatus = status.copyWith( _userStatus = status.copyWith(
appVersion: "${pkginfo.version} of ${pkginfo.appName}", appVersion: "${pkginfo.version} of ${pkginfo.appName}",
isMobile: true,
lastStatusChange: DateTime.now(), lastStatusChange: DateTime.now(),
); );
_hubManager.send("BroadcastStatus", arguments: [ _hubManager.send(
_userStatus.toMap(), "BroadcastStatus",
{ arguments: [
"group": 0, _userStatus.toMap(),
"targetIds": [], {
} "group": 0,
]); "targetIds": [],
}
],
);
final self = getAsFriend(_apiClient.userId); final self = getAsFriend(_apiClient.userId);
await _updateContact(self!.copyWith(userStatus: _userStatus)); await _updateContact(self!.copyWith(userStatus: _userStatus));
@ -272,6 +284,7 @@ class MessagingClient extends ChangeNotifier {
_hubManager.setHandler(EventTarget.receiveSessionUpdate, _onReceiveSessionUpdate); _hubManager.setHandler(EventTarget.receiveSessionUpdate, _onReceiveSessionUpdate);
await _hubManager.start(); await _hubManager.start();
setUserStatus(userStatus);
_hubManager.send( _hubManager.send(
"InitializeStatus", "InitializeStatus",
responseHandler: (Map data) async { responseHandler: (Map data) async {
@ -335,5 +348,12 @@ class MessagingClient extends ChangeNotifier {
notifyListeners(); 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();
}
} }

14
lib/crypto_helper.dart Normal file
View file

@ -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<int> randomBytes(int length) => List<int>.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();
}

View file

@ -1,4 +1,7 @@
import 'dart:convert';
import 'package:contacts_plus_plus/string_formatter.dart'; import 'package:contacts_plus_plus/string_formatter.dart';
import 'package:crypto/crypto.dart';
class Session { class Session {
final String id; final String id;
@ -96,15 +99,15 @@ class Session {
enum SessionAccessLevel { enum SessionAccessLevel {
unknown, unknown,
private, private,
friends, contacts,
friendsOfFriends, contactsPlus,
anyone; anyone;
static const _readableNamesMap = { static const _readableNamesMap = {
SessionAccessLevel.unknown: "Unknown", SessionAccessLevel.unknown: "Unknown",
SessionAccessLevel.private: "Private", SessionAccessLevel.private: "Private",
SessionAccessLevel.friends: "Contacts", SessionAccessLevel.contacts: "Contacts",
SessionAccessLevel.friendsOfFriends: "Contacts+", SessionAccessLevel.contactsPlus: "Contacts+",
SessionAccessLevel.anyone: "Anyone", SessionAccessLevel.anyone: "Anyone",
}; };

View file

@ -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,
};
}
}

View file

@ -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:contacts_plus_plus/models/users/online_status.dart';
import 'package:crypto/crypto.dart';
class UserStatus { class UserStatus {
final OnlineStatus onlineStatus; final OnlineStatus onlineStatus;
@ -7,56 +11,67 @@ class UserStatus {
final int currentSessionAccessLevel; final int currentSessionAccessLevel;
final bool currentSessionHidden; final bool currentSessionHidden;
final bool currentHosting; final bool currentHosting;
final Session currentSession; final int currentSessionIndex;
final List<Session> activeSessions; final List<SessionMetadata> sessions;
final String appVersion; final String appVersion;
final String outputDevice; final String outputDevice;
final bool isMobile; final bool isMobile;
final String compatibilityHash; final String compatibilityHash;
final String hashSalt;
const UserStatus({ const UserStatus({
required this.onlineStatus, required this.onlineStatus,
required this.lastStatusChange, required this.lastStatusChange,
required this.currentSession, required this.currentSessionIndex,
required this.currentSessionAccessLevel, required this.currentSessionAccessLevel,
required this.currentSessionHidden, required this.currentSessionHidden,
required this.currentHosting, required this.currentHosting,
required this.activeSessions, required this.sessions,
required this.appVersion, required this.appVersion,
required this.outputDevice, required this.outputDevice,
required this.isMobile, required this.isMobile,
required this.compatibilityHash, required this.compatibilityHash,
required this.hashSalt,
}); });
factory UserStatus.initial() => UserStatus.empty().copyWith(
onlineStatus: OnlineStatus.online,
hashSalt: CryptoHelper.cryptoToken(),
outputDevice: "Mobile",
);
factory UserStatus.empty() => UserStatus( factory UserStatus.empty() => UserStatus(
onlineStatus: OnlineStatus.offline, onlineStatus: OnlineStatus.offline,
lastStatusChange: DateTime.now(), lastStatusChange: DateTime.now(),
currentSessionAccessLevel: 0, currentSessionAccessLevel: 0,
currentSessionHidden: false, currentSessionHidden: false,
currentHosting: false, currentHosting: false,
currentSession: Session.none(), currentSessionIndex: -1,
activeSessions: [], sessions: [],
appVersion: "", appVersion: "",
outputDevice: "Unknown", outputDevice: "Unknown",
isMobile: false, isMobile: false,
compatibilityHash: "", compatibilityHash: "",
hashSalt: "",
); );
factory UserStatus.fromMap(Map map) { factory UserStatus.fromMap(Map map) {
final statusString = map["onlineStatus"].toString(); final statusString = map["onlineStatus"].toString();
final status = OnlineStatus.fromString(statusString); final status = OnlineStatus.fromString(statusString);
return UserStatus( return UserStatus(
onlineStatus: status, onlineStatus: status,
lastStatusChange: DateTime.parse(map["lastStatusChange"]), lastStatusChange: DateTime.parse(map["lastStatusChange"]),
currentSessionAccessLevel: map["currentSessionAccessLevel"] ?? 0, currentSessionAccessLevel: map["currentSessionAccessLevel"] ?? 0,
currentSessionHidden: map["currentSessionHidden"] ?? false, currentSessionHidden: map["currentSessionHidden"] ?? false,
currentHosting: map["currentHosting"] ?? false, currentHosting: map["currentHosting"] ?? false,
currentSession: Session.fromMap(map["currentSession"]), currentSessionIndex: map["currentSessionIndex"] ?? -1,
activeSessions: (map["activeSessions"] as List? ?? []).map((e) => Session.fromMap(e)).toList(), sessions: (map["sessions"] as List? ?? []).map((e) => SessionMetadata.fromMap(e)).toList(),
appVersion: map["appVersion"] ?? "", 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"] ?? "",
hashSalt: map["hashSalt"] ?? "",
);
} }
Map toMap({bool shallow = false}) { Map toMap({bool shallow = false}) {
@ -66,10 +81,10 @@ class UserStatus {
"currentSessionAccessLevel": currentSessionAccessLevel, "currentSessionAccessLevel": currentSessionAccessLevel,
"currentSessionHidden": currentSessionHidden, "currentSessionHidden": currentSessionHidden,
"currentHosting": currentHosting, "currentHosting": currentHosting,
"currentSession": currentSession.isNone || shallow ? null : currentSession.toMap(), "currentSessionIndex": currentSessionIndex,
"activeSessions": shallow "sessions": shallow
? [] ? []
: activeSessions : sessions
.map( .map(
(e) => e.toMap(), (e) => e.toMap(),
) )
@ -87,12 +102,13 @@ class UserStatus {
int? currentSessionAccessLevel, int? currentSessionAccessLevel,
bool? currentSessionHidden, bool? currentSessionHidden,
bool? currentHosting, bool? currentHosting,
Session? currentSession, int? currentSessionIndex,
List<Session>? activeSessions, List<SessionMetadata>? sessions,
String? appVersion, String? appVersion,
String? outputDevice, String? outputDevice,
bool? isMobile, bool? isMobile,
String? compatibilityHash, String? compatibilityHash,
String? hashSalt,
}) => }) =>
UserStatus( UserStatus(
onlineStatus: onlineStatus ?? this.onlineStatus, onlineStatus: onlineStatus ?? this.onlineStatus,
@ -100,11 +116,12 @@ class UserStatus {
currentSessionAccessLevel: currentSessionAccessLevel ?? this.currentSessionAccessLevel, currentSessionAccessLevel: currentSessionAccessLevel ?? this.currentSessionAccessLevel,
currentSessionHidden: currentSessionHidden ?? this.currentSessionHidden, currentSessionHidden: currentSessionHidden ?? this.currentSessionHidden,
currentHosting: currentHosting ?? this.currentHosting, currentHosting: currentHosting ?? this.currentHosting,
currentSession: currentSession ?? this.currentSession, currentSessionIndex: currentSessionIndex ?? this.currentSessionIndex,
activeSessions: activeSessions ?? this.activeSessions, sessions: sessions ?? this.sessions,
appVersion: appVersion ?? this.appVersion, appVersion: appVersion ?? 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,
hashSalt: hashSalt ?? this.hashSalt,
); );
} }

View file

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/auxiliary.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/users/friend.dart'; import 'package:contacts_plus_plus/models/users/friend.dart';
@ -21,6 +23,9 @@ class FriendListTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final imageUri = Aux.resdbToHttp(friend.userProfile.iconUrl); final imageUri = Aux.resdbToHttp(friend.userProfile.iconUrl);
final theme = Theme.of(context); final theme = Theme.of(context);
final mClient = Provider.of<MessagingClient>(context, listen: false);
final currentSessionMetadata = friend.userStatus.sessions.elementAtOrNull(max(0, friend.userStatus.currentSessionIndex));
final currentSession = mClient.getSessionInfo(currentSessionMetadata?.sessionHash ?? "");
return ListTile( return ListTile(
leading: GenericAvatar( leading: GenericAvatar(
imageUri: imageUri, imageUri: imageUri,
@ -54,11 +59,11 @@ class FriendListTile extends StatelessWidget {
width: 4, width: 4,
), ),
Text(toBeginningOfSentenceCase(friend.userStatus.onlineStatus.name) ?? "Unknown"), Text(toBeginningOfSentenceCase(friend.userStatus.onlineStatus.name) ?? "Unknown"),
if (!friend.userStatus.currentSession.isNone) ...[ if (currentSession != null) ...[
const Text(" in "), const Text(" in "),
Expanded( Expanded(
child: FormattedText( child: FormattedText(
friend.userStatus.currentSession.formattedName, currentSession.formattedName,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1, maxLines: 1,
)) ))
@ -67,7 +72,6 @@ class FriendListTile extends StatelessWidget {
), ),
onTap: () async { onTap: () async {
onTap?.call(); onTap?.call();
final mClient = Provider.of<MessagingClient>(context, listen: false);
mClient.loadUserMessageCache(friend.id); mClient.loadUserMessageCache(friend.id);
final unreads = mClient.getUnreadsForFriend(friend); final unreads = mClient.getUnreadsForFriend(friend);
if (unreads.isNotEmpty) { if (unreads.isNotEmpty) {

View file

@ -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/message_input_bar.dart';
import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'message_bubble.dart'; import 'message_bubble.dart';
@ -57,7 +56,7 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
return Consumer<MessagingClient>(builder: (context, mClient, _) { return Consumer<MessagingClient>(builder: (context, mClient, _) {
final friend = mClient.selectedFriend ?? Friend.empty(); final friend = mClient.selectedFriend ?? Friend.empty();
final cache = mClient.getUserMessageCache(friend.id); final cache = mClient.getUserMessageCache(friend.id);
final sessions = friend.userStatus.activeSessions; final sessions = friend.userStatus.sessions;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
@ -121,7 +120,12 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
controller: _sessionListScrollController, controller: _sessionListScrollController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: sessions.length, 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( AnimatedOpacity(
opacity: _shevronOpacity, opacity: _shevronOpacity,

View file

@ -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:contacts_plus_plus/widgets/sessions/session_view.dart';
import 'package:flutter/material.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 { class SessionTile extends StatelessWidget {
const SessionTile({required this.session, super.key}); const SessionTile({required this.session, super.key});