diff --git a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt index fddf4372..89d56826 100644 --- a/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt +++ b/android/app/src/main/kotlin/im/cwtch/flwtch/FlwtchWorker.kt @@ -122,6 +122,12 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) : val indexI = a.getInt("index") return Result.success(Data.Builder().putString("result", Cwtch.getMessage(profile, handle, indexI.toLong())).build()) } + "GetMessageByContentHash" -> { + val profile = (a.get("profile") as? String) ?: "" + val handle = (a.get("contact") as? String) ?: "" + val contentHash = (a.get("contentHash") as? String) ?: "" + return Result.success(Data.Builder().putString("result", Cwtch.getMessageByContentHash(profile, handle, contentHash)).build()) + } "UpdateMessageFlags" -> { val profile = (a.get("profile") as? String) ?: "" val handle = (a.get("contact") as? String) ?: "" diff --git a/lib/cwtch/cwtch.dart b/lib/cwtch/cwtch.dart index 6d9a3036..25359975 100644 --- a/lib/cwtch/cwtch.dart +++ b/lib/cwtch/cwtch.dart @@ -32,6 +32,8 @@ abstract class Cwtch { // ignore: non_constant_identifier_names Future GetMessage(String profile, String handle, int index); // ignore: non_constant_identifier_names + Future GetMessageByContentHash(String profile, String handle, String contentHash); + // ignore: non_constant_identifier_names void UpdateMessageFlags(String profile, String handle, int index, int flags); // ignore: non_constant_identifier_names void SendMessage(String profile, String handle, String message); diff --git a/lib/cwtch/ffi.dart b/lib/cwtch/ffi.dart index 0f8f1ae3..a1b4bda7 100644 --- a/lib/cwtch/ffi.dart +++ b/lib/cwtch/ffi.dart @@ -57,9 +57,9 @@ typedef GetIntFromStrStrFn = int Function(Pointer, int, Pointer, int typedef get_json_blob_from_str_str_int_function = Pointer Function(Pointer, Int32, Pointer, Int32, Int32); typedef GetJsonBlobFromStrStrIntFn = Pointer Function(Pointer, int, Pointer, int, int); -//func GetMessages(profile_ptr *C.char, profile_len C.int, handle_ptr *C.char, handle_len C.int, start C.int, end C.int) *C.char { -typedef get_json_blob_from_str_str_int_int_function = Pointer Function(Pointer, Int32, Pointer, Int32, Int32, Int32); -typedef GetJsonBlobFromStrStrIntIntFn = Pointer Function(Pointer, int, Pointer, int, int, int); +// func c_GetMessagesByContentHash(profile_ptr *C.char, profile_len C.int, handle_ptr *C.char, handle_len C.int, contenthash_ptr *C.char, contenthash_len C.int) *C.char +typedef get_json_blob_from_str_str_str_function = Pointer Function(Pointer, Int32, Pointer, Int32, Pointer, Int32); +typedef GetJsonBlobFromStrStrStrFn = Pointer Function(Pointer, int, Pointer, int, Pointer, int); typedef appbus_events_function = Pointer Function(); typedef AppbusEventsFn = Pointer Function(); @@ -397,4 +397,17 @@ class CwtchFfi implements Cwtch { _receivePort.close(); print("Receive Port Closed"); } + + @override + Future GetMessageByContentHash(String profile, String handle, String contentHash) async { + var getMessagesByContentHashC = library.lookup>("c_GetMessagesByContentHash"); + // ignore: non_constant_identifier_names + final GetMessagesByContentHash = getMessagesByContentHashC.asFunction(); + final utf8profile = profile.toNativeUtf8(); + final utf8handle = handle.toNativeUtf8(); + final utf8contentHash = contentHash.toNativeUtf8(); + Pointer jsonMessageBytes = GetMessagesByContentHash(utf8profile, utf8profile.length, utf8handle, utf8handle.length, utf8contentHash, utf8contentHash.length); + String jsonMessage = jsonMessageBytes.toDartString(); + return jsonMessage; + } } diff --git a/lib/cwtch/gomobile.dart b/lib/cwtch/gomobile.dart index d9be08c9..4717f7e5 100644 --- a/lib/cwtch/gomobile.dart +++ b/lib/cwtch/gomobile.dart @@ -183,4 +183,9 @@ class CwtchGomobile implements Cwtch { print("gomobile.dart Shutdown"); cwtchPlatform.invokeMethod("Shutdown", {}); } + + @override + Future GetMessageByContentHash(String profile, String handle, String contentHash) { + return cwtchPlatform.invokeMethod("GetMessageByContentHash", {"profile": profile, "contact": handle, "contentHash": contentHash}); + } } diff --git a/lib/model.dart b/lib/model.dart index 32dbb485..667264c7 100644 --- a/lib/model.dart +++ b/lib/model.dart @@ -67,6 +67,7 @@ class AppState extends ChangeNotifier { String appError = ""; String? _selectedProfile; String? _selectedConversation; + int? _selectedIndex; void SetCwtchInit() { cwtchInit = true; @@ -90,6 +91,12 @@ class AppState extends ChangeNotifier { notifyListeners(); } + int? get selectedIndex => _selectedIndex; + set selectedIndex(int? newVal) { + this._selectedIndex = newVal; + notifyListeners(); + } + bool isLandscape(BuildContext c) => MediaQuery.of(c).size.width > MediaQuery.of(c).size.height; } @@ -457,7 +464,7 @@ class MessageState extends ChangeNotifier { final String profileOnion; final String contactHandle; final int messageIndex; - late String _message; + late dynamic _message; late int _overlay; late String _inviteTarget; late String _inviteNick; @@ -535,7 +542,7 @@ class MessageState extends ChangeNotifier { return; } dynamic message = jsonDecode(messageWrapper['Message']); - this._message = message['d']; + this._message = message['d'] as dynamic; this._overlay = int.parse(message['o'].toString()); this._timestamp = DateTime.tryParse(messageWrapper['Timestamp'])!; this._senderOnion = messageWrapper['PeerID']; diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index 258f596e..c8d276b6 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'dart:io'; - +import 'package:crypto/crypto.dart'; import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:cwtch/widgets/profileimage.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -104,11 +105,33 @@ class _MessageViewState extends State { void _sendMessage([String? ignoredParam]) { if (ctrlrCompose.value.text.isNotEmpty) { - ChatMessage cm = new ChatMessage(o: 1, d: ctrlrCompose.value.text); - Provider.of(context, listen: false) - .cwtch - .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).onion, jsonEncode(cm)); - _sendMessageHelper(); + if (Provider.of(context).selectedConversation != null && Provider.of(context).selectedIndex != null) { + Provider.of(context) + .cwtch + .GetMessage(Provider.of(context).selectedProfile!, Provider.of(context).selectedConversation!, Provider.of(context).selectedIndex!).then((data) { + try { + var messageWrapper = jsonDecode(data! as String); + var bytes1 = utf8.encode(messageWrapper["PeerID"]+messageWrapper['Message']); + var digest1 = sha256.convert(bytes1); + var contentHash = base64Encode(digest1.bytes); + var quotedMessage = "{\"quotedHash\":\""+contentHash+"\",\"body\":\""+ctrlrCompose.value.text+"\"}"; + ChatMessage cm = new ChatMessage(o: 10, d: quotedMessage); + Provider.of(context, listen: false) + .cwtch + .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).onion, jsonEncode(cm)); + } catch (e) { + + } + Provider.of(context, listen: false).selectedIndex = null; + _sendMessageHelper(); + }); + } else { + ChatMessage cm = new ChatMessage(o: 1, d: ctrlrCompose.value.text); + Provider.of(context, listen: false) + .cwtch + .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).onion, jsonEncode(cm)); + _sendMessageHelper(); + } } } @@ -130,7 +153,7 @@ class _MessageViewState extends State { } Widget _buildComposeBox() { - return Container( + var composeBox = Container( color: Provider.of(context).theme.backgroundMainColor(), padding: EdgeInsets.all(2), margin: EdgeInsets.all(2), @@ -141,52 +164,83 @@ class _MessageViewState extends State { child: Container( decoration: BoxDecoration(border: Border(top: BorderSide(color: Provider.of(context).theme.defaultButtonActiveColor()))), child: RawKeyboardListener( - focusNode: FocusNode(), - onKey: handleKeyPress, - child: TextFormField( - key: Key('txtCompose'), - controller: ctrlrCompose, - focusNode: focusNode, - autofocus: !Platform.isAndroid, - textInputAction: TextInputAction.newline, - keyboardType: TextInputType.multiline, - minLines: 1, - maxLines: null, - onFieldSubmitted: _sendMessage, - decoration: InputDecoration( - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - enabled: true, - prefixIcon: IconButton( - icon: Icon(CwtchIcons.send_invite, size: 24, color: Provider.of(context).theme.mainTextColor()), - tooltip: AppLocalizations.of(context)!.sendInvite, - enableFeedback: true, - splashColor: Provider.of(context).theme.defaultButtonActiveColor(), - hoverColor: Provider.of(context).theme.defaultButtonActiveColor(), - onPressed: () => _modalSendInvitation(context)), - suffixIcon: IconButton( - icon: Icon(CwtchIcons.send_24px, size: 24, color: Provider.of(context).theme.mainTextColor()), - tooltip: AppLocalizations.of(context)!.sendMessage, - onPressed: _sendMessage, - ), - )))), + focusNode: FocusNode(), + onKey: handleKeyPress, + child: TextFormField( + key: Key('txtCompose'), + controller: ctrlrCompose, + focusNode: focusNode, + autofocus: !Platform.isAndroid, + textInputAction: TextInputAction.newline, + keyboardType: TextInputType.multiline, + minLines: 1, + maxLines: null, + onFieldSubmitted: _sendMessage, + decoration: InputDecoration( + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + enabled: true, + prefixIcon: IconButton( + icon: Icon(CwtchIcons.send_invite, size: 24, color: Provider.of(context).theme.mainTextColor()), + tooltip: AppLocalizations.of(context)!.sendInvite, + enableFeedback: true, + splashColor: Provider.of(context).theme.defaultButtonActiveColor(), + hoverColor: Provider.of(context).theme.defaultButtonActiveColor(), + onPressed: () => _modalSendInvitation(context)), + suffixIcon: IconButton( + icon: Icon(CwtchIcons.send_24px, size: 24, color: Provider.of(context).theme.mainTextColor()), + tooltip: AppLocalizations.of(context)!.sendMessage, + onPressed: _sendMessage, + ), + )))), ), ], ), ); + + var children; + if (Provider.of(context).selectedConversation != null && Provider.of(context).selectedIndex != null) { + var quoted = FutureBuilder( + future: Provider.of(context) + .cwtch + .GetMessage(Provider.of(context).selectedProfile!, Provider.of(context).selectedConversation!, Provider.of(context).selectedIndex!), + builder: (context, snapshot) { + if (snapshot.hasData) { + try { + var messageWrapper = jsonDecode(snapshot.data! as String); + dynamic message = jsonDecode(messageWrapper['Message']); + return Container( + margin: EdgeInsets.all(5), + padding: EdgeInsets.all(5), + color: messageWrapper['PeerID'] != Provider.of(context).selectedProfile + ? Provider.of(context).theme.messageFromOtherBackgroundColor() + : Provider.of(context).theme.messageFromMeBackgroundColor(), + child: Text(message["d"])); + } catch (e) { + return MalformedBubble(); + } + } else { + return Text(""); + } + }, + ); + + children = [quoted, composeBox]; + } else { + children = [composeBox]; + } + + return Column(mainAxisSize: MainAxisSize.min, children: children); } // Send the message if enter is pressed without the shift key... void handleKeyPress(event) { - var data = event.data as RawKeyEventData; - if (data.logicalKey == LogicalKeyboardKey.enter && !event.isShiftPressed) { - final messageWithoutNewLine = ctrlrCompose.value.text.trimRight(); - ctrlrCompose.value = TextEditingValue( - text: messageWithoutNewLine - ); - _sendMessage(); - - } + var data = event.data as RawKeyEventData; + if (data.logicalKey == LogicalKeyboardKey.enter && !event.isShiftPressed) { + final messageWithoutNewLine = ctrlrCompose.value.text.trimRight(); + ctrlrCompose.value = TextEditingValue(text: messageWithoutNewLine); + _sendMessage(); + } } void placeHolder() => {}; diff --git a/lib/widgets/contactrow.dart b/lib/widgets/contactrow.dart index d38d1df1..0c844b00 100644 --- a/lib/widgets/contactrow.dart +++ b/lib/widgets/contactrow.dart @@ -98,6 +98,7 @@ class _ContactRowState extends State { Provider.of(context, listen: false).contactList.getContact(contact.onion)!.unreadMessages = 0; // triggers update in Double/TripleColumnView Provider.of(context, listen: false).selectedConversation = contact.onion; + Provider.of(context, listen: false).selectedIndex = null; // if in singlepane mode, push to the stack var isLandscape = Provider.of(context, listen: false).isLandscape(context); if (Provider.of(context, listen: false).uiColumns(isLandscape).length == 1) _pushMessageView(contact.onion); diff --git a/lib/widgets/messagerow.dart b/lib/widgets/messagerow.dart index b6c99788..e37e7a85 100644 --- a/lib/widgets/messagerow.dart +++ b/lib/widgets/messagerow.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:cwtch/widgets/quotedmessage.dart'; import 'package:flutter/material.dart'; import 'package:cwtch/widgets/profileimage.dart'; import 'package:provider/provider.dart'; @@ -33,14 +34,18 @@ class _MessageRowState extends State { Widget wdgBubble = Flexible(flex: 3, fit: FlexFit.loose, child: Provider.of(context).loaded == true ? widgetForOverlay(Provider.of(context).overlay) : MessageLoadingBubble()); - Widget wdgIcons = Icon(Icons.delete_forever_outlined, color: Provider.of(context).theme.dropShadowColor()); + Widget wdgIcons = IconButton( + onPressed: () { + Provider.of(context, listen: false).selectedIndex = Provider.of(context).messageIndex; + }, + icon: Icon(Icons.reply, color: Provider.of(context).theme.dropShadowColor())); Widget wdgSpacer = Expanded(child: SizedBox(width: 60, height: 10)); var widgetRow = []; if (fromMe) { widgetRow = [ wdgSpacer, - //wdgIcons, + wdgIcons, wdgBubble, ]; } else { @@ -60,7 +65,7 @@ class _MessageRowState extends State { widgetRow = [ wdgPortrait, wdgBubble, - //wdgIcons, + wdgIcons, wdgSpacer, ]; } @@ -75,6 +80,8 @@ class _MessageRowState extends State { case 100: case 101: return InvitationBubble(); + case 10: + return QuotedMessageBubble(); } return MalformedBubble(); } diff --git a/lib/widgets/quotedmessage.dart b/lib/widgets/quotedmessage.dart index a0cba993..e71d615d 100644 --- a/lib/widgets/quotedmessage.dart +++ b/lib/widgets/quotedmessage.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:cwtch/main.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -7,6 +10,22 @@ import 'package:intl/intl.dart'; import '../settings.dart'; import 'messagebubbledecorations.dart'; +class LocallyIndexedMessage { + final dynamic message; + final int index; + + LocallyIndexedMessage(this.message, this.index); + + LocallyIndexedMessage.fromJson(Map json) + : message = json['Message'], + index = json['LocalIndex']; + + Map toJson() => { + 'Message': message, + 'LocalIndex': index, + }; +} + class QuotedMessageBubble extends StatefulWidget { @override QuotedMessageBubbleState createState() => QuotedMessageBubbleState(); @@ -22,68 +41,110 @@ class QuotedMessageBubbleState extends State { var borderRadiousEh = 15.0; var myKey = Provider.of(context).profileOnion + "::" + Provider.of(context).contactHandle + "::" + Provider.of(context).messageIndex.toString(); - if (Provider.of(context).timestamp != null) { - // user-configurable timestamps prolly ideal? #todo - DateTime messageDate = Provider.of(context).timestamp; - prettyDate = DateFormat.yMd().add_jm().format(messageDate.toLocal()); - } + try { + dynamic message = jsonDecode(Provider.of(context).message); - // If the sender is not us, then we want to give them a nickname... - var senderDisplayStr = ""; - if (!fromMe && Provider.of(context).senderOnion != null) { - ContactInfoState? contact = Provider.of(context).contactList.getContact(Provider.of(context).senderOnion); - if (contact != null) { - senderDisplayStr = contact.nickname; - } else { - senderDisplayStr = Provider.of(context).senderOnion; + var quotedMessagePotentials = + Provider.of(context).cwtch.GetMessageByContentHash(Provider.of(context).profileOnion, Provider.of(context).contactHandle, message["quotedHash"]); + int messageIndex = Provider.of(context).messageIndex; + var quotedMessage = quotedMessagePotentials.then((matchingMessages) { + // reverse order the messages from newest to oldest and return the + // first matching message where it's index is less than the index of this + // message + try { + var list = (jsonDecode(matchingMessages) as List).map((data) => LocallyIndexedMessage.fromJson(data)).toList(); + LocallyIndexedMessage candidate = list.reversed.firstWhere((element) => messageIndex < element.index, orElse: () { + return list.firstWhere((element) => messageIndex > element.index); + }); + return candidate; + } catch (e) { + // Malformed Message will be returned... + } + }); + + if (Provider.of(context).timestamp != null) { + // user-configurable timestamps prolly ideal? #todo + DateTime messageDate = Provider.of(context).timestamp; + prettyDate = DateFormat.yMd().add_jm().format(messageDate.toLocal()); } - } - var wdgSender = SelectableText(senderDisplayStr, - style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of(context).theme.messageFromMeTextColor() : Provider.of(context).theme.messageFromOtherTextColor())); - var wdgMessage = SelectableText( - (Provider.of(context).message ?? "") + '\u202F', - key: Key(myKey), - focusNode: _focus, - style: TextStyle( - color: fromMe ? Provider.of(context).theme.messageFromMeTextColor() : Provider.of(context).theme.messageFromOtherTextColor(), - ), - textAlign: TextAlign.left, - textWidthBasis: TextWidthBasis.longestLine, - ); + // If the sender is not us, then we want to give them a nickname... + var senderDisplayStr = ""; + if (!fromMe && Provider.of(context).senderOnion != null) { + ContactInfoState? contact = Provider.of(context).contactList.getContact(Provider.of(context).senderOnion); + if (contact != null) { + senderDisplayStr = contact.nickname; + } else { + senderDisplayStr = Provider.of(context).senderOnion; + } + } + var wdgSender = SelectableText(senderDisplayStr, + style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of(context).theme.messageFromMeTextColor() : Provider.of(context).theme.messageFromOtherTextColor())); - var wdgDecorations = MessageBubbleDecoration(ackd: Provider.of(context).ackd, errored: Provider.of(context).error, fromMe: fromMe, prettyDate: prettyDate); + var wdgMessage = SelectableText( + (message["body"] ?? "") + '\u202F', + key: Key(myKey), + focusNode: _focus, + style: TextStyle( + color: fromMe ? Provider.of(context).theme.messageFromMeTextColor() : Provider.of(context).theme.messageFromOtherTextColor(), + ), + textAlign: TextAlign.left, + textWidthBasis: TextWidthBasis.longestLine, + ); - var error = Provider.of(context).error; + var wdgQuote = FutureBuilder( + future: quotedMessage, + builder: (context, snapshot) { + if (snapshot.hasData) { + var lim = (snapshot.data! as LocallyIndexedMessage); + var limmessage = lim.message; + // Swap the background color for quoted tweets.. + return Container( + margin: EdgeInsets.all(5), + padding: EdgeInsets.all(5), + color: fromMe ? Provider.of(context).theme.messageFromOtherBackgroundColor() : Provider.of(context).theme.messageFromMeBackgroundColor(), + child: Text(jsonDecode(limmessage)["d"])); + } else { + // This should be almost instantly resolved, any failure likely means an issue in decoding... + return MalformedBubble(); + } + }, + ); - return LayoutBuilder(builder: (context, constraints) { - //print(constraints.toString()+", "+constraints.maxWidth.toString()); - return RepaintBoundary( - child: Container( - child: Container( - decoration: BoxDecoration( - color: error - ? malformedColor - : (fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor() : Provider.of(context).theme.messageFromOtherBackgroundColor()), - border: Border.all( - color: error - ? malformedColor - : (fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor() : Provider.of(context).theme.messageFromOtherBackgroundColor()), - width: 1), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(borderRadiousEh), - topRight: Radius.circular(borderRadiousEh), - bottomLeft: fromMe ? Radius.circular(borderRadiousEh) : Radius.zero, - bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadiousEh), + var wdgDecorations = MessageBubbleDecoration(ackd: Provider.of(context).ackd, errored: Provider.of(context).error, fromMe: fromMe, prettyDate: prettyDate); + + var error = Provider.of(context).error; + + return LayoutBuilder(builder: (context, constraints) { + return RepaintBoundary( + child: Container( + child: Container( + decoration: BoxDecoration( + color: error + ? malformedColor + : (fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor() : Provider.of(context).theme.messageFromOtherBackgroundColor()), + border: Border.all( + color: error + ? malformedColor + : (fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor() : Provider.of(context).theme.messageFromOtherBackgroundColor()), + width: 1), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(borderRadiousEh), + topRight: Radius.circular(borderRadiousEh), + bottomLeft: fromMe ? Radius.circular(borderRadiousEh) : Radius.zero, + bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadiousEh), + ), ), - ), - child: Padding( - padding: EdgeInsets.all(9.0), - child: Column( - crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, - mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: fromMe ? [wdgMessage, wdgDecorations] : [wdgSender, wdgMessage, wdgDecorations]))))); - }); + child: Padding( + padding: EdgeInsets.all(9.0), + child: Column( + crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: fromMe ? [wdgQuote, wdgMessage, wdgDecorations] : [wdgSender, wdgQuote, wdgMessage, wdgDecorations]))))); + }); + } catch (e) { + return MalformedBubble(); + } } } diff --git a/pubspec.lock b/pubspec.lock index 58f991e0..2d7c5500 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.15.0" + crypto: + dependency: "direct main" + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" cupertino_icons: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7e23469e..e59cc712 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: ffi: ^1.0.0 path_provider: ^2.0.0 desktop_notifications: 0.5.0 + crypto: 3.0.1 glob: any flutter_test: