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