import 'dart:convert'; import 'dart:math'; import 'package:cwtch/config.dart'; import 'package:cwtch/models/remoteserver.dart'; import 'package:cwtch/models/search.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../main.dart'; import '../themes/opaque.dart'; import '../views/contactsview.dart'; import 'contact.dart'; import 'contactlist.dart'; import 'filedownloadprogress.dart'; import 'profileservers.dart'; class ProfileInfoState extends ChangeNotifier { ProfileServerListState _servers = ProfileServerListState(); ContactListState _contacts = ContactListState(); final String onion; String _nickname = ""; String _imagePath = ""; String _defaultImagePath = ""; int _unreadMessages = 0; bool _online = false; Map _downloads = Map(); Map _downloadTriggers = Map(); ItemScrollController contactListScrollController = new ItemScrollController(); // assume profiles are encrypted...this will be set to false // in the constructor if the profile is encrypted with the defacto password. bool _encrypted = true; bool _autostart = true; bool _enabled = false; bool _appearOffline = false; bool _appearOfflineAtStartup = false; ProfileInfoState({ required this.onion, nickname = "", imagePath = "", defaultImagePath = "", unreadMessages = 0, contactsJson = "", serversJson = "", online = false, autostart = true, encrypted = true, appearOffline = false, String, }) { this._nickname = nickname; this._imagePath = imagePath; this._defaultImagePath = defaultImagePath; this._unreadMessages = unreadMessages; this._online = online; this._enabled = _enabled; this._autostart = autostart; if (autostart) { this._enabled = true; } this._appearOffline = appearOffline; this._appearOfflineAtStartup = appearOffline; this._encrypted = encrypted; _contacts.connectServers(this._servers); if (contactsJson != null && contactsJson != "" && contactsJson != "null") { this.replaceServers(serversJson); List contacts = jsonDecode(contactsJson); this._contacts.addAll(contacts.map((contact) { this._unreadMessages += contact["numUnread"] as int; return ContactInfoState(this.onion, contact["identifier"], contact["onion"], nickname: contact["name"], localNickname: contact["attributes"]?["local.profile.name"] ?? "", // contact may not have a local name status: contact["status"], imagePath: contact["picture"], defaultImagePath: contact["isGroup"] ? contact["picture"] : contact["defaultPicture"], accepted: contact["accepted"], blocked: contact["blocked"], savePeerHistory: contact["saveConversationHistory"], numMessages: contact["numMessages"], numUnread: contact["numUnread"], isGroup: contact["isGroup"], server: contact["groupServer"], archived: contact["isArchived"] == true, lastMessageTime: DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])), pinned: contact["attributes"]?["local.profile.pinned"] == "true", notificationPolicy: contact["notificationPolicy"] ?? "ConversationNotificationPolicy.Default"); })); // dummy set to invoke sort-on-load if (this._contacts.num > 0) { this._contacts.updateLastMessageReceivedTime(this._contacts.contacts.first.identifier, this._contacts.contacts.first.lastMessageReceivedTime); } } } // Code for managing the state of the profile-wide search feature... String activeSearchID = ""; List activeSearchResults = List.empty(growable: true); void newSearch(String activeSearchID) { this.activeSearchID = activeSearchID; this.activeSearchResults.clear(); notifyListeners(); } void handleSearchResult(String searchID, int conversationIdentifier, int messageIndex) { if (searchID == activeSearchID) { activeSearchResults.add(SearchResult(searchID: searchID, conversationIdentifier: conversationIdentifier, messageIndex: messageIndex)); notifyListeners(); } } // Parse out the server list json into our server info state struct... void replaceServers(String serversJson) { if (serversJson != "" && serversJson != "null") { List servers = jsonDecode(serversJson); this._servers.replace(servers.map((server) { // TODO Keys... var preSyncStartTime = DateTime.tryParse(server["syncProgress"]["startTime"]); var lastMessageTime = DateTime.tryParse(server["syncProgress"]["lastMessageTime"]); return RemoteServerInfoState(server["onion"], server["identifier"], server["description"], server["status"], lastPreSyncMessageTime: preSyncStartTime, mostRecentMessageTime: lastMessageTime); })); this._contacts.contacts.forEach((contact) { if (contact.isGroup) { _servers.addGroup(contact); } }); notifyListeners(); } } // void updateServerStatusCache(String server, String status) { this._servers.updateServerState(server, status); notifyListeners(); } // Getters and Setters for Online Status bool get isOnline => this._online; set isOnline(bool newValue) { this._online = newValue; notifyListeners(); } // Check encrypted status for profile info screen bool get isEncrypted => this._encrypted; set isEncrypted(bool newValue) { this._encrypted = newValue; notifyListeners(); } String get nickname => this._nickname; set nickname(String newValue) { this._nickname = newValue; notifyListeners(); } String get imagePath => this._imagePath; set imagePath(String newVal) { this._imagePath = newVal; notifyListeners(); } bool get enabled => this._enabled; set enabled(bool newVal) { this._enabled = newVal; notifyListeners(); } bool get autostart => this._autostart; set autostart(bool newVal) { this._autostart = newVal; notifyListeners(); } bool get appearOfflineAtStartup => this._appearOfflineAtStartup; set appearOfflineAtStartup(bool newVal) { this._appearOfflineAtStartup = newVal; notifyListeners(); } bool get appearOffline => this._appearOffline; set appearOffline(bool newVal) { this._appearOffline = newVal; notifyListeners(); } String get defaultImagePath => this._defaultImagePath; set defaultImagePath(String newVal) { this._defaultImagePath = newVal; notifyListeners(); } int get unreadMessages => this._unreadMessages; set unreadMessages(int newVal) { this._unreadMessages = newVal; notifyListeners(); } void recountUnread() { this._unreadMessages = _contacts.contacts.fold(0, (i, c) => i + c.unreadMessages); } // Remove a contact from a list. Currently only used when rejecting a group invitation. // Eventually will also be used for other removals. void removeContact(String handle) { this.contactList.removeContactByHandle(handle); notifyListeners(); } ContactListState get contactList => this._contacts; ProfileServerListState get serverList => this._servers; @override void dispose() { super.dispose(); } void updateFrom(String onion, String name, String picture, String contactsJson, String serverJson, bool online) { this._nickname = name; this._imagePath = picture; this._online = online; this._unreadMessages = 0; this.replaceServers(serverJson); if (contactsJson != "" && contactsJson != "null") { List contacts = jsonDecode(contactsJson); contacts.forEach((contact) { var profileContact = this._contacts.getContact(contact["identifier"]); this._unreadMessages += contact["numUnread"] as int; if (profileContact != null) { profileContact.status = contact["status"]; var newCount = contact["numMessages"] as int; if (newCount != profileContact.totalMessages) { if (newCount < profileContact.totalMessages) { // on Android, when sharing a file the UI may be briefly unloaded for the // OS to display the file management/selection screen. Afterwards a // call to ReconnectCwtchForeground will be made which will refresh all values (including count of numMessages) // **at the same time** the foreground will increment .totalMessages and send a new message to the backend. // This will result in a negative number of messages being calculated here, and an incorrect totalMessage count. // This bug is exacerbated in debug mode, and when multiple files are sent in succession. Both cases result in multiple ReconnectCwtchForeground // events that have the potential to conflict with currentMessageCounts. // Note that *if* a new message came in at the same time, we would be unable to distinguish this case - as such this is specific instance of a more general problem // TODO: A true-fix to this bug is to implement a syncing step in the foreground where totalMessages and inFlightMessages can be distinguished // This requires a change to the backend to confirm submission of an inFlightMessage, which will be implemented in #664 EnvironmentConfig.debugLog("Conflicting message counts: $newCount ${profileContact.totalMessages}"); newCount = max(newCount, profileContact.totalMessages); } profileContact.messageCache.addFrontIndexGap(newCount - profileContact.totalMessages); } profileContact.totalMessages = newCount; profileContact.unreadMessages = contact["numUnread"]; profileContact.lastMessageReceivedTime = DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])); } else { this._contacts.add(ContactInfoState( this.onion, contact["identifier"], contact["onion"], nickname: contact["name"], defaultImagePath: contact["defaultPicture"], status: contact["status"], imagePath: contact["picture"], accepted: contact["accepted"], blocked: contact["blocked"], savePeerHistory: contact["saveConversationHistory"], numMessages: contact["numMessages"], numUnread: contact["numUnread"], isGroup: contact["isGroup"], server: contact["groupServer"], lastMessageTime: DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])), notificationPolicy: contact["notificationPolicy"] ?? "ConversationNotificationPolicy.Default", )); } }); } this._contacts.resort(); } void newMessage( int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String contenthash, bool selectedProfile, bool selectedConversation) { if (!selectedProfile) { unreadMessages++; notifyListeners(); } contactList.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation); } void downloadInit(String fileKey, int numChunks) { this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now()); notifyListeners(); } void downloadUpdate(String fileKey, int progress, int numChunks) { if (!downloadActive(fileKey)) { this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now()); if (progress < 0) { this._downloads[fileKey]!.interrupted = true; } } else { if (this._downloads[fileKey]!.interrupted) { this._downloads[fileKey]!.interrupted = false; } this._downloads[fileKey]!.chunksDownloaded = progress; this._downloads[fileKey]!.chunksTotal = numChunks; this._downloads[fileKey]!.markUpdate(); } notifyListeners(); } void downloadMarkManifest(String fileKey) { if (!downloadActive(fileKey)) { this._downloads[fileKey] = FileDownloadProgress(1, DateTime.now()); } this._downloads[fileKey]!.gotManifest = true; this._downloads[fileKey]!.markUpdate(); notifyListeners(); } void downloadMarkFinished(String fileKey, String finalPath) { if (!downloadActive(fileKey)) { // happens as a result of a CheckDownloadStatus call, // invoked from a historical (timeline) download message // so setting numChunks correctly shouldn't matter this.downloadInit(fileKey, 1); } // Update the contact with a custom profile image if we are // waiting for one... if (this._downloadTriggers.containsKey(fileKey)) { int identifier = this._downloadTriggers[fileKey]!; this.contactList.getContact(identifier)!.imagePath = finalPath; notifyListeners(); } // only update if different if (!this._downloads[fileKey]!.complete) { this._downloads[fileKey]!.timeEnd = DateTime.now(); this._downloads[fileKey]!.downloadedTo = finalPath; this._downloads[fileKey]!.complete = true; this._downloads[fileKey]!.markUpdate(); notifyListeners(); } } bool downloadKnown(String fileKey) { return this._downloads.containsKey(fileKey); } bool downloadActive(String fileKey) { return this._downloads.containsKey(fileKey) && !this._downloads[fileKey]!.interrupted; } bool downloadGotManifest(String fileKey) { return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.gotManifest; } bool downloadComplete(String fileKey) { return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.complete; } bool downloadInterrupted(String fileKey) { if (this._downloads.containsKey(fileKey)) { if (this._downloads[fileKey]!.interrupted) { return true; } } return false; } void downloadMarkResumed(String fileKey) { if (this._downloads.containsKey(fileKey)) { this._downloads[fileKey]!.interrupted = false; this._downloads[fileKey]!.requested = DateTime.now(); this._downloads[fileKey]!.markUpdate(); notifyListeners(); } } double downloadProgress(String fileKey) { return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0; } // used for loading interrupted download info; use downloadMarkFinished for successful downloads void downloadSetPath(String fileKey, String path) { if (this._downloads.containsKey(fileKey)) { this._downloads[fileKey]!.downloadedTo = path; notifyListeners(); } } // set the download path for the sender void downloadSetPathForSender(String fileKey, String path) { // we may trigger this event for auto-downloaded receivers too, // as such we don't assume anything else about the file...other than that // it exists. if (!this._downloads.containsKey(fileKey)) { // this will be overwritten by download update if the file is being downloaded this._downloads[fileKey] = FileDownloadProgress(1, DateTime.now()); } this._downloads[fileKey]!.downloadedTo = path; notifyListeners(); } String? downloadFinalPath(String fileKey) { return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.downloadedTo : null; } String downloadSpeed(String fileKey) { if (!downloadActive(fileKey) || this._downloads[fileKey]!.chunksDownloaded == 0) { return "0 B/s"; } var bytes = this._downloads[fileKey]!.chunksDownloaded * 4096; var seconds = (this._downloads[fileKey]!.timeEnd ?? DateTime.now()).difference(this._downloads[fileKey]!.timeStart!).inSeconds; if (seconds == 0) { return "0 B/s"; } return prettyBytes((bytes / seconds).round()) + "/s"; } void waitForDownloadComplete(int identifier, String fileKey) { _downloadTriggers[fileKey] = identifier; notifyListeners(); } int cacheMemUsage() { return _contacts.cacheMemUsage(); } void downloadReset(String fileKey) { this._downloads.remove(fileKey); notifyListeners(); } // Profile Attributes. Can be set in Profile Edit View... List attributes = [null, null, null]; void setAttribute(int i, String? value) { this.attributes[i] = value; notifyListeners(); } ProfileStatusMenu availabilityStatus = ProfileStatusMenu.available; void setAvailabilityStatus(String status) { switch (status) { case "available": availabilityStatus = ProfileStatusMenu.available; break; case "busy": availabilityStatus = ProfileStatusMenu.busy; break; case "away": availabilityStatus = ProfileStatusMenu.away; break; default: ProfileStatusMenu.available; } notifyListeners(); } Color getBorderColor(OpaqueThemeType theme) { switch (this.availabilityStatus) { case ProfileStatusMenu.available: return theme.portraitOnlineBorderColor; case ProfileStatusMenu.away: return theme.portraitOnlineAwayColor; case ProfileStatusMenu.busy: return theme.portraitOnlineBusyColor; default: throw UnimplementedError("not a valid status"); } } // during deactivation it is possible that the event bus is cleaned up prior to statuses being updated // this method nicely cleans up our current state so that the UI functions as expected. // FIXME: Cwtch should be sending these events prior to shutting down the engine... void deactivatePeerEngine(BuildContext context) { Provider.of(context, listen: false).cwtch.DeactivatePeerEngine(onion); this.contactList.contacts.forEach((element) { element.status = "Disconnected"; // reset retry time to allow for instant reconnection... element.lastRetryTime = element.loaded; }); this.serverList.servers.forEach((element) { element.status = "Disconnected"; }); } }