Line data Source code
1 : import 'dart:convert';
2 : import 'dart:math';
3 :
4 : import 'package:cwtch/config.dart';
5 : import 'package:cwtch/models/remoteserver.dart';
6 : import 'package:cwtch/models/search.dart';
7 : import 'package:flutter/widgets.dart';
8 : import 'package:provider/provider.dart';
9 : import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
10 :
11 : import '../main.dart';
12 : import '../themes/opaque.dart';
13 : import '../views/contactsview.dart';
14 : import 'contact.dart';
15 : import 'contactlist.dart';
16 : import 'filedownloadprogress.dart';
17 : import 'profileservers.dart';
18 :
19 : class ProfileInfoState extends ChangeNotifier {
20 : ProfileServerListState _servers = ProfileServerListState();
21 : ContactListState _contacts = ContactListState();
22 : final String onion;
23 : String _nickname = "";
24 : String _imagePath = "";
25 : String _defaultImagePath = "";
26 : int _unreadMessages = 0;
27 : bool _online = false;
28 : Map<String, FileDownloadProgress> _downloads = Map<String, FileDownloadProgress>();
29 : Map<String, int> _downloadTriggers = Map<String, int>();
30 : ItemScrollController contactListScrollController = new ItemScrollController();
31 : // assume profiles are encrypted...this will be set to false
32 : // in the constructor if the profile is encrypted with the defacto password.
33 : bool _encrypted = true;
34 :
35 : bool _autostart = true;
36 : bool _enabled = false;
37 : bool _appearOffline = false;
38 : bool _appearOfflineAtStartup = false;
39 :
40 0 : ProfileInfoState({
41 : required this.onion,
42 : nickname = "",
43 : imagePath = "",
44 : defaultImagePath = "",
45 : unreadMessages = 0,
46 : contactsJson = "",
47 : serversJson = "",
48 : online = false,
49 : autostart = true,
50 : encrypted = true,
51 : appearOffline = false,
52 : String,
53 : }) {
54 0 : this._nickname = nickname;
55 0 : this._imagePath = imagePath;
56 0 : this._defaultImagePath = defaultImagePath;
57 0 : this._unreadMessages = unreadMessages;
58 0 : this._online = online;
59 0 : this._enabled = _enabled;
60 0 : this._autostart = autostart;
61 : if (autostart) {
62 0 : this._enabled = true;
63 : }
64 0 : this._appearOffline = appearOffline;
65 0 : this._appearOfflineAtStartup = appearOffline;
66 0 : this._encrypted = encrypted;
67 :
68 0 : _contacts.connectServers(this._servers);
69 :
70 0 : if (contactsJson != null && contactsJson != "" && contactsJson != "null") {
71 0 : this.replaceServers(serversJson);
72 :
73 0 : List<dynamic> contacts = jsonDecode(contactsJson);
74 0 : this._contacts.addAll(contacts.map((contact) {
75 0 : this._unreadMessages += contact["numUnread"] as int;
76 :
77 0 : return ContactInfoState(this.onion, contact["identifier"], contact["onion"],
78 0 : nickname: contact["name"],
79 0 : localNickname: contact["attributes"]?["local.profile.name"] ?? "", // contact may not have a local name
80 0 : status: contact["status"],
81 0 : imagePath: contact["picture"],
82 0 : defaultImagePath: contact["isGroup"] ? contact["picture"] : contact["defaultPicture"],
83 0 : accepted: contact["accepted"],
84 0 : blocked: contact["blocked"],
85 0 : savePeerHistory: contact["saveConversationHistory"],
86 0 : numMessages: contact["numMessages"],
87 0 : numUnread: contact["numUnread"],
88 0 : isGroup: contact["isGroup"],
89 0 : server: contact["groupServer"],
90 0 : archived: contact["isArchived"] == true,
91 0 : lastMessageTime: DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])),
92 0 : pinned: contact["attributes"]?["local.profile.pinned"] == "true",
93 0 : notificationPolicy: contact["notificationPolicy"] ?? "ConversationNotificationPolicy.Default");
94 : }));
95 :
96 : // dummy set to invoke sort-on-load
97 0 : if (this._contacts.num > 0) {
98 0 : this._contacts.updateLastMessageReceivedTime(this._contacts.contacts.first.identifier, this._contacts.contacts.first.lastMessageReceivedTime);
99 : }
100 : }
101 : }
102 :
103 : // Code for managing the state of the profile-wide search feature...
104 : String activeSearchID = "";
105 : List<SearchResult> activeSearchResults = List.empty(growable: true);
106 :
107 0 : void newSearch(String activeSearchID) {
108 0 : this.activeSearchID = activeSearchID;
109 0 : this.activeSearchResults.clear();
110 0 : notifyListeners();
111 : }
112 :
113 0 : void handleSearchResult(String searchID, int conversationIdentifier, int messageIndex) {
114 0 : if (searchID == activeSearchID) {
115 0 : activeSearchResults.add(SearchResult(searchID: searchID, conversationIdentifier: conversationIdentifier, messageIndex: messageIndex));
116 0 : notifyListeners();
117 : }
118 : }
119 :
120 : // Parse out the server list json into our server info state struct...
121 0 : void replaceServers(String serversJson) {
122 0 : if (serversJson != "" && serversJson != "null") {
123 0 : List<dynamic> servers = jsonDecode(serversJson);
124 0 : this._servers.replace(servers.map((server) {
125 : // TODO Keys...
126 0 : var preSyncStartTime = DateTime.tryParse(server["syncProgress"]["startTime"]);
127 0 : var lastMessageTime = DateTime.tryParse(server["syncProgress"]["lastMessageTime"]);
128 0 : return RemoteServerInfoState(server["onion"], server["identifier"], server["description"], server["status"], lastPreSyncMessageTime: preSyncStartTime, mostRecentMessageTime: lastMessageTime);
129 : }));
130 :
131 0 : this._contacts.contacts.forEach((contact) {
132 0 : if (contact.isGroup) {
133 0 : _servers.addGroup(contact);
134 : }
135 : });
136 :
137 0 : notifyListeners();
138 : }
139 : }
140 :
141 : //
142 0 : void updateServerStatusCache(String server, String status) {
143 0 : this._servers.updateServerState(server, status);
144 0 : notifyListeners();
145 : }
146 :
147 : // Getters and Setters for Online Status
148 0 : bool get isOnline => this._online;
149 :
150 0 : set isOnline(bool newValue) {
151 0 : this._online = newValue;
152 0 : notifyListeners();
153 : }
154 :
155 : // Check encrypted status for profile info screen
156 0 : bool get isEncrypted => this._encrypted;
157 0 : set isEncrypted(bool newValue) {
158 0 : this._encrypted = newValue;
159 0 : notifyListeners();
160 : }
161 :
162 0 : String get nickname => this._nickname;
163 :
164 0 : set nickname(String newValue) {
165 0 : this._nickname = newValue;
166 0 : notifyListeners();
167 : }
168 :
169 0 : String get imagePath => this._imagePath;
170 :
171 0 : set imagePath(String newVal) {
172 0 : this._imagePath = newVal;
173 0 : notifyListeners();
174 : }
175 :
176 0 : bool get enabled => this._enabled;
177 :
178 0 : set enabled(bool newVal) {
179 0 : this._enabled = newVal;
180 0 : notifyListeners();
181 : }
182 :
183 0 : bool get autostart => this._autostart;
184 0 : set autostart(bool newVal) {
185 0 : this._autostart = newVal;
186 0 : notifyListeners();
187 : }
188 :
189 0 : bool get appearOfflineAtStartup => this._appearOfflineAtStartup;
190 0 : set appearOfflineAtStartup(bool newVal) {
191 0 : this._appearOfflineAtStartup = newVal;
192 0 : notifyListeners();
193 : }
194 :
195 0 : bool get appearOffline => this._appearOffline;
196 0 : set appearOffline(bool newVal) {
197 0 : this._appearOffline = newVal;
198 0 : notifyListeners();
199 : }
200 :
201 0 : String get defaultImagePath => this._defaultImagePath;
202 :
203 0 : set defaultImagePath(String newVal) {
204 0 : this._defaultImagePath = newVal;
205 0 : notifyListeners();
206 : }
207 :
208 0 : int get unreadMessages => this._unreadMessages;
209 :
210 0 : set unreadMessages(int newVal) {
211 0 : this._unreadMessages = newVal;
212 0 : notifyListeners();
213 : }
214 :
215 0 : void recountUnread() {
216 0 : this._unreadMessages = _contacts.contacts.fold(0, (i, c) => i + c.unreadMessages);
217 : }
218 :
219 : // Remove a contact from a list. Currently only used when rejecting a group invitation.
220 : // Eventually will also be used for other removals.
221 0 : void removeContact(String handle) {
222 0 : this.contactList.removeContactByHandle(handle);
223 0 : notifyListeners();
224 : }
225 :
226 0 : ContactListState get contactList => this._contacts;
227 :
228 0 : ProfileServerListState get serverList => this._servers;
229 :
230 0 : @override
231 : void dispose() {
232 0 : super.dispose();
233 : }
234 :
235 0 : void updateFrom(String onion, String name, String picture, String contactsJson, String serverJson, bool online) {
236 0 : this._nickname = name;
237 0 : this._imagePath = picture;
238 0 : this._online = online;
239 0 : this._unreadMessages = 0;
240 0 : this.replaceServers(serverJson);
241 :
242 0 : if (contactsJson != "" && contactsJson != "null") {
243 0 : List<dynamic> contacts = jsonDecode(contactsJson);
244 0 : contacts.forEach((contact) {
245 0 : var profileContact = this._contacts.getContact(contact["identifier"]);
246 0 : this._unreadMessages += contact["numUnread"] as int;
247 : if (profileContact != null) {
248 0 : profileContact.status = contact["status"];
249 :
250 0 : var newCount = contact["numMessages"] as int;
251 0 : if (newCount != profileContact.totalMessages) {
252 0 : if (newCount < profileContact.totalMessages) {
253 : // on Android, when sharing a file the UI may be briefly unloaded for the
254 : // OS to display the file management/selection screen. Afterwards a
255 : // call to ReconnectCwtchForeground will be made which will refresh all values (including count of numMessages)
256 : // **at the same time** the foreground will increment .totalMessages and send a new message to the backend.
257 : // This will result in a negative number of messages being calculated here, and an incorrect totalMessage count.
258 : // This bug is exacerbated in debug mode, and when multiple files are sent in succession. Both cases result in multiple ReconnectCwtchForeground
259 : // events that have the potential to conflict with currentMessageCounts.
260 : // 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
261 : // TODO: A true-fix to this bug is to implement a syncing step in the foreground where totalMessages and inFlightMessages can be distinguished
262 : // This requires a change to the backend to confirm submission of an inFlightMessage, which will be implemented in #664
263 0 : EnvironmentConfig.debugLog("Conflicting message counts: $newCount ${profileContact.totalMessages}");
264 0 : newCount = max(newCount, profileContact.totalMessages);
265 : }
266 0 : profileContact.messageCache.addFrontIndexGap(newCount - profileContact.totalMessages);
267 : }
268 0 : profileContact.totalMessages = newCount;
269 0 : profileContact.unreadMessages = contact["numUnread"];
270 0 : profileContact.lastMessageReceivedTime = DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"]));
271 : } else {
272 0 : this._contacts.add(ContactInfoState(
273 0 : this.onion,
274 0 : contact["identifier"],
275 0 : contact["onion"],
276 0 : nickname: contact["name"],
277 0 : defaultImagePath: contact["defaultPicture"],
278 0 : status: contact["status"],
279 0 : imagePath: contact["picture"],
280 0 : accepted: contact["accepted"],
281 0 : blocked: contact["blocked"],
282 0 : savePeerHistory: contact["saveConversationHistory"],
283 0 : numMessages: contact["numMessages"],
284 0 : numUnread: contact["numUnread"],
285 0 : isGroup: contact["isGroup"],
286 0 : server: contact["groupServer"],
287 0 : lastMessageTime: DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])),
288 0 : notificationPolicy: contact["notificationPolicy"] ?? "ConversationNotificationPolicy.Default",
289 : ));
290 : }
291 : });
292 : }
293 0 : this._contacts.resort();
294 : }
295 :
296 0 : void newMessage(
297 : int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String contenthash, bool selectedProfile, bool selectedConversation) {
298 : if (!selectedProfile) {
299 0 : unreadMessages++;
300 0 : notifyListeners();
301 : }
302 :
303 0 : contactList.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation);
304 : }
305 :
306 0 : void downloadInit(String fileKey, int numChunks) {
307 0 : this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now());
308 0 : notifyListeners();
309 : }
310 :
311 0 : void downloadUpdate(String fileKey, int progress, int numChunks) {
312 0 : if (!downloadActive(fileKey)) {
313 0 : this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now());
314 0 : if (progress < 0) {
315 0 : this._downloads[fileKey]!.interrupted = true;
316 : }
317 : } else {
318 0 : if (this._downloads[fileKey]!.interrupted) {
319 0 : this._downloads[fileKey]!.interrupted = false;
320 : }
321 0 : this._downloads[fileKey]!.chunksDownloaded = progress;
322 0 : this._downloads[fileKey]!.chunksTotal = numChunks;
323 0 : this._downloads[fileKey]!.markUpdate();
324 : }
325 0 : notifyListeners();
326 : }
327 :
328 0 : void downloadMarkManifest(String fileKey) {
329 0 : if (!downloadActive(fileKey)) {
330 0 : this._downloads[fileKey] = FileDownloadProgress(1, DateTime.now());
331 : }
332 0 : this._downloads[fileKey]!.gotManifest = true;
333 0 : this._downloads[fileKey]!.markUpdate();
334 0 : notifyListeners();
335 : }
336 :
337 0 : void downloadMarkFinished(String fileKey, String finalPath) {
338 0 : if (!downloadActive(fileKey)) {
339 : // happens as a result of a CheckDownloadStatus call,
340 : // invoked from a historical (timeline) download message
341 : // so setting numChunks correctly shouldn't matter
342 0 : this.downloadInit(fileKey, 1);
343 : }
344 :
345 : // Update the contact with a custom profile image if we are
346 : // waiting for one...
347 0 : if (this._downloadTriggers.containsKey(fileKey)) {
348 0 : int identifier = this._downloadTriggers[fileKey]!;
349 0 : this.contactList.getContact(identifier)!.imagePath = finalPath;
350 0 : notifyListeners();
351 : }
352 :
353 : // only update if different
354 0 : if (!this._downloads[fileKey]!.complete) {
355 0 : this._downloads[fileKey]!.timeEnd = DateTime.now();
356 0 : this._downloads[fileKey]!.downloadedTo = finalPath;
357 0 : this._downloads[fileKey]!.complete = true;
358 0 : this._downloads[fileKey]!.markUpdate();
359 0 : notifyListeners();
360 : }
361 : }
362 :
363 0 : bool downloadKnown(String fileKey) {
364 0 : return this._downloads.containsKey(fileKey);
365 : }
366 :
367 0 : bool downloadActive(String fileKey) {
368 0 : return this._downloads.containsKey(fileKey) && !this._downloads[fileKey]!.interrupted;
369 : }
370 :
371 0 : bool downloadGotManifest(String fileKey) {
372 0 : return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.gotManifest;
373 : }
374 :
375 0 : bool downloadComplete(String fileKey) {
376 0 : return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.complete;
377 : }
378 :
379 0 : bool downloadInterrupted(String fileKey) {
380 0 : if (this._downloads.containsKey(fileKey)) {
381 0 : if (this._downloads[fileKey]!.interrupted) {
382 : return true;
383 : }
384 : }
385 : return false;
386 : }
387 :
388 0 : void downloadMarkResumed(String fileKey) {
389 0 : if (this._downloads.containsKey(fileKey)) {
390 0 : this._downloads[fileKey]!.interrupted = false;
391 0 : this._downloads[fileKey]!.requested = DateTime.now();
392 0 : this._downloads[fileKey]!.markUpdate();
393 0 : notifyListeners();
394 : }
395 : }
396 :
397 0 : double downloadProgress(String fileKey) {
398 0 : return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0;
399 : }
400 :
401 : // used for loading interrupted download info; use downloadMarkFinished for successful downloads
402 0 : void downloadSetPath(String fileKey, String path) {
403 0 : if (this._downloads.containsKey(fileKey)) {
404 0 : this._downloads[fileKey]!.downloadedTo = path;
405 0 : notifyListeners();
406 : }
407 : }
408 :
409 : // set the download path for the sender
410 0 : void downloadSetPathForSender(String fileKey, String path) {
411 : // we may trigger this event for auto-downloaded receivers too,
412 : // as such we don't assume anything else about the file...other than that
413 : // it exists.
414 0 : if (!this._downloads.containsKey(fileKey)) {
415 : // this will be overwritten by download update if the file is being downloaded
416 0 : this._downloads[fileKey] = FileDownloadProgress(1, DateTime.now());
417 : }
418 0 : this._downloads[fileKey]!.downloadedTo = path;
419 0 : notifyListeners();
420 : }
421 :
422 0 : String? downloadFinalPath(String fileKey) {
423 0 : return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.downloadedTo : null;
424 : }
425 :
426 0 : String downloadSpeed(String fileKey) {
427 0 : if (!downloadActive(fileKey) || this._downloads[fileKey]!.chunksDownloaded == 0) {
428 : return "0 B/s";
429 : }
430 0 : var bytes = this._downloads[fileKey]!.chunksDownloaded * 4096;
431 0 : var seconds = (this._downloads[fileKey]!.timeEnd ?? DateTime.now()).difference(this._downloads[fileKey]!.timeStart!).inSeconds;
432 0 : if (seconds == 0) {
433 : return "0 B/s";
434 : }
435 0 : return prettyBytes((bytes / seconds).round()) + "/s";
436 : }
437 :
438 0 : void waitForDownloadComplete(int identifier, String fileKey) {
439 0 : _downloadTriggers[fileKey] = identifier;
440 0 : notifyListeners();
441 : }
442 :
443 0 : int cacheMemUsage() {
444 0 : return _contacts.cacheMemUsage();
445 : }
446 :
447 0 : void downloadReset(String fileKey) {
448 0 : this._downloads.remove(fileKey);
449 0 : notifyListeners();
450 : }
451 :
452 : // Profile Attributes. Can be set in Profile Edit View...
453 : List<String?> attributes = [null, null, null];
454 0 : void setAttribute(int i, String? value) {
455 0 : this.attributes[i] = value;
456 0 : notifyListeners();
457 : }
458 :
459 : ProfileStatusMenu availabilityStatus = ProfileStatusMenu.available;
460 0 : void setAvailabilityStatus(String status) {
461 : switch (status) {
462 0 : case "available":
463 0 : availabilityStatus = ProfileStatusMenu.available;
464 : break;
465 0 : case "busy":
466 0 : availabilityStatus = ProfileStatusMenu.busy;
467 : break;
468 0 : case "away":
469 0 : availabilityStatus = ProfileStatusMenu.away;
470 : break;
471 : default:
472 : ProfileStatusMenu.available;
473 : }
474 0 : notifyListeners();
475 : }
476 :
477 0 : Color getBorderColor(OpaqueThemeType theme) {
478 0 : switch (this.availabilityStatus) {
479 0 : case ProfileStatusMenu.available:
480 0 : return theme.portraitOnlineBorderColor;
481 0 : case ProfileStatusMenu.away:
482 0 : return theme.portraitOnlineAwayColor;
483 0 : case ProfileStatusMenu.busy:
484 0 : return theme.portraitOnlineBusyColor;
485 : default:
486 0 : throw UnimplementedError("not a valid status");
487 : }
488 : }
489 :
490 : // during deactivation it is possible that the event bus is cleaned up prior to statuses being updated
491 : // this method nicely cleans up our current state so that the UI functions as expected.
492 : // FIXME: Cwtch should be sending these events prior to shutting down the engine...
493 0 : void deactivatePeerEngine(BuildContext context) {
494 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.DeactivatePeerEngine(onion);
495 0 : this.contactList.contacts.forEach((element) {
496 0 : element.status = "Disconnected";
497 : // reset retry time to allow for instant reconnection...
498 0 : element.lastRetryTime = element.loaded;
499 : });
500 0 : this.serverList.servers.forEach((element) {
501 0 : element.status = "Disconnected";
502 : });
503 : }
504 : }
|