LCOV - code coverage report
Current view: top level - lib/models - message.dart (source / functions) Hit Total Coverage
Test: lcov.info Lines: 0 197 0.0 %
Date: 2026-09-01 20:32:38 Functions: 0 0 -

          Line data    Source code
       1             : import 'dart:convert';
       2             : import 'package:cwtch/config.dart';
       3             : import 'package:cwtch/cwtch/cwtch.dart';
       4             : import 'package:cwtch/l10n/app_localizations.dart';
       5             : import 'package:cwtch/widgets/staticmessagebubble.dart';
       6             : import 'package:flutter/material.dart';
       7             : import 'package:flutter/widgets.dart';
       8             : import 'package:provider/provider.dart';
       9             : 
      10             : import '../main.dart';
      11             : import '../constants.dart';
      12             : import 'messagecache.dart';
      13             : import 'messages/deletedmessage.dart';
      14             : import 'messages/eventmessage.dart';
      15             : import 'messages/filemessage.dart';
      16             : import 'messages/hiddenmessage.dart';
      17             : import 'messages/invitemessage.dart';
      18             : import 'messages/malformedmessage.dart';
      19             : import 'messages/quotedmessage.dart';
      20             : import 'messages/textmessage.dart';
      21             : import 'messages/withheldmessage.dart';
      22             : import 'profile.dart';
      23             : 
      24             : // Define the overlays
      25             : const TextMessageOverlay = 1;
      26             : const QuotedMessageOverlay = 10;
      27             : const SuggestContactOverlay = 100;
      28             : const InviteGroupOverlay = 101;
      29             : const FileShareOverlay = 200;
      30             : const DeletedMessageOverlay = 410;
      31             : const EventMessageOverlay = 411;
      32             : const WithheldMessageOverlay = 504;
      33             : 
      34             : // Defines the length of the tor v3 onion address. Code using this constant will
      35             : // need to updated when we allow multiple different identifiers. At which time
      36             : // it will likely be prudent to define a proper Contact wrapper.
      37             : const TorV3ContactHandleLength = 56;
      38             : 
      39             : // Defines the length of a Cwtch v2 Group.
      40             : const GroupConversationHandleLength = 32;
      41             : 
      42             : abstract class Message {
      43             :   MessageMetadata getMetadata();
      44             : 
      45             :   Widget getWidget(BuildContext context, Key key, int index);
      46             : 
      47             :   Widget getPreviewWidget(BuildContext context, {BoxConstraints? constraints});
      48             : }
      49             : 
      50           0 : Message compileOverlay(MessageInfo messageInfo) {
      51             :   try {
      52           0 :     dynamic message = jsonDecode(messageInfo.wrapper);
      53           0 :     var content = message['d'] as dynamic;
      54           0 :     var overlay = int.parse(message['o'].toString());
      55             : 
      56             :     // Deleted needs to take priority over hidden/withheld
      57           0 :     if (overlay == DeletedMessageOverlay) {
      58           0 :       return DeletedMessage(messageInfo.metadata);
      59             :     }
      60             : 
      61           0 :     if (messageInfo.metadata.attributes["hidden"] == "true") {
      62           0 :       return HiddenMessage(messageInfo.metadata, messageInfo);
      63           0 :     } else if (messageInfo.metadata.attributes["withheld"] == "true") {
      64           0 :       return WithheldMessage(messageInfo.metadata, messageInfo);
      65             :     }
      66             : 
      67             :     switch (overlay) {
      68           0 :       case TextMessageOverlay:
      69           0 :         return TextMessage(messageInfo.metadata, content);
      70           0 :       case SuggestContactOverlay:
      71           0 :       case InviteGroupOverlay:
      72           0 :         return InviteMessage(overlay, messageInfo.metadata, content);
      73           0 :       case QuotedMessageOverlay:
      74           0 :         return QuotedMessage(messageInfo.metadata, content);
      75           0 :       case FileShareOverlay:
      76           0 :         return FileMessage(messageInfo.metadata, content);
      77           0 :       case WithheldMessageOverlay:
      78           0 :         return WithheldMessage(messageInfo.metadata, messageInfo);
      79           0 :       case EventMessageOverlay:
      80           0 :         return EventMessage(messageInfo.metadata, content);
      81             :       default:
      82             :         // Metadata is valid, content is not..
      83           0 :         EnvironmentConfig.debugLog("unknown overlay:$overlay");
      84           0 :         return MalformedMessage(messageInfo.metadata);
      85             :     }
      86             :   } catch (e) {
      87             :     //return TextMessage(messageInfo.metadata, messageInfo.wrapper);
      88             :     //print("compileOverlay error: $e");
      89           0 :     return MalformedMessage(messageInfo.metadata);
      90             :   }
      91             : }
      92             : 
      93             : abstract class CacheHandler {
      94             :   Future<MessageInfo?> get(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache);
      95             :   Future<MessageInfo?> sync(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache);
      96             : }
      97             : 
      98             : class ByIndex implements CacheHandler {
      99             :   int index;
     100             : 
     101           0 :   ByIndex(this.index);
     102             : 
     103           0 :   Future<MessageInfo?> lookup(MessageCache cache) async {
     104           0 :     var msg = cache.getByIndex(index);
     105             :     return msg;
     106             :   }
     107             : 
     108           0 :   Future<MessageInfo?> get(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async {
     109             :     // if in cache, get. But if the cache has unsynced or not in cache, we'll have to do a fetch
     110           0 :     if (index < cache.cacheByIndex.length) {
     111           0 :       return cache.getByIndex(index);
     112             :     }
     113             : 
     114             :     // otherwise we are going to fetch, so we'll fetch a chunk of messages
     115             :     // observationally flutter future builder seemed to be reaching for 20-40 message on pane load, so we start trying to load up to that many messages in one request
     116             :     var amount = ItemsPerFetch;
     117           0 :     var start = index;
     118             :     // we have to keep the indexed cache contiguous so reach back to the end of it and start the fetch from there
     119           0 :     if (index > cache.cacheByIndex.length) {
     120           0 :       start = cache.cacheByIndex.length;
     121           0 :       amount += index - start;
     122             :     }
     123             : 
     124             :     // check that we aren't asking for messages beyond stored messages
     125           0 :     if (start + amount >= cache.storageMessageCount) {
     126           0 :       amount = cache.storageMessageCount - start;
     127           0 :       if (amount <= 0) {
     128           0 :         return Future.value(null);
     129             :       }
     130             :     }
     131             : 
     132           0 :     cache.lockIndexes(start, start + amount);
     133           0 :     await fetchAndProcess(start, amount, cwtch, profileOnion, conversationIdentifier, cache);
     134             : 
     135           0 :     return cache.getByIndex(index);
     136             :   }
     137             : 
     138           0 :   void loadUnsynced(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) {
     139             :     // return if inadvertently called when no unsynced messages
     140           0 :     if (cache.indexUnsynced == 0) {
     141             :       return;
     142             :     }
     143             : 
     144             :     // otherwise we are going to fetch, so we'll fetch a chunk of messages
     145             :     var start = 0;
     146           0 :     var amount = cache.indexUnsynced;
     147             : 
     148           0 :     cache.lockIndexes(start, start + amount);
     149           0 :     fetchAndProcess(start, amount, cwtch, profileOnion, conversationIdentifier, cache);
     150             :     return;
     151             :   }
     152             : 
     153           0 :   Future<void> fetchAndProcess(int start, int amount, Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async {
     154           0 :     var msgs = await cwtch.GetMessages(profileOnion, conversationIdentifier, start, amount);
     155             :     int i = 0; // i used to loop through returned messages. if doesn't reach the requested count, we will use it in the finally stanza to error out the remaining asked for messages in the cache
     156             :     try {
     157           0 :       List<dynamic> messagesWrapper = jsonDecode(msgs);
     158             : 
     159           0 :       for (; i < messagesWrapper.length; i++) {
     160           0 :         var messageInfo = MessageWrapperToInfo(profileOnion, conversationIdentifier, messagesWrapper[i]);
     161           0 :         messageInfo.metadata.lastChecked = DateTime.now();
     162           0 :         cache.addIndexed(messageInfo, start + i);
     163             :       }
     164             :     } catch (e, stacktrace) {
     165           0 :       EnvironmentConfig.debugLog("Error: Getting indexed messages $start to ${start + amount} failed parsing: " + e.toString() + " " + stacktrace.toString());
     166             :     } finally {
     167           0 :       if (i != amount) {
     168           0 :         cache.malformIndexes(start + i, start + amount);
     169             :       }
     170             :     }
     171             :   }
     172             : 
     173           0 :   void add(MessageCache cache, MessageInfo messageInfo) {
     174           0 :     cache.addIndexed(messageInfo, index);
     175             :   }
     176             : 
     177           0 :   @override
     178             :   Future<MessageInfo?> sync(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) {
     179           0 :     EnvironmentConfig.debugLog("performing a resync on message ${index}");
     180           0 :     fetchAndProcess(index, 1, cwtch, profileOnion, conversationIdentifier, cache);
     181           0 :     return get(cwtch, profileOnion, conversationIdentifier, cache);
     182             :   }
     183             : }
     184             : 
     185             : class ById implements CacheHandler {
     186             :   int id;
     187             : 
     188           0 :   ById(this.id);
     189             : 
     190           0 :   Future<MessageInfo?> lookup(MessageCache cache) {
     191           0 :     return Future<MessageInfo?>.value(cache.getById(id));
     192             :   }
     193             : 
     194           0 :   Future<MessageInfo?> fetch(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async {
     195           0 :     var rawMessageEnvelope = await cwtch.GetMessageByID(profileOnion, conversationIdentifier, id);
     196           0 :     var messageInfo = messageJsonToInfo(profileOnion, conversationIdentifier, rawMessageEnvelope);
     197             :     if (messageInfo == null) {
     198           0 :       return Future.value(null);
     199             :     }
     200           0 :     EnvironmentConfig.debugLog("fetching $profileOnion $conversationIdentifier $id ${messageInfo.wrapper}");
     201           0 :     cache.addUnindexed(messageInfo);
     202           0 :     return Future.value(messageInfo);
     203             :   }
     204             : 
     205           0 :   Future<MessageInfo?> get(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async {
     206           0 :     var messageInfo = await lookup(cache);
     207             :     if (messageInfo != null) {
     208           0 :       return Future.value(messageInfo);
     209             :     }
     210           0 :     return fetch(cwtch, profileOnion, conversationIdentifier, cache);
     211             :   }
     212             : 
     213           0 :   @override
     214             :   Future<MessageInfo?> sync(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) {
     215           0 :     return get(cwtch, profileOnion, conversationIdentifier, cache);
     216             :   }
     217             : }
     218             : 
     219             : class ByContentHash implements CacheHandler {
     220             :   String hash;
     221             : 
     222           0 :   ByContentHash(this.hash);
     223             : 
     224           0 :   Future<MessageInfo?> lookup(MessageCache cache) {
     225           0 :     return Future<MessageInfo?>.value(cache.getByContentHash(hash));
     226             :   }
     227             : 
     228           0 :   Future<MessageInfo?> fetch(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async {
     229           0 :     var rawMessageEnvelope = await cwtch.GetMessageByContentHash(profileOnion, conversationIdentifier, hash);
     230           0 :     var messageInfo = messageJsonToInfo(profileOnion, conversationIdentifier, rawMessageEnvelope);
     231             :     if (messageInfo == null) {
     232           0 :       return Future.value(null);
     233             :     }
     234           0 :     cache.addUnindexed(messageInfo);
     235           0 :     return Future.value(messageInfo);
     236             :   }
     237             : 
     238           0 :   Future<MessageInfo?> get(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async {
     239           0 :     var messageInfo = await lookup(cache);
     240             :     if (messageInfo != null) {
     241           0 :       return Future.value(messageInfo);
     242             :     }
     243           0 :     return fetch(cwtch, profileOnion, conversationIdentifier, cache);
     244             :   }
     245             : 
     246           0 :   @override
     247             :   Future<MessageInfo?> sync(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) {
     248           0 :     return get(cwtch, profileOnion, conversationIdentifier, cache);
     249             :   }
     250             : }
     251             : 
     252           0 : List<Message> getReplies(MessageCache cache, int messageIdentifier) {
     253           0 :   List<Message> replies = List.empty(growable: true);
     254             : 
     255             :   try {
     256           0 :     MessageInfo original = cache.cache[messageIdentifier]!;
     257           0 :     String hash = original.metadata.contenthash;
     258             : 
     259           0 :     cache.cache.forEach((key, messageInfo) {
     260             :       // only bother searching for identifiers that came *after*
     261           0 :       if (key > messageIdentifier) {
     262             :         try {
     263           0 :           dynamic message = jsonDecode(messageInfo.wrapper);
     264           0 :           var content = message['d'] as dynamic;
     265           0 :           dynamic qmessage = jsonDecode(content);
     266           0 :           if (qmessage["body"] == null || qmessage["quotedHash"] == null) {
     267             :             return;
     268             :           }
     269           0 :           if (qmessage["quotedHash"] == hash) {
     270           0 :             replies.add(compileOverlay(messageInfo));
     271             :           }
     272             :         } catch (e) {
     273             :           // ignore
     274             :         }
     275             :       }
     276             :     });
     277             :   } catch (e) {
     278           0 :     EnvironmentConfig.debugLog("message handler exception on get from cache: $e");
     279             :   }
     280             : 
     281           0 :   replies.sort((a, b) {
     282           0 :     return a.getMetadata().messageID.compareTo(b.getMetadata().messageID);
     283             :   });
     284             : 
     285             :   return replies;
     286             : }
     287             : 
     288           0 : Future<Message> messageHandler(BuildContext context, String profileOnion, int conversationIdentifier, CacheHandler cacheHandler) async {
     289           0 :   var malformedMetadata = MessageMetadata(profileOnion, conversationIdentifier, 0, DateTime.now(), "", "", "", <String, String>{}, false, true, false, "");
     290           0 :   var cwtch = Provider.of<FlwtchState>(context, listen: false).cwtch;
     291             : 
     292             :   MessageCache? cache;
     293             :   try {
     294           0 :     cache = Provider.of<ProfileInfoState>(context, listen: false).contactList.getContact(conversationIdentifier)?.messageCache;
     295             :     if (cache == null) {
     296           0 :       EnvironmentConfig.debugLog("error: cannot get message cache for profile: $profileOnion conversation: $conversationIdentifier");
     297           0 :       return MalformedMessage(malformedMetadata);
     298             :     }
     299             :   } catch (e) {
     300           0 :     EnvironmentConfig.debugLog("message handler exception on get from cache: $e");
     301             :     // provider check failed...make an expensive call...
     302           0 :     return MalformedMessage(malformedMetadata);
     303             :   }
     304             : 
     305           0 :   MessageInfo? messageInfo = await cacheHandler.get(cwtch, profileOnion, conversationIdentifier, cache);
     306             : 
     307             :   if (messageInfo != null) {
     308           0 :     if (messageInfo.metadata.ackd == false) {
     309           0 :       if (messageInfo.metadata.lastChecked == null || messageInfo.metadata.lastChecked!.difference(DateTime.now()).abs().inSeconds > 30) {
     310           0 :         messageInfo.metadata.lastChecked = DateTime.now();
     311             :         // NOTE: Only ByIndex lookups will trigger
     312           0 :         messageInfo = await cacheHandler.sync(cwtch, profileOnion, conversationIdentifier, cache);
     313             :       }
     314             :     }
     315             :   }
     316             : 
     317             :   if (messageInfo != null) {
     318           0 :     return compileOverlay(messageInfo);
     319             :   } else {
     320           0 :     return MalformedMessage(malformedMetadata);
     321             :   }
     322             : }
     323             : 
     324           0 : MessageInfo? messageJsonToInfo(String profileOnion, int conversationIdentifier, dynamic messageJson) {
     325             :   try {
     326           0 :     dynamic messageWrapper = jsonDecode(messageJson);
     327             : 
     328           0 :     if (messageWrapper == null || messageWrapper['Message'] == '' || messageWrapper['Message'] == '{}') {
     329             :       return null;
     330             :     }
     331             : 
     332           0 :     return MessageWrapperToInfo(profileOnion, conversationIdentifier, messageWrapper);
     333             :   } catch (e, stacktrace) {
     334           0 :     EnvironmentConfig.debugLog("message handler exception on parse message and cache: " + e.toString() + " " + stacktrace.toString());
     335             :     return null;
     336             :   }
     337             : }
     338             : 
     339           0 : MessageInfo MessageWrapperToInfo(String profileOnion, int conversationIdentifier, dynamic messageWrapper) {
     340             :   // Construct the initial metadata
     341           0 :   var messageID = messageWrapper['ID'];
     342           0 :   var timestamp = DateTime.tryParse(messageWrapper['Timestamp'])!;
     343           0 :   var senderHandle = messageWrapper['PeerID'];
     344           0 :   var senderImage = messageWrapper['ContactImage'];
     345           0 :   var attributes = messageWrapper['Attributes'];
     346           0 :   var ackd = messageWrapper['Acknowledged'];
     347           0 :   var error = messageWrapper['Error'] != null;
     348           0 :   var signature = messageWrapper['Signature'];
     349           0 :   var contenthash = messageWrapper['ContentHash'];
     350           0 :   var metadata = MessageMetadata(profileOnion, conversationIdentifier, messageID, timestamp, senderHandle, senderImage, signature, attributes, ackd, error, false, contenthash);
     351           0 :   var messageInfo = new MessageInfo(metadata, messageWrapper['Message']);
     352             : 
     353             :   return messageInfo;
     354             : }
     355             : 
     356             : class MessageMetadata extends ChangeNotifier {
     357             :   // meta-metadata
     358             :   final String profileOnion;
     359             :   final int conversationIdentifier;
     360             :   final int messageID;
     361             : 
     362             :   final DateTime timestamp;
     363             :   final String senderHandle;
     364             :   final String? senderImage;
     365             :   dynamic _attributes;
     366             :   bool _ackd;
     367             :   bool _error;
     368             :   final bool isAuto;
     369             : 
     370             :   String? signature;
     371             :   final String contenthash;
     372             :   DateTime? lastChecked;
     373             : 
     374           0 :   dynamic get attributes => this._attributes;
     375           0 :   set attributes(dynamic newVal) {
     376           0 :     this._attributes = newVal;
     377           0 :     this._ackd = newVal.containsKey('ack') ? (newVal['ack'] == 'true') : false;
     378           0 :     this._error = newVal.containsKey('error') ? (newVal['error'] == 'true') : false;
     379           0 :     notifyListeners();
     380             :   }
     381             : 
     382           0 :   bool get ackd => this._ackd;
     383             : 
     384             :   String translation = "";
     385           0 :   void updateTranslationEvent(String translation) {
     386           0 :     this.translation += translation;
     387           0 :     notifyListeners();
     388             :   }
     389             : 
     390           0 :   set ackd(bool newVal) {
     391           0 :     this._ackd = newVal;
     392           0 :     notifyListeners();
     393             :   }
     394             : 
     395           0 :   bool get error => this._error;
     396             : 
     397           0 :   set error(bool newVal) {
     398           0 :     this._error = newVal;
     399           0 :     notifyListeners();
     400             :   }
     401             : 
     402           0 :   MessageMetadata(
     403             :     this.profileOnion,
     404             :     this.conversationIdentifier,
     405             :     this.messageID,
     406             :     this.timestamp,
     407             :     this.senderHandle,
     408             :     this.senderImage,
     409             :     this.signature,
     410             :     this._attributes,
     411             :     this._ackd,
     412             :     this._error,
     413             :     this.isAuto,
     414             :     this.contenthash,
     415             :   );
     416             : 
     417           0 :   MessageMetadata copy() {
     418           0 :     return MessageMetadata(
     419           0 :       this.profileOnion,
     420           0 :       this.conversationIdentifier,
     421           0 :       this.messageID,
     422           0 :       this.timestamp,
     423           0 :       this.senderHandle,
     424           0 :       this.senderImage,
     425           0 :       this.signature,
     426           0 :       Map.from(this._attributes),
     427           0 :       this._ackd,
     428           0 :       this._error,
     429           0 :       this.isAuto,
     430           0 :       this.contenthash,
     431             :     );
     432             :   }
     433             : }
     434             : 
     435             : // explicitly passing BuildContext ctx here is important, change at risk to own health
     436             : // otherwise some Providers will become inaccessible to subwidgets...?
     437             : // https://stackoverflow.com/a/63818697
     438           0 : void modalPreviewMessage(BuildContext ctx, MessageInfo messageInfo) {
     439             :   // re-wrap the message as if it wasn't hidden/withheld to preview
     440           0 :   var metadata = messageInfo.metadata.copy();
     441           0 :   metadata.attributes["hidden"] = "false";
     442           0 :   metadata.attributes["withheld"] = "false";
     443             :   //Widget bubble = compileOverlay(MessageInfo(metadata, messageInfo.wrapper)).getPreviewWidget(ctx);
     444             :   //StaticMessageBubble(profile, settings, e.getMetadata(), Row(children: [Flexible(child: e.getPreviewWidget(context))]))
     445           0 :   Widget bubble = StaticMessageBubble(
     446           0 :     Provider.of(ctx, listen: false),
     447           0 :     Provider.of(ctx, listen: false),
     448             :     metadata,
     449           0 :     Row(children: [Flexible(child: compileOverlay(MessageInfo(metadata, messageInfo.wrapper)).getPreviewWidget(ctx))]),
     450             :   );
     451             : 
     452           0 :   showModalBottomSheet<void>(
     453             :     context: ctx,
     454           0 :     builder: (BuildContext bcontext) {
     455           0 :       return Container(
     456             :         height: 200, // bespoke value courtesy of the [TextField] docs
     457           0 :         child: Center(
     458           0 :           child: Padding(
     459           0 :             padding: EdgeInsets.all(10.0),
     460           0 :             child: Column(
     461             :               mainAxisAlignment: MainAxisAlignment.center,
     462             :               mainAxisSize: MainAxisSize.min,
     463             :               crossAxisAlignment: CrossAxisAlignment.stretch,
     464           0 :               children: <Widget>[
     465           0 :                 Text(AppLocalizations.of(ctx)!.messageHiddenPreview),
     466           0 :                 SizedBox(height: 20),
     467             :                 bubble,
     468           0 :                 SizedBox(height: 20),
     469           0 :                 ElevatedButton(
     470           0 :                   child: Text(AppLocalizations.of(ctx)!.okButton, semanticsLabel: AppLocalizations.of(ctx)!.okButton),
     471           0 :                   onPressed: () {
     472             :                     if (false) {
     473             :                       //Provider.of<ContactInfoState>(context, listen: false).messageDraft.attachInvite(this.selectedContact);
     474             :                     }
     475           0 :                     Navigator.pop(bcontext);
     476             :                     //setState(() {});
     477             :                   },
     478             :                 ),
     479             :               ],
     480             :             ),
     481             :           ),
     482             :         ),
     483             :       );
     484             :     },
     485             :   );
     486             : }

Generated by: LCOV version 1.14