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 '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<String, Session> _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();
});
}
@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<void> 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: [
_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();
}
}

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: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",
};

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:crypto/crypto.dart';
class UserStatus {
final OnlineStatus onlineStatus;
@ -7,39 +11,48 @@ class UserStatus {
final int currentSessionAccessLevel;
final bool currentSessionHidden;
final bool currentHosting;
final Session currentSession;
final List<Session> activeSessions;
final int currentSessionIndex;
final List<SessionMetadata> 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) {
@ -51,12 +64,14 @@ class UserStatus {
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(),
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"] ?? "");
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<Session>? activeSessions,
int? currentSessionIndex,
List<SessionMetadata>? 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,
);
}

View file

@ -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<MessagingClient>(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<MessagingClient>(context, listen: false);
mClient.loadUserMessageCache(friend.id);
final unreads = mClient.getUnreadsForFriend(friend);
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/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<MessagesList> with SingleTickerProviderSt
return Consumer<MessagingClient>(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<MessagesList> 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,

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: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});