From c9581363dcdf3c61d6001d04c8c3f73fc9e94218 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Fri, 26 Aug 2022 20:15:34 +0930 Subject: [PATCH 1/6] WIP - Working on adding attachments to messages --- Backend/Database/Init.go | 1 + Backend/Models/Attachments.go | 8 + mobile/ios/Runner/Info.plist | 6 + mobile/lib/components/file_picker.dart | 97 ++++++ mobile/lib/models/conversations.dart | 5 +- mobile/lib/models/image_message.dart | 131 ++++++++ mobile/lib/models/messages.dart | 111 ++----- mobile/lib/models/text_messages.dart | 131 ++++++++ mobile/lib/utils/storage/messages.dart | 22 +- .../lib/views/main/conversation/detail.dart | 287 +++++++++++++----- .../views/main/conversation/list_item.dart | 27 +- .../lib/views/main/conversation/settings.dart | 6 +- .../conversation/settings_user_list_item.dart | 84 ++--- mobile/pubspec.lock | 49 +++ mobile/pubspec.yaml | 1 + 15 files changed, 725 insertions(+), 241 deletions(-) create mode 100644 Backend/Models/Attachments.go create mode 100644 mobile/lib/components/file_picker.dart create mode 100644 mobile/lib/models/image_message.dart create mode 100644 mobile/lib/models/text_messages.dart diff --git a/Backend/Database/Init.go b/Backend/Database/Init.go index 4481002..f4b6fb9 100644 --- a/Backend/Database/Init.go +++ b/Backend/Database/Init.go @@ -20,6 +20,7 @@ var DB *gorm.DB func getModels() []interface{} { return []interface{}{ &Models.Session{}, + &Models.Attachment{}, &Models.User{}, &Models.FriendRequest{}, &Models.MessageData{}, diff --git a/Backend/Models/Attachments.go b/Backend/Models/Attachments.go new file mode 100644 index 0000000..34304a7 --- /dev/null +++ b/Backend/Models/Attachments.go @@ -0,0 +1,8 @@ +package Models + +// Attachment holds the attachment data +type Attachment struct { + Base + FilePath string `gorm:"not null" json:"-"` + Mimetype string `gorm:"not null" json:"mimetype"` +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index d3ba628..f2762fc 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -47,5 +47,11 @@ NSCameraUsageDescription This app needs camera access to scan QR codes + NSPhotoLibraryUsageDescription + Upload images for screen background + NSCameraUsageDescription + Upload image from camera for screen background + NSMicrophoneUsageDescription + Post videos to profile diff --git a/mobile/lib/components/file_picker.dart b/mobile/lib/components/file_picker.dart new file mode 100644 index 0000000..6c56310 --- /dev/null +++ b/mobile/lib/components/file_picker.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +class FilePicker extends StatelessWidget { + FilePicker({ + Key? key, + this.cameraHandle, + this.galleryHandleSingle, + this.galleryHandleMultiple, + this.fileHandle, + }) : super(key: key); + + final Function()? cameraHandle; + final Function()? galleryHandleSingle; + final Function(List images)? galleryHandleMultiple; + final Function()? fileHandle; + + final ImagePicker _picker = ImagePicker(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10, left: 5, right: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _filePickerSelection( + hasHandle: cameraHandle != null, + icon: Icons.camera_alt, + onTap: () { + }, + context: context, + ), + _filePickerSelection( + hasHandle: galleryHandleSingle != null, + icon: Icons.image, + onTap: () async { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + print(image); + }, + context: context, + ), + _filePickerSelection( + hasHandle: galleryHandleMultiple != null, + icon: Icons.image, + onTap: () async { + final List? images = await _picker.pickMultiImage(); + if (images == null) { + return; + } + galleryHandleMultiple!(images); + }, + context: context, + ), + _filePickerSelection( + hasHandle: fileHandle != null, + icon: Icons.file_present_sharp, + onTap: () { + }, + context: context, + ), + ], + ) + ); + } + + Widget _filePickerSelection({ + required bool hasHandle, + required IconData icon, + required Function() onTap, + required BuildContext context + }) { + if (!hasHandle) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(left: 5, right: 5), + child: GestureDetector( + onTap: onTap, + child: Container( + height: 75, + width: 75, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(25), + ), + child: Icon( + icon, + size: 40, + ), + ), + ), + ); + } +} + diff --git a/mobile/lib/models/conversations.dart b/mobile/lib/models/conversations.dart index e7d760d..9aa7c33 100644 --- a/mobile/lib/models/conversations.dart +++ b/mobile/lib/models/conversations.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:Envelope/models/messages.dart'; +import 'package:Envelope/models/text_messages.dart'; import 'package:pointycastle/export.dart'; import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; @@ -348,11 +349,11 @@ class Conversation { return null; } - return Message( + return TextMessage( id: maps[0]['id'], symmetricKey: maps[0]['symmetric_key'], userSymmetricKey: maps[0]['user_symmetric_key'], - data: maps[0]['data'], + text: maps[0]['data'], senderId: maps[0]['sender_id'], senderUsername: maps[0]['sender_username'], associationKey: maps[0]['association_key'], diff --git a/mobile/lib/models/image_message.dart b/mobile/lib/models/image_message.dart new file mode 100644 index 0000000..d430e2d --- /dev/null +++ b/mobile/lib/models/image_message.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:pointycastle/pointycastle.dart'; +import 'package:uuid/uuid.dart'; + +import '/models/conversations.dart'; +import '/models/messages.dart'; +import '/utils/encryption/aes_helper.dart'; +import '/utils/encryption/crypto_utils.dart'; +import '/utils/strings.dart'; + +class ImageMessage extends Message { + String text; + + ImageMessage({ + id, + symmetricKey, + userSymmetricKey, + senderId, + senderUsername, + associationKey, + createdAt, + failedToSend, + required this.text, + }) : super( + id: id, + symmetricKey: symmetricKey, + userSymmetricKey: userSymmetricKey, + senderId: senderId, + senderUsername: senderUsername, + associationKey: associationKey, + createdAt: createdAt, + failedToSend: failedToSend, + ); + + factory ImageMessage.fromJson(Map json, RSAPrivateKey privKey) { + var userSymmetricKey = CryptoUtils.rsaDecrypt( + base64.decode(json['symmetric_key']), + privKey, + ); + + var symmetricKey = AesHelper.aesDecrypt( + userSymmetricKey, + base64.decode(json['message_data']['symmetric_key']), + ); + + var senderId = AesHelper.aesDecrypt( + base64.decode(symmetricKey), + base64.decode(json['message_data']['sender_id']), + ); + + var data = AesHelper.aesDecrypt( + base64.decode(symmetricKey), + base64.decode(json['message_data']['data']), + ); + + return ImageMessage( + id: json['id'], + symmetricKey: symmetricKey, + userSymmetricKey: base64.encode(userSymmetricKey), + senderId: senderId, + senderUsername: 'Unknown', + associationKey: json['association_key'], + createdAt: json['created_at'], + failedToSend: false, + text: data, + ); + } + + Map toMap() { + return { + 'id': id, + 'symmetric_key': symmetricKey, + 'user_symmetric_key': userSymmetricKey, + 'data': text, + 'sender_id': senderId, + 'sender_username': senderUsername, + 'association_key': associationKey, + 'created_at': createdAt, + 'failed_to_send': failedToSend ? 1 : 0, + }; + } + + Future> payloadJson(Conversation conversation, String messageId) async { + final String messageDataId = (const Uuid()).v4(); + + final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); + + List> messages = await super.payloadJsonBase( + symmetricKey, + conversation, + messageId, + messageDataId, + ); + + Map messageData = { + 'id': messageDataId, + 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)), + 'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), + 'symmetric_key': AesHelper.aesEncrypt( + userSymmetricKey, + Uint8List.fromList(base64.encode(symmetricKey).codeUnits), + ), + }; + + return { + 'message_data': messageData, + 'message': messages, + }; + } + + @override + String getContent() { + return 'Image'; + } + + @override + String toString() { + return ''' + + + id: $id + data: $text, + senderId: $senderId + senderUsername: $senderUsername + associationKey: $associationKey + createdAt: $createdAt + '''; + } +} diff --git a/mobile/lib/models/messages.dart b/mobile/lib/models/messages.dart index e88594f..251236f 100644 --- a/mobile/lib/models/messages.dart +++ b/mobile/lib/models/messages.dart @@ -1,17 +1,16 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:pointycastle/export.dart'; -import 'package:uuid/uuid.dart'; +import 'package:Envelope/models/conversation_users.dart'; +import 'package:Envelope/models/my_profile.dart'; +import 'package:Envelope/models/text_messages.dart'; +import 'package:Envelope/utils/encryption/aes_helper.dart'; +import 'package:Envelope/utils/encryption/crypto_utils.dart'; +import 'package:Envelope/utils/strings.dart'; +import 'package:pointycastle/pointycastle.dart'; -import '/models/conversation_users.dart'; import '/models/conversations.dart'; -import '/models/my_profile.dart'; -import '/models/friends.dart'; -import '/utils/encryption/aes_helper.dart'; -import '/utils/encryption/crypto_utils.dart'; import '/utils/storage/database.dart'; -import '/utils/strings.dart'; const messageTypeReceiver = 'receiver'; const messageTypeSender = 'sender'; @@ -30,11 +29,11 @@ Future> getMessagesForThread(Conversation conversation) async { ); return List.generate(maps.length, (i) { - return Message( + return TextMessage( id: maps[i]['id'], symmetricKey: maps[i]['symmetric_key'], userSymmetricKey: maps[i]['user_symmetric_key'], - data: maps[i]['data'], + text: maps[i]['data'], senderId: maps[i]['sender_id'], senderUsername: maps[i]['sender_username'], associationKey: maps[i]['association_key'], @@ -42,24 +41,22 @@ Future> getMessagesForThread(Conversation conversation) async { failedToSend: maps[i]['failed_to_send'] == 1, ); }); - } class Message { String id; String symmetricKey; String userSymmetricKey; - String data; String senderId; String senderUsername; String associationKey; String createdAt; bool failedToSend; + Message({ required this.id, required this.symmetricKey, required this.userSymmetricKey, - required this.data, required this.senderId, required this.senderUsername, required this.associationKey, @@ -67,42 +64,13 @@ class Message { required this.failedToSend, }); + Future>> payloadJsonBase( + Uint8List symmetricKey, + Conversation conversation, + String messageId, + String messageDataId, + ) async { - factory Message.fromJson(Map json, RSAPrivateKey privKey) { - var userSymmetricKey = CryptoUtils.rsaDecrypt( - base64.decode(json['symmetric_key']), - privKey, - ); - - var symmetricKey = AesHelper.aesDecrypt( - userSymmetricKey, - base64.decode(json['message_data']['symmetric_key']), - ); - - var senderId = AesHelper.aesDecrypt( - base64.decode(symmetricKey), - base64.decode(json['message_data']['sender_id']), - ); - - var data = AesHelper.aesDecrypt( - base64.decode(symmetricKey), - base64.decode(json['message_data']['data']), - ); - - return Message( - id: json['id'], - symmetricKey: symmetricKey, - userSymmetricKey: base64.encode(userSymmetricKey), - data: data, - senderId: senderId, - senderUsername: 'Unknown', - associationKey: json['association_key'], - createdAt: json['created_at'], - failedToSend: false, - ); - } - - Future payloadJson(Conversation conversation, String messageId) async { MyProfile profile = await MyProfile.getProfile(); if (profile.publicKey == null) { throw Exception('Could not get profile.publicKey'); @@ -110,10 +78,7 @@ class Message { RSAPublicKey publicKey = profile.publicKey!; - final String messageDataId = (const Uuid()).v4(); - final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32)); - final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); List> messages = []; List conversationUsers = await getConversationUsers(conversation); @@ -150,48 +115,10 @@ class Message { }); } - Map messageData = { - 'id': messageDataId, - 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(data.codeUnits)), - 'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), - 'symmetric_key': AesHelper.aesEncrypt( - userSymmetricKey, - Uint8List.fromList(base64.encode(symmetricKey).codeUnits), - ), - }; - - return jsonEncode({ - 'message_data': messageData, - 'message': messages, - }); + return messages; } - Map toMap() { - return { - 'id': id, - 'symmetric_key': symmetricKey, - 'user_symmetric_key': userSymmetricKey, - 'data': data, - 'sender_id': senderId, - 'sender_username': senderUsername, - 'association_key': associationKey, - 'created_at': createdAt, - 'failed_to_send': failedToSend ? 1 : 0, - }; + String getContent() { + return ''; } - - @override - String toString() { - return ''' - - - id: $id - data: $data - senderId: $senderId - senderUsername: $senderUsername - associationKey: $associationKey - createdAt: $createdAt - '''; - } - } diff --git a/mobile/lib/models/text_messages.dart b/mobile/lib/models/text_messages.dart new file mode 100644 index 0000000..2dbb898 --- /dev/null +++ b/mobile/lib/models/text_messages.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:pointycastle/pointycastle.dart'; +import 'package:uuid/uuid.dart'; + +import '/models/conversations.dart'; +import '/models/messages.dart'; +import '/utils/encryption/aes_helper.dart'; +import '/utils/encryption/crypto_utils.dart'; +import '/utils/strings.dart'; + +class TextMessage extends Message { + String text; + + TextMessage({ + id, + symmetricKey, + userSymmetricKey, + senderId, + senderUsername, + associationKey, + createdAt, + failedToSend, + required this.text, + }) : super( + id: id, + symmetricKey: symmetricKey, + userSymmetricKey: userSymmetricKey, + senderId: senderId, + senderUsername: senderUsername, + associationKey: associationKey, + createdAt: createdAt, + failedToSend: failedToSend, + ); + + factory TextMessage.fromJson(Map json, RSAPrivateKey privKey) { + var userSymmetricKey = CryptoUtils.rsaDecrypt( + base64.decode(json['symmetric_key']), + privKey, + ); + + var symmetricKey = AesHelper.aesDecrypt( + userSymmetricKey, + base64.decode(json['message_data']['symmetric_key']), + ); + + var senderId = AesHelper.aesDecrypt( + base64.decode(symmetricKey), + base64.decode(json['message_data']['sender_id']), + ); + + var data = AesHelper.aesDecrypt( + base64.decode(symmetricKey), + base64.decode(json['message_data']['data']), + ); + + return TextMessage( + id: json['id'], + symmetricKey: symmetricKey, + userSymmetricKey: base64.encode(userSymmetricKey), + senderId: senderId, + senderUsername: 'Unknown', + associationKey: json['association_key'], + createdAt: json['created_at'], + failedToSend: false, + text: data, + ); + } + + Map toMap() { + return { + 'id': id, + 'symmetric_key': symmetricKey, + 'user_symmetric_key': userSymmetricKey, + 'data': text, + 'sender_id': senderId, + 'sender_username': senderUsername, + 'association_key': associationKey, + 'created_at': createdAt, + 'failed_to_send': failedToSend ? 1 : 0, + }; + } + + Future> payloadJson(Conversation conversation, String messageId) async { + final String messageDataId = (const Uuid()).v4(); + + final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); + + List> messages = await super.payloadJsonBase( + symmetricKey, + conversation, + messageId, + messageDataId, + ); + + Map messageData = { + 'id': messageDataId, + 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)), + 'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), + 'symmetric_key': AesHelper.aesEncrypt( + userSymmetricKey, + Uint8List.fromList(base64.encode(symmetricKey).codeUnits), + ), + }; + + return { + 'message_data': messageData, + 'message': messages, + }; + } + + @override + String getContent() { + return text; + } + + @override + String toString() { + return ''' + + + id: $id + data: $text, + senderId: $senderId + senderUsername: $senderUsername + associationKey: $associationKey + createdAt: $createdAt + '''; + } +} diff --git a/mobile/lib/utils/storage/messages.dart b/mobile/lib/utils/storage/messages.dart index da715e0..a8bb5fb 100644 --- a/mobile/lib/utils/storage/messages.dart +++ b/mobile/lib/utils/storage/messages.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:Envelope/models/text_messages.dart'; import 'package:http/http.dart' as http; import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; @@ -11,7 +13,7 @@ import '/models/my_profile.dart'; import '/utils/storage/database.dart'; import '/utils/storage/session_cookie.dart'; -Future sendMessage(Conversation conversation, String data) async { +Future sendMessage(Conversation conversation, { String? data, List? files }) async { MyProfile profile = await MyProfile.getProfile(); var uuid = const Uuid(); @@ -19,13 +21,25 @@ Future sendMessage(Conversation conversation, String data) async { ConversationUser currentUser = await getConversationUser(conversation, profile.id); - Message message = Message( + List> messagesToAdd = []; + + if (data != null) { + messagesToAdd.add({ 'text': data }); + } + + if (files != null && files.isNotEmpty) { + for (File file in files) { + messagesToAdd.add({ 'file': file }); + } + } + + var message = TextMessage( id: messageId, symmetricKey: '', userSymmetricKey: '', senderId: currentUser.userId, senderUsername: profile.username, - data: data, + text: data!, associationKey: currentUser.associationKey, createdAt: DateTime.now().toIso8601String(), failedToSend: false, @@ -89,7 +103,7 @@ Future updateMessageThread(Conversation conversation, {MyProfile? profile} final db = await getDatabaseConnection(); for (var i = 0; i < messageThreadJson.length; i++) { - Message message = Message.fromJson( + var message = TextMessage.fromJson( messageThreadJson[i] as Map, profile.privateKey!, ); diff --git a/mobile/lib/views/main/conversation/detail.dart b/mobile/lib/views/main/conversation/detail.dart index 572f855..be284e4 100644 --- a/mobile/lib/views/main/conversation/detail.dart +++ b/mobile/lib/views/main/conversation/detail.dart @@ -1,6 +1,11 @@ -import 'package:Envelope/components/custom_title_bar.dart'; +import 'dart:io'; + +import 'package:Envelope/models/text_messages.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '/components/custom_title_bar.dart'; +import '/components/file_picker.dart'; import '/models/conversations.dart'; import '/models/messages.dart'; import '/models/my_profile.dart'; @@ -30,6 +35,9 @@ class _ConversationDetailState extends State { TextEditingController msgController = TextEditingController(); + bool showFilePicker = false; + List selectedImages = []; + @override Widget build(BuildContext context) { return Scaffold( @@ -61,80 +69,7 @@ class _ConversationDetailState extends State { body: Stack( children: [ messagesView(), - Align( - alignment: Alignment.bottomLeft, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 200.0, - ), - child: Container( - padding: const EdgeInsets.only(left: 10,bottom: 10,top: 10), - // height: 60, - width: double.infinity, - color: Theme.of(context).backgroundColor, - child: Row( - children: [ - GestureDetector( - onTap: (){ - }, - child: Container( - height: 30, - width: 30, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), - ), - child: Icon( - Icons.add, - color: Theme.of(context).colorScheme.onPrimary, - size: 20 - ), - ), - ), - const SizedBox(width: 15,), - Expanded( - child: TextField( - decoration: InputDecoration( - hintText: 'Write message...', - hintStyle: TextStyle( - color: Theme.of(context).hintColor, - ), - border: InputBorder.none, - ), - maxLines: null, - controller: msgController, - ), - ), - const SizedBox(width: 15), - Container( - width: 45, - height: 45, - child: FittedBox( - child: FloatingActionButton( - onPressed: () async { - if (msgController.text == '') { - return; - } - await sendMessage(widget.conversation, msgController.text); - messages = await getMessagesForThread(widget.conversation); - setState(() {}); - msgController.text = ''; - }, - child: Icon( - Icons.send, - color: Theme.of(context).colorScheme.onPrimary, - size: 22 - ), - backgroundColor: Theme.of(context).primaryColor, - ), - ), - ), - const SizedBox(width: 10), - ], - ), - ), - ), - ), + newMessageContent(), ], ), ); @@ -220,15 +155,7 @@ class _ConversationDetailState extends State { ), ), padding: const EdgeInsets.all(12), - child: Text( - messages[index].data, - style: TextStyle( - fontSize: 15, - color: messages[index].senderUsername == profile.username ? - Theme.of(context).colorScheme.onPrimary : - Theme.of(context).colorScheme.onTertiary, - ) - ), + child: messageContent(index), ), const SizedBox(height: 1.5), Row( @@ -269,4 +196,196 @@ class _ConversationDetailState extends State { }, ); } + + Widget messageContent(int index) { + return Text( + messages[index].getContent(), + style: TextStyle( + fontSize: 15, + color: messages[index].senderUsername == profile.username ? + Theme.of(context).colorScheme.onPrimary : + Theme.of(context).colorScheme.onTertiary, + ) + ); + } + + Widget showSelectedImages() { + if (selectedImages.isEmpty) { + return const SizedBox.shrink(); + } + + return SizedBox( + height: 80, + width: double.infinity, + child: ListView.builder( + itemCount: selectedImages.length, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.all(5), + itemBuilder: (context, i) { + + return Stack( + children: [ + Column( + children: [ + const SizedBox(height: 5), + Container( + alignment: Alignment.center, + height: 65, + width: 65, + child: Image.file( + selectedImages[i], + fit: BoxFit.fill, + ), + ), + ], + ), + + SizedBox( + height: 60, + width: 70, + child: Align( + alignment: Alignment.topRight, + child: GestureDetector( + onTap: () { + setState(() { + selectedImages.removeAt(i); + }); + }, + child: Container( + height: 20, + width: 20, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onPrimary, + borderRadius: BorderRadius.circular(30), + ), + child: Icon( + Icons.cancel, + color: Theme.of(context).primaryColor, + size: 20 + ), + ), + ), + ), + ), + + ], + ); + }, + ) + ); + } + + Widget newMessageContent() { + return Align( + alignment: Alignment.bottomLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: selectedImages.isEmpty ? + 200.0 : + 270.0, + ), + child: Container( + padding: const EdgeInsets.only(left: 10,bottom: 10,top: 10), + width: double.infinity, + color: Theme.of(context).backgroundColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + + showSelectedImages(), + + Row( + children: [ + + GestureDetector( + onTap: (){ + setState(() { + showFilePicker = !showFilePicker; + }); + }, + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + child: Icon( + Icons.add, + color: Theme.of(context).colorScheme.onPrimary, + size: 20 + ), + ), + ), + + const SizedBox(width: 15,), + + Expanded( + child: TextField( + decoration: InputDecoration( + hintText: 'Write message...', + hintStyle: TextStyle( + color: Theme.of(context).hintColor, + ), + border: InputBorder.none, + ), + maxLines: null, + controller: msgController, + ), + ), + + const SizedBox(width: 15), + + SizedBox( + width: 45, + height: 45, + child: FittedBox( + child: FloatingActionButton( + onPressed: () async { + if (msgController.text == '' || selectedImages.isEmpty) { + return; + } + await sendMessage( + widget.conversation, + data: msgController.text != '' ? msgController.text : null, + files: selectedImages, + ); + messages = await getMessagesForThread(widget.conversation); + setState(() {}); + msgController.text = ''; + }, + child: Icon( + Icons.send, + color: Theme.of(context).colorScheme.onPrimary, + size: 22 + ), + backgroundColor: Theme.of(context).primaryColor, + ), + ), + ), + const SizedBox(width: 10), + ], + ), + + showFilePicker ? + FilePicker( + cameraHandle: () {}, + galleryHandleMultiple: (List images) async { + for (var img in images) { + selectedImages.add(File(img.path)); + } + setState(() { + showFilePicker = false; + }); + }, + fileHandle: () {}, + ) : + const SizedBox.shrink(), + ], + ), + ), + ), + ); + } } diff --git a/mobile/lib/views/main/conversation/list_item.dart b/mobile/lib/views/main/conversation/list_item.dart index a94e900..1cadb95 100644 --- a/mobile/lib/views/main/conversation/list_item.dart +++ b/mobile/lib/views/main/conversation/list_item.dart @@ -59,21 +59,20 @@ class _ConversationListItemState extends State { style: const TextStyle(fontSize: 16) ), recentMessage != null ? - const SizedBox(height: 2) : - const SizedBox.shrink() - , + const SizedBox(height: 2) : + const SizedBox.shrink(), recentMessage != null ? - Text( - recentMessage!.data, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade600, - fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold, - ), - ) : - const SizedBox.shrink(), + Text( + recentMessage!.getContent(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold, + ), + ) : + const SizedBox.shrink(), ], ), ), diff --git a/mobile/lib/views/main/conversation/settings.dart b/mobile/lib/views/main/conversation/settings.dart index 35939e1..6eda0ba 100644 --- a/mobile/lib/views/main/conversation/settings.dart +++ b/mobile/lib/views/main/conversation/settings.dart @@ -143,9 +143,9 @@ class _ConversationSettingsState extends State { label: const Text( 'Leave Conversation', style: TextStyle(fontSize: 16) -), -icon: const Icon(Icons.exit_to_app), -style: const ButtonStyle( + ), + icon: const Icon(Icons.exit_to_app), + style: const ButtonStyle( alignment: Alignment.centerLeft, ), onPressed: () { diff --git a/mobile/lib/views/main/conversation/settings_user_list_item.dart b/mobile/lib/views/main/conversation/settings_user_list_item.dart index a527e23..d4add1e 100644 --- a/mobile/lib/views/main/conversation/settings_user_list_item.dart +++ b/mobile/lib/views/main/conversation/settings_user_list_item.dart @@ -47,48 +47,48 @@ class _ConversationSettingsUserListItemState extends State( - itemBuilder: (context) => [ - PopupMenuItem( - value: 'admin', - // row with 2 children - child: Row( - children: const [ - Icon(Icons.admin_panel_settings), - SizedBox( - width: 10, - ), - Text('Promote to Admin') - ], - ), - ), - PopupMenuItem( - value: 'remove', - // row with 2 children - child: Row( - children: const [ - Icon(Icons.cancel), - SizedBox( - width: 10, - ), - Text('Remove from chat') - ], - ), - ), - ], - offset: const Offset(0, 0), - elevation: 2, - // on selected we show the dialog box - onSelected: (String value) { - // if value 1 show dialog - if (value == 'admin') { - print('admin'); - return; - // if value 2 show dialog - } - if (value == 'remove') { - print('remove'); - } - }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'admin', + // row with 2 children + child: Row( + children: const [ + Icon(Icons.admin_panel_settings), + SizedBox( + width: 10, + ), + Text('Promote to Admin') + ], + ), + ), + PopupMenuItem( + value: 'remove', + // row with 2 children + child: Row( + children: const [ + Icon(Icons.cancel), + SizedBox( + width: 10, + ), + Text('Remove from chat') + ], + ), + ), + ], + offset: const Offset(0, 0), + elevation: 2, + // on selected we show the dialog box + onSelected: (String value) { + // if value 1 show dialog + if (value == 'admin') { + print('admin'); + return; + // if value 2 show dialog + } + if (value == 'remove') { + print('remove'); + } + }, ); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 128580d..c744b58 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -57,6 +57,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + cross_file: + dependency: transitive + description: + name: cross_file + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.3+1" crypto: dependency: transitive description: @@ -111,6 +118,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.7" flutter_test: dependency: "direct dev" description: flutter @@ -142,6 +156,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.1" + image_picker: + dependency: "direct main" + description: + name: image_picker + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.5+3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.5+2" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.8" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.5+6" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.6.1" intl: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9a4d284..105e3a3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: qr_flutter: ^4.0.0 qr_code_scanner: ^1.0.1 sliding_up_panel: ^2.0.0+1 + image_picker: ^0.8.5+3 dev_dependencies: flutter_test: From f56ccfe9427241a9a7bda439ab1bf1cc100fff38 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Sun, 28 Aug 2022 16:43:36 +0930 Subject: [PATCH 2/6] WIP - Adding image support --- .gitignore | 1 + Backend/Api/Messages/CreateMessage.go | 42 +++-- Backend/Api/Messages/MessageThread.go | 14 ++ Backend/Api/Routes.go | 5 + Backend/Database/Messages.go | 9 +- Backend/Models/Attachments.go | 7 +- Backend/Models/Messages.go | 8 +- Backend/Util/Files.go | 46 +++++ mobile/lib/components/view_image.dart | 30 ++++ mobile/lib/models/conversations.dart | 6 +- mobile/lib/models/image_message.dart | 53 ++++-- mobile/lib/models/messages.dart | 49 ++++-- mobile/lib/models/text_messages.dart | 10 +- mobile/lib/utils/encryption/aes_helper.dart | 10 +- mobile/lib/utils/storage/database.dart | 1 + mobile/lib/utils/storage/messages.dart | 157 ++++++++++------- mobile/lib/utils/storage/write_file.dart | 26 +++ .../lib/views/main/conversation/detail.dart | 131 ++------------- .../lib/views/main/conversation/message.dart | 158 ++++++++++++++++++ mobile/pubspec.lock | 37 +++- mobile/pubspec.yaml | 2 + 21 files changed, 580 insertions(+), 222 deletions(-) create mode 100644 Backend/Util/Files.go create mode 100644 mobile/lib/components/view_image.dart create mode 100644 mobile/lib/utils/storage/write_file.dart create mode 100644 mobile/lib/views/main/conversation/message.dart diff --git a/.gitignore b/.gitignore index f7fec7f..cdb1a17 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /mobile/.env +/Backend/attachments/* diff --git a/Backend/Api/Messages/CreateMessage.go b/Backend/Api/Messages/CreateMessage.go index c233fc8..052f128 100644 --- a/Backend/Api/Messages/CreateMessage.go +++ b/Backend/Api/Messages/CreateMessage.go @@ -1,40 +1,54 @@ package Messages import ( + "encoding/base64" "encoding/json" "net/http" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" ) -type RawMessageData struct { +type rawMessageData struct { MessageData Models.MessageData `json:"message_data"` Messages []Models.Message `json:"message"` } +// CreateMessage sends a message func CreateMessage(w http.ResponseWriter, r *http.Request) { var ( - rawMessageData RawMessageData - err error + messagesData []rawMessageData + messageData rawMessageData + decodedFile []byte + fileName string + err error ) - err = json.NewDecoder(r.Body).Decode(&rawMessageData) + err = json.NewDecoder(r.Body).Decode(&messagesData) if err != nil { http.Error(w, "Error", http.StatusInternalServerError) return } - err = Database.CreateMessageData(&rawMessageData.MessageData) - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - err = Database.CreateMessages(&rawMessageData.Messages) - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return + for _, messageData = range messagesData { + if messageData.MessageData.Data == "" { + decodedFile, err = base64.StdEncoding.DecodeString(messageData.MessageData.Attachment.Data) + fileName, err = Util.WriteFile(decodedFile) + messageData.MessageData.Attachment.FilePath = fileName + } + + err = Database.CreateMessageData(&messageData.MessageData) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + err = Database.CreateMessages(&messageData.Messages) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } } w.WriteHeader(http.StatusOK) diff --git a/Backend/Api/Messages/MessageThread.go b/Backend/Api/Messages/MessageThread.go index 14fac7c..b9cb53e 100644 --- a/Backend/Api/Messages/MessageThread.go +++ b/Backend/Api/Messages/MessageThread.go @@ -2,6 +2,7 @@ package Messages import ( "encoding/json" + "fmt" "net/http" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" @@ -14,9 +15,11 @@ import ( func Messages(w http.ResponseWriter, r *http.Request) { var ( messages []Models.Message + message Models.Message urlVars map[string]string associationKey string returnJSON []byte + i int ok bool err error ) @@ -34,6 +37,17 @@ func Messages(w http.ResponseWriter, r *http.Request) { return } + for i, message = range messages { + if message.MessageData.AttachmentID == nil { + continue + } + + messages[i].MessageData.Attachment.ImageLink = fmt.Sprintf( + "http://192.168.1.5:8080/files/%s", + message.MessageData.Attachment.FilePath, + ) + } + returnJSON, err = json.MarshalIndent(messages, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 999a2f2..5892d46 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -44,6 +44,7 @@ func InitAPIEndpoints(router *mux.Router) { var ( api *mux.Router authAPI *mux.Router + fs http.Handler ) log.Println("Initializing API routes...") @@ -79,4 +80,8 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/message", Messages.CreateMessage).Methods("POST") authAPI.HandleFunc("/messages/{associationKey}", Messages.Messages).Methods("GET") + + // TODO: Add authentication to this route + fs = http.FileServer(http.Dir("./attachments/")) + router.PathPrefix("/files/").Handler(http.StripPrefix("/files/", fs)) } diff --git a/Backend/Database/Messages.go b/Backend/Database/Messages.go index 67cf8d3..f415c0e 100644 --- a/Backend/Database/Messages.go +++ b/Backend/Database/Messages.go @@ -7,7 +7,8 @@ import ( "gorm.io/gorm/clause" ) -func GetMessageById(id string) (Models.Message, error) { +// GetMessageByID gets a message +func GetMessageByID(id string) (Models.Message, error) { var ( message Models.Message err error @@ -20,6 +21,8 @@ func GetMessageById(id string) (Models.Message, error) { return message, err } +// GetMessagesByAssociationKey for getting whole thread +// TODO: Add pagination func GetMessagesByAssociationKey(associationKey string) ([]Models.Message, error) { var ( messages []Models.Message @@ -27,12 +30,14 @@ func GetMessagesByAssociationKey(associationKey string) ([]Models.Message, error ) err = DB.Preload("MessageData"). + Preload("MessageData.Attachment"). Find(&messages, "association_key = ?", associationKey). Error return messages, err } +// CreateMessage creates a message record func CreateMessage(message *Models.Message) error { var err error @@ -43,6 +48,7 @@ func CreateMessage(message *Models.Message) error { return err } +// CreateMessages creates multiple records func CreateMessages(messages *[]Models.Message) error { var err error @@ -53,6 +59,7 @@ func CreateMessages(messages *[]Models.Message) error { return err } +// DeleteMessage deletes a message func DeleteMessage(message *Models.Message) error { return DB.Session(&gorm.Session{FullSaveAssociations: true}). Delete(message). diff --git a/Backend/Models/Attachments.go b/Backend/Models/Attachments.go index 34304a7..739369e 100644 --- a/Backend/Models/Attachments.go +++ b/Backend/Models/Attachments.go @@ -3,6 +3,9 @@ package Models // Attachment holds the attachment data type Attachment struct { Base - FilePath string `gorm:"not null" json:"-"` - Mimetype string `gorm:"not null" json:"mimetype"` + FilePath string `gorm:"not null" json:"-"` + Mimetype string `gorm:"not null" json:"mimetype"` + Extension string `gorm:"not null" json:"extension"` + Data string `gorm:"-" json:"data"` + ImageLink string `gorm:"-" json:"image_link"` } diff --git a/Backend/Models/Messages.go b/Backend/Models/Messages.go index 9e995b5..bf05e3b 100644 --- a/Backend/Models/Messages.go +++ b/Backend/Models/Messages.go @@ -11,9 +11,11 @@ import ( // encrypted through the Message.SymmetricKey type MessageData struct { Base - Data string `gorm:"not null" json:"data"` // Stored encrypted - SenderID string `gorm:"not null" json:"sender_id"` // Stored encrypted - SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted + Data string ` json:"data"` // Stored encrypted + AttachmentID *uuid.UUID ` json:"attachment_id"` + Attachment Attachment ` json:"attachment"` + SenderID string `gorm:"not null" json:"sender_id"` // Stored encrypted + SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted } // Message holds data pertaining to each users' message diff --git a/Backend/Util/Files.go b/Backend/Util/Files.go new file mode 100644 index 0000000..4ee8b81 --- /dev/null +++ b/Backend/Util/Files.go @@ -0,0 +1,46 @@ +package Util + +import ( + "fmt" + "os" +) + +// WriteFile to disk +func WriteFile(contents []byte) (string, error) { + var ( + fileName string + filePath string + cwd string + f *os.File + err error + ) + + cwd, err = os.Getwd() + if err != nil { + return fileName, err + } + + fileName = RandomString(32) + + filePath = fmt.Sprintf( + "%s/attachments/%s", + cwd, + fileName, + ) + + f, err = os.Create(filePath) + + if err != nil { + return fileName, err + } + + defer f.Close() + + _, err = f.Write(contents) + + if err != nil { + return fileName, err + } + + return fileName, nil +} diff --git a/mobile/lib/components/view_image.dart b/mobile/lib/components/view_image.dart new file mode 100644 index 0000000..648fb00 --- /dev/null +++ b/mobile/lib/components/view_image.dart @@ -0,0 +1,30 @@ +import 'package:Envelope/components/custom_title_bar.dart'; +import 'package:Envelope/models/image_message.dart'; +import 'package:flutter/material.dart'; + +class ViewImage extends StatelessWidget { + const ViewImage({ + Key? key, + required this.message, + }) : super(key: key); + + final ImageMessage message; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: const CustomTitleBar( + title: Text(''), + showBack: true, + backgroundColor: Colors.black, + ), + body: Center( + child: Image.file( + message.file, + ), + ), + ); + } +} + diff --git a/mobile/lib/models/conversations.dart b/mobile/lib/models/conversations.dart index 9aa7c33..552955d 100644 --- a/mobile/lib/models/conversations.dart +++ b/mobile/lib/models/conversations.dart @@ -35,7 +35,7 @@ Future createConversation(String title, List friends, bool status: ConversationStatus.pending, isRead: true, ); - + await db.insert( 'conversations', conversation.toMap(), @@ -185,7 +185,7 @@ Future getTwoUserConversation(String userId) async { final List> maps = await db.rawQuery( ''' - SELECT conversations.* FROM conversations + SELECT conversations.* FROM conversations LEFT JOIN conversation_users ON conversation_users.conversation_id = conversations.id WHERE conversation_users.user_id = ? AND conversation_users.user_id != ? @@ -353,7 +353,7 @@ class Conversation { id: maps[0]['id'], symmetricKey: maps[0]['symmetric_key'], userSymmetricKey: maps[0]['user_symmetric_key'], - text: maps[0]['data'], + text: maps[0]['data'] ?? 'Image', senderId: maps[0]['sender_id'], senderUsername: maps[0]['sender_username'], associationKey: maps[0]['association_key'], diff --git a/mobile/lib/models/image_message.dart b/mobile/lib/models/image_message.dart index d430e2d..e092d36 100644 --- a/mobile/lib/models/image_message.dart +++ b/mobile/lib/models/image_message.dart @@ -1,6 +1,11 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; +import 'package:Envelope/utils/storage/session_cookie.dart'; +import 'package:Envelope/utils/storage/write_file.dart'; +import 'package:http/http.dart' as http; +import 'package:mime/mime.dart'; import 'package:pointycastle/pointycastle.dart'; import 'package:uuid/uuid.dart'; @@ -11,7 +16,7 @@ import '/utils/encryption/crypto_utils.dart'; import '/utils/strings.dart'; class ImageMessage extends Message { - String text; + File file; ImageMessage({ id, @@ -22,7 +27,7 @@ class ImageMessage extends Message { associationKey, createdAt, failedToSend, - required this.text, + required this.file, }) : super( id: id, symmetricKey: symmetricKey, @@ -34,7 +39,7 @@ class ImageMessage extends Message { failedToSend: failedToSend, ); - factory ImageMessage.fromJson(Map json, RSAPrivateKey privKey) { + static Future fromJson(Map json, RSAPrivateKey privKey) async { var userSymmetricKey = CryptoUtils.rsaDecrypt( base64.decode(json['symmetric_key']), privKey, @@ -50,9 +55,25 @@ class ImageMessage extends Message { base64.decode(json['message_data']['sender_id']), ); - var data = AesHelper.aesDecrypt( + var resp = await http.get( + Uri.parse(json['message_data']['attachment']['image_link']), + headers: { + 'cookie': await getSessionCookie(), + } + ); + + if (resp.statusCode != 200) { + throw Exception('Could not get attachment file'); + } + + var data = AesHelper.aesDecryptBytes( base64.decode(symmetricKey), - base64.decode(json['message_data']['data']), + resp.bodyBytes, + ); + + File file = await writeImage( + '${json['id']}', + data, ); return ImageMessage( @@ -64,16 +85,17 @@ class ImageMessage extends Message { associationKey: json['association_key'], createdAt: json['created_at'], failedToSend: false, - text: data, + file: file, ); } + @override Map toMap() { return { 'id': id, 'symmetric_key': symmetricKey, 'user_symmetric_key': userSymmetricKey, - 'data': text, + 'file': file.path, 'sender_id': senderId, 'sender_username': senderUsername, 'association_key': associationKey, @@ -82,26 +104,33 @@ class ImageMessage extends Message { }; } - Future> payloadJson(Conversation conversation, String messageId) async { + Future> payloadJson(Conversation conversation) async { final String messageDataId = (const Uuid()).v4(); final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); + final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32)); + List> messages = await super.payloadJsonBase( symmetricKey, + userSymmetricKey, conversation, - messageId, + id, messageDataId, ); - Map messageData = { + Map messageData = { 'id': messageDataId, - 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)), 'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), 'symmetric_key': AesHelper.aesEncrypt( userSymmetricKey, Uint8List.fromList(base64.encode(symmetricKey).codeUnits), ), + 'attachment': { + 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(file.readAsBytesSync())), + 'mimetype': lookupMimeType(file.path), + 'extension': getExtension(file.path), + } }; return { @@ -121,7 +150,7 @@ class ImageMessage extends Message { id: $id - data: $text, + file: ${file.path}, senderId: $senderId senderUsername: $senderUsername associationKey: $associationKey diff --git a/mobile/lib/models/messages.dart b/mobile/lib/models/messages.dart index 251236f..debb69f 100644 --- a/mobile/lib/models/messages.dart +++ b/mobile/lib/models/messages.dart @@ -1,15 +1,16 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; -import 'package:Envelope/models/conversation_users.dart'; -import 'package:Envelope/models/my_profile.dart'; -import 'package:Envelope/models/text_messages.dart'; -import 'package:Envelope/utils/encryption/aes_helper.dart'; -import 'package:Envelope/utils/encryption/crypto_utils.dart'; -import 'package:Envelope/utils/strings.dart'; import 'package:pointycastle/pointycastle.dart'; +import 'package:uuid/uuid.dart'; +import '/models/image_message.dart'; +import '/models/conversation_users.dart'; +import '/models/my_profile.dart'; +import '/models/text_messages.dart'; import '/models/conversations.dart'; +import '/utils/encryption/crypto_utils.dart'; import '/utils/storage/database.dart'; const messageTypeReceiver = 'receiver'; @@ -29,6 +30,23 @@ Future> getMessagesForThread(Conversation conversation) async { ); return List.generate(maps.length, (i) { + if (maps[i]['data'] == null) { + + File file = File(maps[i]['file']); + + return ImageMessage( + id: maps[i]['id'], + symmetricKey: maps[i]['symmetric_key'], + userSymmetricKey: maps[i]['user_symmetric_key'], + file: file, + senderId: maps[i]['sender_id'], + senderUsername: maps[i]['sender_username'], + associationKey: maps[i]['association_key'], + createdAt: maps[i]['created_at'], + failedToSend: maps[i]['failed_to_send'] == 1, + ); + } + return TextMessage( id: maps[i]['id'], symmetricKey: maps[i]['symmetric_key'], @@ -66,11 +84,11 @@ class Message { Future>> payloadJsonBase( Uint8List symmetricKey, + Uint8List userSymmetricKey, Conversation conversation, String messageId, String messageDataId, ) async { - MyProfile profile = await MyProfile.getProfile(); if (profile.publicKey == null) { throw Exception('Could not get profile.publicKey'); @@ -78,8 +96,6 @@ class Message { RSAPublicKey publicKey = profile.publicKey!; - final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32)); - List> messages = []; List conversationUsers = await getConversationUsers(conversation); @@ -87,8 +103,6 @@ class Message { ConversationUser user = conversationUsers[i]; if (profile.id == user.userId) { - id = user.id; - messages.add({ 'id': messageId, 'message_data_id': messageDataId, @@ -121,4 +135,17 @@ class Message { String getContent() { return ''; } + + Map toMap() { + return { + 'id': id, + 'symmetric_key': symmetricKey, + 'user_symmetric_key': userSymmetricKey, + 'sender_id': senderId, + 'sender_username': senderUsername, + 'association_key': associationKey, + 'created_at': createdAt, + 'failed_to_send': failedToSend ? 1 : 0, + }; + } } diff --git a/mobile/lib/models/text_messages.dart b/mobile/lib/models/text_messages.dart index 2dbb898..e9ba715 100644 --- a/mobile/lib/models/text_messages.dart +++ b/mobile/lib/models/text_messages.dart @@ -68,6 +68,7 @@ class TextMessage extends Message { ); } + @override Map toMap() { return { 'id': id, @@ -82,20 +83,23 @@ class TextMessage extends Message { }; } - Future> payloadJson(Conversation conversation, String messageId) async { + Future> payloadJson(Conversation conversation) async { final String messageDataId = (const Uuid()).v4(); final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); + final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32)); + List> messages = await super.payloadJsonBase( symmetricKey, + userSymmetricKey, conversation, - messageId, + id, messageDataId, ); Map messageData = { - 'id': messageDataId, + 'id': id, 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)), 'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), 'symmetric_key': AesHelper.aesEncrypt( diff --git a/mobile/lib/utils/encryption/aes_helper.dart b/mobile/lib/utils/encryption/aes_helper.dart index adad897..07f7d93 100644 --- a/mobile/lib/utils/encryption/aes_helper.dart +++ b/mobile/lib/utils/encryption/aes_helper.dart @@ -100,7 +100,7 @@ class AesHelper { return base64.encode(cipherIvBytes); } - static String aesDecrypt(dynamic password, Uint8List ciphertext, + static Uint8List aesDecryptBytes(dynamic password, Uint8List ciphertext, {String mode = cbcMode}) { Uint8List derivedKey; @@ -136,7 +136,13 @@ class AesHelper { Uint8List paddedText = _processBlocks(cipher, cipherBytes); Uint8List textBytes = unpad(paddedText); - return String.fromCharCodes(textBytes); + return textBytes; + } + + static String aesDecrypt(dynamic password, Uint8List ciphertext, + {String mode = cbcMode}) { + + return String.fromCharCodes(aesDecryptBytes(password, ciphertext, mode: mode)); } static Uint8List _processBlocks(BlockCipher cipher, Uint8List inp) { diff --git a/mobile/lib/utils/storage/database.dart b/mobile/lib/utils/storage/database.dart index e643f53..2dbf2c4 100644 --- a/mobile/lib/utils/storage/database.dart +++ b/mobile/lib/utils/storage/database.dart @@ -63,6 +63,7 @@ Future getDatabaseConnection() async { symmetric_key TEXT, user_symmetric_key TEXT, data TEXT, + file TEXT, sender_id TEXT, sender_username TEXT, association_key TEXT, diff --git a/mobile/lib/utils/storage/messages.dart b/mobile/lib/utils/storage/messages.dart index a8bb5fb..e9747ea 100644 --- a/mobile/lib/utils/storage/messages.dart +++ b/mobile/lib/utils/storage/messages.dart @@ -1,86 +1,122 @@ import 'dart:convert'; import 'dart:io'; -import 'package:Envelope/models/text_messages.dart'; +import 'package:Envelope/models/messages.dart'; +import 'package:Envelope/utils/storage/write_file.dart'; import 'package:http/http.dart' as http; import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; +import '/models/image_message.dart'; +import '/models/text_messages.dart'; import '/models/conversation_users.dart'; import '/models/conversations.dart'; -import '/models/messages.dart'; import '/models/my_profile.dart'; import '/utils/storage/database.dart'; import '/utils/storage/session_cookie.dart'; -Future sendMessage(Conversation conversation, { String? data, List? files }) async { +Future sendMessage(Conversation conversation, { + String? data, + List files = const [] +}) async { + MyProfile profile = await MyProfile.getProfile(); var uuid = const Uuid(); - final String messageId = uuid.v4(); ConversationUser currentUser = await getConversationUser(conversation, profile.id); - List> messagesToAdd = []; + List messages = []; + List> messagesToSend = []; + + final db = await getDatabaseConnection(); if (data != null) { - messagesToAdd.add({ 'text': data }); - } + TextMessage message = TextMessage( + id: uuid.v4(), + symmetricKey: '', + userSymmetricKey: '', + senderId: currentUser.userId, + senderUsername: profile.username, + associationKey: currentUser.associationKey, + createdAt: DateTime.now().toIso8601String(), + failedToSend: false, + text: data, + ); + + messages.add(message); + messagesToSend.add(await message.payloadJson( + conversation, + )); + + await db.insert( + 'messages', + message.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); - if (files != null && files.isNotEmpty) { - for (File file in files) { - messagesToAdd.add({ 'file': file }); - } } - var message = TextMessage( - id: messageId, - symmetricKey: '', - userSymmetricKey: '', - senderId: currentUser.userId, - senderUsername: profile.username, - text: data!, - associationKey: currentUser.associationKey, - createdAt: DateTime.now().toIso8601String(), - failedToSend: false, - ); + for (File file in files) { - final db = await getDatabaseConnection(); + String messageId = uuid.v4(); - await db.insert( - 'messages', - message.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); + File writtenFile = await writeImage( + messageId, + file.readAsBytesSync(), + ); + + ImageMessage message = ImageMessage( + id: messageId, + symmetricKey: '', + userSymmetricKey: '', + senderId: currentUser.userId, + senderUsername: profile.username, + associationKey: currentUser.associationKey, + createdAt: DateTime.now().toIso8601String(), + failedToSend: false, + file: writtenFile, + ); + + messages.add(message); + messagesToSend.add(await message.payloadJson( + conversation, + )); + + await db.insert( + 'messages', + message.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } String sessionCookie = await getSessionCookie(); - message.payloadJson(conversation, messageId) - .then((messageJson) async { - return http.post( - await MyProfile.getServerUrl('api/v1/auth/message'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - 'cookie': sessionCookie, - }, - body: messageJson, - ); - }) - .then((resp) { - if (resp.statusCode != 200) { - throw Exception('Unable to send message'); - } - }) - .catchError((exception) { - message.failedToSend = true; - db.update( - 'messages', - message.toMap(), - where: 'id = ?', - whereArgs: [message.id], - ); - throw exception; - }); + return http.post( + await MyProfile.getServerUrl('api/v1/auth/message'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'cookie': sessionCookie, + }, + body: jsonEncode(messagesToSend), + ) + .then((resp) { + if (resp.statusCode != 200) { + throw Exception('Unable to send message'); + } + }) + .catchError((exception) { + for (Message message in messages) { + message.failedToSend = true; + db.update( + 'messages', + message.toMap(), + where: 'id = ?', + whereArgs: [message.id], + ); + } + throw exception; + }); } Future updateMessageThread(Conversation conversation, {MyProfile? profile}) async { @@ -103,10 +139,17 @@ Future updateMessageThread(Conversation conversation, {MyProfile? profile} final db = await getDatabaseConnection(); for (var i = 0; i < messageThreadJson.length; i++) { - var message = TextMessage.fromJson( - messageThreadJson[i] as Map, + var messageJson = messageThreadJson[i] as Map; + + var message = messageJson['message_data']['attachment_id'] != null ? + await ImageMessage.fromJson( + messageJson, profile.privateKey!, - ); + ) : + TextMessage.fromJson( + messageJson, + profile.privateKey!, + ); ConversationUser messageUser = await getConversationUser(conversation, message.senderId); message.senderUsername = messageUser.username; diff --git a/mobile/lib/utils/storage/write_file.dart b/mobile/lib/utils/storage/write_file.dart new file mode 100644 index 0000000..36a6860 --- /dev/null +++ b/mobile/lib/utils/storage/write_file.dart @@ -0,0 +1,26 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path_provider/path_provider.dart'; + +Future get _localPath async { + final directory = await getApplicationDocumentsDirectory(); + + return directory.path; +} + +Future _localFile(String fileName) async { + final path = await _localPath; + return File('$path/$fileName'); +} + +Future writeImage(String fileName, Uint8List data) async { + final file = await _localFile(fileName); + + // Write the file + return file.writeAsBytes(data); +} + +String getExtension(String fileName) { + return fileName.split('.').last; +} diff --git a/mobile/lib/views/main/conversation/detail.dart b/mobile/lib/views/main/conversation/detail.dart index be284e4..d779392 100644 --- a/mobile/lib/views/main/conversation/detail.dart +++ b/mobile/lib/views/main/conversation/detail.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'package:Envelope/models/image_message.dart'; import 'package:Envelope/models/text_messages.dart'; +import 'package:Envelope/views/main/conversation/message.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; @@ -87,38 +89,6 @@ class _ConversationDetailState extends State { fetchMessages(); } - Widget usernameOrFailedToSend(int index) { - if (messages[index].senderUsername != profile.username) { - return Text( - messages[index].senderUsername, - style: TextStyle( - fontSize: 12, - color: Colors.grey[300], - ), - ); - } - - if (messages[index].failedToSend) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: const [ - Icon( - Icons.warning_rounded, - color: Colors.red, - size: 20, - ), - Text( - 'Failed to send', - style: TextStyle(color: Colors.red, fontSize: 12), - textAlign: TextAlign.right, - ), - ], - ); - } - - return const SizedBox.shrink(); - } - Widget messagesView() { if (messages.isEmpty) { return const Center( @@ -129,94 +99,29 @@ class _ConversationDetailState extends State { return ListView.builder( itemCount: messages.length, shrinkWrap: true, - padding: const EdgeInsets.only(top: 10,bottom: 90), + padding: EdgeInsets.only( + top: 10, + bottom: selectedImages.isEmpty ? 90 : 160, + ), reverse: true, itemBuilder: (context, index) { - return Container( - padding: const EdgeInsets.only(left: 14,right: 14,top: 0,bottom: 0), - child: Align( - alignment: ( - messages[index].senderUsername == profile.username ? - Alignment.topRight : - Alignment.topLeft - ), - child: Column( - crossAxisAlignment: messages[index].senderUsername == profile.username ? - CrossAxisAlignment.end : - CrossAxisAlignment.start, - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: ( - messages[index].senderUsername == profile.username ? - Theme.of(context).colorScheme.primary : - Theme.of(context).colorScheme.tertiary - ), - ), - padding: const EdgeInsets.all(12), - child: messageContent(index), - ), - const SizedBox(height: 1.5), - Row( - mainAxisAlignment: messages[index].senderUsername == profile.username ? - MainAxisAlignment.end : - MainAxisAlignment.start, - children: [ - const SizedBox(width: 10), - usernameOrFailedToSend(index), - ], - ), - const SizedBox(height: 1.5), - Row( - mainAxisAlignment: messages[index].senderUsername == profile.username ? - MainAxisAlignment.end : - MainAxisAlignment.start, - children: [ - const SizedBox(width: 10), - Text( - convertToAgo(messages[index].createdAt), - textAlign: messages[index].senderUsername == profile.username ? - TextAlign.left : - TextAlign.right, - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - ), - ), - ], - ), - index != 0 ? - const SizedBox(height: 20) : - const SizedBox.shrink(), - ], - ) - ), + return ConversationMessage( + message: messages[index], + profile: profile, + index: index, ); }, ); } - Widget messageContent(int index) { - return Text( - messages[index].getContent(), - style: TextStyle( - fontSize: 15, - color: messages[index].senderUsername == profile.username ? - Theme.of(context).colorScheme.onPrimary : - Theme.of(context).colorScheme.onTertiary, - ) - ); - } - Widget showSelectedImages() { if (selectedImages.isEmpty) { return const SizedBox.shrink(); } - return SizedBox( - height: 80, - width: double.infinity, + return SizedBox( + height: 80, + width: double.infinity, child: ListView.builder( itemCount: selectedImages.length, shrinkWrap: true, @@ -289,7 +194,7 @@ class _ConversationDetailState extends State { padding: const EdgeInsets.only(left: 10,bottom: 10,top: 10), width: double.infinity, color: Theme.of(context).backgroundColor, - child: Column( + child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -343,11 +248,11 @@ class _ConversationDetailState extends State { child: FittedBox( child: FloatingActionButton( onPressed: () async { - if (msgController.text == '' || selectedImages.isEmpty) { + if (msgController.text == '' && selectedImages.isEmpty) { return; } await sendMessage( - widget.conversation, + widget.conversation, data: msgController.text != '' ? msgController.text : null, files: selectedImages, ); @@ -368,7 +273,7 @@ class _ConversationDetailState extends State { ], ), - showFilePicker ? + showFilePicker ? FilePicker( cameraHandle: () {}, galleryHandleMultiple: (List images) async { @@ -380,7 +285,7 @@ class _ConversationDetailState extends State { }); }, fileHandle: () {}, - ) : + ) : const SizedBox.shrink(), ], ), diff --git a/mobile/lib/views/main/conversation/message.dart b/mobile/lib/views/main/conversation/message.dart new file mode 100644 index 0000000..5b4f42d --- /dev/null +++ b/mobile/lib/views/main/conversation/message.dart @@ -0,0 +1,158 @@ +import 'package:Envelope/components/view_image.dart'; +import 'package:Envelope/models/image_message.dart'; +import 'package:Envelope/models/my_profile.dart'; +import 'package:Envelope/utils/time.dart'; +import 'package:flutter/material.dart'; + +import '/models/messages.dart'; + +@immutable +class ConversationMessage extends StatelessWidget { + const ConversationMessage({ + Key? key, + required this.message, + required this.profile, + required this.index, + }) : super(key: key); + + final Message message; + final MyProfile profile; + final int index; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(left: 14,right: 14,top: 0,bottom: 0), + child: Align( + alignment: ( + message.senderUsername == profile.username ? + Alignment.topRight : + Alignment.topLeft + ), + child: Column( + crossAxisAlignment: message.senderUsername == profile.username ? + CrossAxisAlignment.end : + CrossAxisAlignment.start, + children: [ + + messageContent(context), + + const SizedBox(height: 1.5), + + Row( + mainAxisAlignment: message.senderUsername == profile.username ? + MainAxisAlignment.end : + MainAxisAlignment.start, + children: [ + const SizedBox(width: 10), + usernameOrFailedToSend(index), + ], + ), + + const SizedBox(height: 1.5), + + Row( + mainAxisAlignment: message.senderUsername == profile.username ? + MainAxisAlignment.end : + MainAxisAlignment.start, + children: [ + const SizedBox(width: 10), + Text( + convertToAgo(message.createdAt), + textAlign: message.senderUsername == profile.username ? + TextAlign.left : + TextAlign.right, + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + ], + ), + + index != 0 ? + const SizedBox(height: 20) : + const SizedBox.shrink(), + ], + ) + ), + ); + } + + Widget messageContent(BuildContext context) { + if (message.runtimeType == ImageMessage) { + return GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (context) { + return ViewImage( + message: (message as ImageMessage) + ); + })); + }, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 350, maxWidth: 250), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.file( + (message as ImageMessage).file, + fit: BoxFit.fill, + ), + ), + ), + ); + } + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: ( + message.senderUsername == profile.username ? + Theme.of(context).colorScheme.primary : + Theme.of(context).colorScheme.tertiary + ), + ), + padding: const EdgeInsets.all(12), + child: Text( + message.getContent(), + style: TextStyle( + fontSize: 15, + color: message.senderUsername == profile.username ? + Theme.of(context).colorScheme.onPrimary : + Theme.of(context).colorScheme.onTertiary, + ), + ), + ); + } + + Widget usernameOrFailedToSend(int index) { + if (message.senderUsername != profile.username) { + return Text( + message.senderUsername, + style: TextStyle( + fontSize: 12, + color: Colors.grey[300], + ), + ); + } + + if (message.failedToSend) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: const [ + Icon( + Icons.warning_rounded, + color: Colors.red, + size: 20, + ), + Text( + 'Failed to send', + style: TextStyle(color: Colors.red, fontSize: 12), + textAlign: TextAlign.right, + ), + ], + ); + } + + return const SizedBox.shrink(); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index c744b58..d22d858 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -233,6 +233,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" + mime: + dependency: "direct main" + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" path: dependency: "direct main" description: @@ -240,6 +247,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.20" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" path_provider_linux: dependency: transitive description: @@ -247,6 +275,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.6" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" path_provider_platform_interface: dependency: transitive description: @@ -478,4 +513,4 @@ packages: version: "0.2.0+1" sdks: dart: ">=2.17.0 <3.0.0" - flutter: ">=2.8.0" + flutter: ">=2.8.1" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 105e3a3..78fa8b3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -26,6 +26,8 @@ dependencies: qr_code_scanner: ^1.0.1 sliding_up_panel: ^2.0.0+1 image_picker: ^0.8.5+3 + path_provider: ^2.0.11 + mime: ^1.0.2 dev_dependencies: flutter_test: From b1ad911e2d6d2f8273172be6034a154a4886149c Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Mon, 29 Aug 2022 20:27:48 +0930 Subject: [PATCH 3/6] WIP - Adding images for conversation & user icons Todo: Save image on server for conversations & profiles --- .../lib/components/custom_circle_avatar.dart | 117 ++++++---- mobile/lib/components/file_picker.dart | 17 +- mobile/lib/components/user_search_result.dart | 1 - mobile/lib/components/view_image.dart | 11 +- mobile/lib/models/conversations.dart | 31 ++- mobile/lib/utils/storage/database.dart | 3 +- mobile/lib/utils/storage/messages.dart | 1 - .../conversation/create_add_users_list.dart | 1 - .../lib/views/main/conversation/detail.dart | 36 ++- .../views/main/conversation/edit_details.dart | 208 +++++++++++------- mobile/lib/views/main/conversation/list.dart | 56 ++--- .../views/main/conversation/list_item.dart | 2 +- .../lib/views/main/conversation/message.dart | 153 ++++++++++--- .../lib/views/main/conversation/settings.dart | 25 ++- .../conversation/settings_user_list_item.dart | 1 - mobile/lib/views/main/friend/list_item.dart | 1 - .../views/main/friend/request_list_item.dart | 1 - mobile/lib/views/main/profile/profile.dart | 5 +- 18 files changed, 443 insertions(+), 227 deletions(-) diff --git a/mobile/lib/components/custom_circle_avatar.dart b/mobile/lib/components/custom_circle_avatar.dart index bf7d1b8..1680fec 100644 --- a/mobile/lib/components/custom_circle_avatar.dart +++ b/mobile/lib/components/custom_circle_avatar.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; enum AvatarTypes { @@ -6,74 +8,107 @@ enum AvatarTypes { image, } -class CustomCircleAvatar extends StatefulWidget { +class CustomCircleAvatar extends StatelessWidget { final String? initials; final Icon? icon; - final String? imagePath; + final File? image; + final Function ()? editImageCallback; final double radius; const CustomCircleAvatar({ - Key? key, - this.initials, - this.icon, - this.imagePath, - this.radius = 20, + Key? key, + this.initials, + this.icon, + this.image, + this.editImageCallback, + this.radius = 20, }) : super(key: key); - @override - _CustomCircleAvatarState createState() => _CustomCircleAvatarState(); -} - -class _CustomCircleAvatarState extends State{ - AvatarTypes type = AvatarTypes.image; - - @override - void initState() { - super.initState(); + Widget avatar() { + AvatarTypes? type; - if (widget.imagePath != null) { - type = AvatarTypes.image; - return; - } + if (icon != null) { + type = AvatarTypes.icon; + } - if (widget.icon != null) { - type = AvatarTypes.icon; - return; - } + if (initials != null) { + type = AvatarTypes.initials; + } - if (widget.initials != null) { - type = AvatarTypes.initials; - return; - } + if (image != null) { + type = AvatarTypes.image; + } + if (type == null) { throw ArgumentError('Invalid arguments passed to CustomCircleAvatar'); - } + } - Widget avatar() { if (type == AvatarTypes.initials) { return CircleAvatar( - backgroundColor: Colors.grey[300], - child: Text(widget.initials!), - radius: widget.radius, + backgroundColor: Colors.grey[300], + child: Text(initials!), + radius: radius, ); } if (type == AvatarTypes.icon) { return CircleAvatar( - backgroundColor: Colors.grey[300], - child: widget.icon, - radius: widget.radius, + backgroundColor: Colors.grey[300], + child: icon, + radius: radius, ); } - return CircleAvatar( - backgroundImage: AssetImage(widget.imagePath!), - radius: widget.radius, + return Container( + width: radius * 2, + height: radius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + image: Image.file(image!).image, + fit: BoxFit.fill + ), + ), + ); + } + + Widget editIcon(BuildContext context) { + if (editImageCallback == null) { + return const SizedBox.shrink(); + } + + return SizedBox( + height: (radius * 2), + width: (radius * 2), + child: Align( + alignment: Alignment.bottomRight, + child: GestureDetector( + onTap: editImageCallback, + child: Container( + height: (radius / 2) + (radius / 7), + width: (radius / 2) + (radius / 7), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(30), + ), + child: Icon( + Icons.add, + color: Theme.of(context).primaryColor, + size: radius / 2 + ), + ), + ), + ), ); } @override Widget build(BuildContext context) { - return avatar(); + return Stack( + children: [ + avatar(), + editIcon(context), + ] + ); } } diff --git a/mobile/lib/components/file_picker.dart b/mobile/lib/components/file_picker.dart index 6c56310..7160e0d 100644 --- a/mobile/lib/components/file_picker.dart +++ b/mobile/lib/components/file_picker.dart @@ -10,9 +10,10 @@ class FilePicker extends StatelessWidget { this.fileHandle, }) : super(key: key); - final Function()? cameraHandle; - final Function()? galleryHandleSingle; + final Function(XFile image)? cameraHandle; + final Function(XFile image)? galleryHandleSingle; final Function(List images)? galleryHandleMultiple; + // TODO: Implement. Perhaps after first release? final Function()? fileHandle; final ImagePicker _picker = ImagePicker(); @@ -27,7 +28,12 @@ class FilePicker extends StatelessWidget { _filePickerSelection( hasHandle: cameraHandle != null, icon: Icons.camera_alt, - onTap: () { + onTap: () async { + final XFile? image = await _picker.pickImage(source: ImageSource.camera); + if (image == null) { + return; + } + cameraHandle!(image); }, context: context, ), @@ -36,7 +42,10 @@ class FilePicker extends StatelessWidget { icon: Icons.image, onTap: () async { final XFile? image = await _picker.pickImage(source: ImageSource.gallery); - print(image); + if (image == null) { + return; + } + galleryHandleSingle!(image); }, context: context, ), diff --git a/mobile/lib/components/user_search_result.dart b/mobile/lib/components/user_search_result.dart index c8c7b95..2885e7e 100644 --- a/mobile/lib/components/user_search_result.dart +++ b/mobile/lib/components/user_search_result.dart @@ -41,7 +41,6 @@ class _UserSearchResultState extends State{ CustomCircleAvatar( initials: widget.user.username[0].toUpperCase(), icon: const Icon(Icons.person, size: 80), - imagePath: null, radius: 50, ), const SizedBox(height: 10), diff --git a/mobile/lib/components/view_image.dart b/mobile/lib/components/view_image.dart index 648fb00..2fe3fcf 100644 --- a/mobile/lib/components/view_image.dart +++ b/mobile/lib/components/view_image.dart @@ -20,9 +20,14 @@ class ViewImage extends StatelessWidget { backgroundColor: Colors.black, ), body: Center( - child: Image.file( - message.file, - ), + child: InteractiveViewer( + panEnabled: false, + minScale: 1, + maxScale: 4, + child: Image.file( + message.file, + ), + ) ), ); } diff --git a/mobile/lib/models/conversations.dart b/mobile/lib/models/conversations.dart index 552955d..f5c7134 100644 --- a/mobile/lib/models/conversations.dart +++ b/mobile/lib/models/conversations.dart @@ -1,12 +1,15 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; import 'package:Envelope/models/messages.dart'; import 'package:Envelope/models/text_messages.dart'; +import 'package:mime/mime.dart'; import 'package:pointycastle/export.dart'; import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; +import '../utils/storage/write_file.dart'; import '/models/conversation_users.dart'; import '/models/friends.dart'; import '/models/my_profile.dart'; @@ -143,6 +146,11 @@ Future getConversationById(String id) async { throw ArgumentError('Invalid user id'); } + File? file; + if (maps[0]['file'] != null && maps[0]['file'] != '') { + file = File(maps[0]['file']); + } + return Conversation( id: maps[0]['id'], userId: maps[0]['user_id'], @@ -152,6 +160,7 @@ Future getConversationById(String id) async { twoUser: maps[0]['two_user'] == 1, status: ConversationStatus.values[maps[0]['status']], isRead: maps[0]['is_read'] == 1, + icon: file, ); } @@ -165,6 +174,12 @@ Future> getConversations() async { ); return List.generate(maps.length, (i) { + + File? file; + if (maps[i]['file'] != null && maps[i]['file'] != '') { + file = File(maps[i]['file']); + } + return Conversation( id: maps[i]['id'], userId: maps[i]['user_id'], @@ -174,6 +189,7 @@ Future> getConversations() async { twoUser: maps[i]['two_user'] == 1, status: ConversationStatus.values[maps[i]['status']], isRead: maps[i]['is_read'] == 1, + icon: file, ); }); } @@ -220,6 +236,7 @@ class Conversation { bool twoUser; ConversationStatus status; bool isRead; + File? icon; Conversation({ required this.id, @@ -230,6 +247,7 @@ class Conversation { required this.twoUser, required this.status, required this.isRead, + this.icon, }); @@ -298,13 +316,23 @@ class Conversation { }); } - return { + Map returnData = { 'id': id, 'name': AesHelper.aesEncrypt(symKey, Uint8List.fromList(name.codeUnits)), 'users': await getEncryptedConversationUsers(this, symKey), 'two_user': AesHelper.aesEncrypt(symKey, Uint8List.fromList((twoUser ? 'true' : 'false').codeUnits)), 'user_conversations': userConversations, }; + + if (icon != null) { + returnData['attachment'] = { + 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())), + 'mimetype': lookupMimeType(icon!.path), + 'extension': getExtension(icon!.path), + }; + } + + return returnData; } Map toMap() { @@ -317,6 +345,7 @@ class Conversation { 'two_user': twoUser ? 1 : 0, 'status': status.index, 'is_read': isRead ? 1 : 0, + 'file': icon != null ? icon!.path : null, }; } diff --git a/mobile/lib/utils/storage/database.dart b/mobile/lib/utils/storage/database.dart index 2dbf2c4..ea22067 100644 --- a/mobile/lib/utils/storage/database.dart +++ b/mobile/lib/utils/storage/database.dart @@ -39,7 +39,8 @@ Future getDatabaseConnection() async { name TEXT, two_user INTEGER, status INTEGER, - is_read INTEGER + is_read INTEGER, + file TEXT ); '''); diff --git a/mobile/lib/utils/storage/messages.dart b/mobile/lib/utils/storage/messages.dart index e9747ea..fd7ced7 100644 --- a/mobile/lib/utils/storage/messages.dart +++ b/mobile/lib/utils/storage/messages.dart @@ -54,7 +54,6 @@ Future sendMessage(Conversation conversation, { message.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); - } for (File file in files) { diff --git a/mobile/lib/views/main/conversation/create_add_users_list.dart b/mobile/lib/views/main/conversation/create_add_users_list.dart index 6c53ac4..fec37f6 100644 --- a/mobile/lib/views/main/conversation/create_add_users_list.dart +++ b/mobile/lib/views/main/conversation/create_add_users_list.dart @@ -40,7 +40,6 @@ class _ConversationAddFriendItemState extends State { children: [ CustomCircleAvatar( initials: widget.friend.username[0].toUpperCase(), - imagePath: null, ), const SizedBox(width: 16), Expanded( diff --git a/mobile/lib/views/main/conversation/detail.dart b/mobile/lib/views/main/conversation/detail.dart index d779392..5eabbec 100644 --- a/mobile/lib/views/main/conversation/detail.dart +++ b/mobile/lib/views/main/conversation/detail.dart @@ -1,7 +1,5 @@ import 'dart:io'; -import 'package:Envelope/models/image_message.dart'; -import 'package:Envelope/models/text_messages.dart'; import 'package:Envelope/views/main/conversation/message.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; @@ -12,7 +10,6 @@ import '/models/conversations.dart'; import '/models/messages.dart'; import '/models/my_profile.dart'; import '/utils/storage/messages.dart'; -import '/utils/time.dart'; import '/views/main/conversation/settings.dart'; class ConversationDetail extends StatefulWidget{ @@ -24,7 +21,6 @@ class ConversationDetail extends StatefulWidget{ @override _ConversationDetailState createState() => _ConversationDetailState(); - } class _ConversationDetailState extends State { @@ -54,18 +50,18 @@ class _ConversationDetailState extends State { ), showBack: true, rightHandButton: IconButton( - onPressed: (){ - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => ConversationSettings( - conversation: widget.conversation - )), - ); - }, - icon: Icon( - Icons.settings, - color: Theme.of(context).appBarTheme.iconTheme?.color, - ), - ), + onPressed: (){ + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ConversationSettings( + conversation: widget.conversation + )), + ); + }, + icon: Icon( + Icons.settings, + color: Theme.of(context).appBarTheme.iconTheme?.color, + ), + ), ), body: Stack( @@ -257,8 +253,10 @@ class _ConversationDetailState extends State { files: selectedImages, ); messages = await getMessagesForThread(widget.conversation); - setState(() {}); - msgController.text = ''; + setState(() { + msgController.text = ''; + selectedImages = []; + }); }, child: Icon( Icons.send, @@ -275,7 +273,7 @@ class _ConversationDetailState extends State { showFilePicker ? FilePicker( - cameraHandle: () {}, + cameraHandle: (XFile image) {}, galleryHandleMultiple: (List images) async { for (var img in images) { selectedImages.add(File(img.path)); diff --git a/mobile/lib/views/main/conversation/edit_details.dart b/mobile/lib/views/main/conversation/edit_details.dart index a0441b9..4d4b281 100644 --- a/mobile/lib/views/main/conversation/edit_details.dart +++ b/mobile/lib/views/main/conversation/edit_details.dart @@ -1,10 +1,14 @@ +import 'dart:io'; + +import 'package:Envelope/components/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import '/components/custom_circle_avatar.dart'; import '/models/conversations.dart'; class ConversationEditDetails extends StatefulWidget { - final Function(String conversationName) saveCallback; + final Function(String conversationName, File? conversationIcon) saveCallback; final Conversation? conversation; const ConversationEditDetails({ Key? key, @@ -22,11 +26,15 @@ class _ConversationEditDetails extends State { List conversations = []; TextEditingController conversationNameController = TextEditingController(); + File? conversationIcon; + + bool showFileSelector = false; @override void initState() { if (widget.conversation != null) { conversationNameController.text = widget.conversation!.name; + conversationIcon = widget.conversation!.icon; } super.initState(); } @@ -54,94 +62,128 @@ class _ConversationEditDetails extends State { ); return Scaffold( - appBar: AppBar( - elevation: 0, - automaticallyImplyLeading: false, - flexibleSpace: SafeArea( - child: Container( - padding: const EdgeInsets.only(right: 16), - child: Row( - children: [ - IconButton( - onPressed: (){ - Navigator.pop(context); - }, - icon: const Icon(Icons.arrow_back), - ), - const SizedBox(width: 2,), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.conversation != null ? - widget.conversation!.name + " Settings" : - 'Add Conversation', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600 - ), - ), - ], - ), - ), - ], - ), + appBar: AppBar( + elevation: 0, + automaticallyImplyLeading: false, + flexibleSpace: SafeArea( + child: Container( + padding: const EdgeInsets.only(right: 16), + child: Row( + children: [ + IconButton( + onPressed: (){ + Navigator.pop(context); + }, + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 2,), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.conversation != null ? + widget.conversation!.name + ' Settings' : + 'Add Conversation', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600 + ), + ), + ], + ), ), + ], + ), ), ), - body: Center( - child: Padding( - padding: const EdgeInsets.only( - top: 50, - left: 25, - right: 25, + ), + + body: Center( + child: Padding( + padding: const EdgeInsets.only( + top: 50, + left: 25, + right: 25, + ), + child: Form( + key: _formKey, + child: Column( + children: [ + + CustomCircleAvatar( + icon: const Icon(Icons.people, size: 60), + image: conversationIcon, + radius: 50, + editImageCallback: () { + setState(() { + showFileSelector = true; + }); + }, ), - child: Form( - key: _formKey, - child: Column( - children: [ - const CustomCircleAvatar( - icon: const Icon(Icons.people, size: 60), - imagePath: null, - radius: 50, - ), - const SizedBox(height: 30), - TextFormField( - controller: conversationNameController, - textAlign: TextAlign.center, - decoration: InputDecoration( - hintText: 'Title', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Add a title'; - } - return null; - }, - ), - const SizedBox(height: 30), - ElevatedButton( - style: buttonStyle, - onPressed: () { - if (!_formKey.currentState!.validate()) { - // TODO: Show error here - return; - } - - widget.saveCallback(conversationNameController.text); - }, - child: const Text('Save'), - ), - ], + + const SizedBox(height: 20), + + showFileSelector ? + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: FilePicker( + cameraHandle: (XFile image) { + setState(() { + conversationIcon = File(image.path); + showFileSelector = false; + }); + }, + galleryHandleSingle: (XFile image) async { + setState(() { + conversationIcon = File(image.path); + showFileSelector = false; + }); + }, + ), + ) : + const SizedBox(height: 10), + + TextFormField( + controller: conversationNameController, + textAlign: TextAlign.center, + decoration: InputDecoration( + hintText: 'Title', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, ), - ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Add a title'; + } + return null; + }, + ), + + const SizedBox(height: 30), + + ElevatedButton( + style: buttonStyle, + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + widget.saveCallback( + conversationNameController.text, + conversationIcon, + ); + }, + child: const Text('Save'), + ), + + ], + ), ), + ), ), ); } diff --git a/mobile/lib/views/main/conversation/list.dart b/mobile/lib/views/main/conversation/list.dart index 62be875..be4b494 100644 --- a/mobile/lib/views/main/conversation/list.dart +++ b/mobile/lib/views/main/conversation/list.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:Envelope/components/custom_title_bar.dart'; import 'package:Envelope/models/friends.dart'; import 'package:Envelope/utils/storage/conversations.dart'; @@ -61,35 +63,35 @@ class _ConversationListState extends State { ), ), floatingActionButton: Padding( - padding: const EdgeInsets.only(right: 10, bottom: 10), - child: FloatingActionButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => ConversationEditDetails( - saveCallback: (String conversationName) { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => ConversationAddFriendsList( - friends: friends, - saveCallback: (List friendsSelected) async { - Conversation conversation = await createConversation( - conversationName, - friendsSelected, - false, - ); + padding: const EdgeInsets.only(right: 10, bottom: 10), + child: FloatingActionButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ConversationEditDetails( + saveCallback: (String conversationName, File? file) { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ConversationAddFriendsList( + friends: friends, + saveCallback: (List friendsSelected) async { + Conversation conversation = await createConversation( + conversationName, + friendsSelected, + false, + ); - uploadConversation(conversation, context); + uploadConversation(conversation, context); - Navigator.of(context).popUntil((route) => route.isFirst); - Navigator.push(context, MaterialPageRoute(builder: (context){ - return ConversationDetail( - conversation: conversation, - ); - })); - }, - )) - ); - }, - )), + Navigator.of(context).popUntil((route) => route.isFirst); + Navigator.push(context, MaterialPageRoute(builder: (context){ + return ConversationDetail( + conversation: conversation, + ); + })); + }, + )) + ); + }, + )), ).then(onGoBack); }, backgroundColor: Theme.of(context).colorScheme.primary, diff --git a/mobile/lib/views/main/conversation/list_item.dart b/mobile/lib/views/main/conversation/list_item.dart index 1cadb95..670919e 100644 --- a/mobile/lib/views/main/conversation/list_item.dart +++ b/mobile/lib/views/main/conversation/list_item.dart @@ -43,7 +43,7 @@ class _ConversationListItemState extends State { children: [ CustomCircleAvatar( initials: widget.conversation.name[0].toUpperCase(), - imagePath: null, + image: widget.conversation.icon, ), const SizedBox(width: 16), Expanded( diff --git a/mobile/lib/views/main/conversation/message.dart b/mobile/lib/views/main/conversation/message.dart index 5b4f42d..ee98aa5 100644 --- a/mobile/lib/views/main/conversation/message.dart +++ b/mobile/lib/views/main/conversation/message.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import '/models/messages.dart'; @immutable -class ConversationMessage extends StatelessWidget { +class ConversationMessage extends StatefulWidget { const ConversationMessage({ Key? key, required this.message, @@ -19,18 +19,71 @@ class ConversationMessage extends StatelessWidget { final MyProfile profile; final int index; + @override + _ConversationMessageState createState() => _ConversationMessageState(); +} + +class _ConversationMessageState extends State { + + List> menuItems = []; + + Offset? _tapPosition; + + bool showDownloadButton = false; + bool showDeleteButton = false; + + @override + void initState() { + super.initState(); + + showDownloadButton = widget.message.runtimeType == ImageMessage; + showDeleteButton = widget.message.senderId == widget.profile.id; + + if (showDownloadButton) { + menuItems.add(PopupMenuItem( + value: 'download', + child: Row( + children: const [ + Icon(Icons.download), + SizedBox( + width: 10, + ), + Text('Download') + ], + ), + )); + } + + if (showDeleteButton) { + menuItems.add(PopupMenuItem( + value: 'delete', + child: Row( + children: const [ + Icon(Icons.delete), + SizedBox( + width: 10, + ), + Text('Delete') + ], + ), + )); + } + + setState(() {}); + } + @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.only(left: 14,right: 14,top: 0,bottom: 0), child: Align( alignment: ( - message.senderUsername == profile.username ? + widget.message.senderId == widget.profile.id ? Alignment.topRight : Alignment.topLeft ), child: Column( - crossAxisAlignment: message.senderUsername == profile.username ? + crossAxisAlignment: widget.message.senderId == widget.profile.id ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ @@ -40,26 +93,26 @@ class ConversationMessage extends StatelessWidget { const SizedBox(height: 1.5), Row( - mainAxisAlignment: message.senderUsername == profile.username ? + mainAxisAlignment: widget.message.senderId == widget.profile.id ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ const SizedBox(width: 10), - usernameOrFailedToSend(index), + usernameOrFailedToSend(), ], ), const SizedBox(height: 1.5), Row( - mainAxisAlignment: message.senderUsername == profile.username ? + mainAxisAlignment: widget.message.senderId == widget.profile.id ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ const SizedBox(width: 10), Text( - convertToAgo(message.createdAt), - textAlign: message.senderUsername == profile.username ? + convertToAgo(widget.message.createdAt), + textAlign: widget.message.senderId == widget.profile.id ? TextAlign.left : TextAlign.right, style: TextStyle( @@ -70,7 +123,7 @@ class ConversationMessage extends StatelessWidget { ], ), - index != 0 ? + widget.index != 0 ? const SizedBox(height: 20) : const SizedBox.shrink(), ], @@ -79,22 +132,50 @@ class ConversationMessage extends StatelessWidget { ); } + void _showCustomMenu() { + final Size overlay = MediaQuery.of(context).size; + + int addVerticalOffset = 75 * menuItems.length; + + // TODO: Implement download & delete methods + showMenu( + context: context, + items: menuItems, + position: RelativeRect.fromRect( + Offset(_tapPosition!.dx, (_tapPosition!.dy - addVerticalOffset)) & const Size(40, 40), + Offset.zero & overlay + ) + ) + .then((String? delta) async { + if (delta == null) { + return; + } + + print(delta); + }); + } + + void _storePosition(TapDownDetails details) { + _tapPosition = details.globalPosition; + } + Widget messageContent(BuildContext context) { - if (message.runtimeType == ImageMessage) { + if (widget.message.runtimeType == ImageMessage) { return GestureDetector( onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) { return ViewImage( - message: (message as ImageMessage) + message: (widget.message as ImageMessage) ); })); }, + onLongPress: _showCustomMenu, + onTapDown: _storePosition, child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 350, maxWidth: 250), child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Image.file( - (message as ImageMessage).file, + borderRadius: BorderRadius.circular(20), child: Image.file( + (widget.message as ImageMessage).file, fit: BoxFit.fill, ), ), @@ -102,32 +183,36 @@ class ConversationMessage extends StatelessWidget { ); } - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: ( - message.senderUsername == profile.username ? - Theme.of(context).colorScheme.primary : - Theme.of(context).colorScheme.tertiary + return GestureDetector( + onLongPress: _showCustomMenu, + onTapDown: _storePosition, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: ( + widget.message.senderId == widget.profile.id ? + Theme.of(context).colorScheme.primary : + Theme.of(context).colorScheme.tertiary + ), ), - ), - padding: const EdgeInsets.all(12), - child: Text( - message.getContent(), - style: TextStyle( - fontSize: 15, - color: message.senderUsername == profile.username ? - Theme.of(context).colorScheme.onPrimary : - Theme.of(context).colorScheme.onTertiary, + padding: const EdgeInsets.all(12), + child: Text( + widget.message.getContent(), + style: TextStyle( + fontSize: 15, + color: widget.message.senderId == widget.profile.id ? + Theme.of(context).colorScheme.onPrimary : + Theme.of(context).colorScheme.onTertiary, + ), ), ), ); } - Widget usernameOrFailedToSend(int index) { - if (message.senderUsername != profile.username) { + Widget usernameOrFailedToSend() { + if (widget.message.senderId != widget.profile.id) { return Text( - message.senderUsername, + widget.message.senderUsername, style: TextStyle( fontSize: 12, color: Colors.grey[300], @@ -135,7 +220,7 @@ class ConversationMessage extends StatelessWidget { ); } - if (message.failedToSend) { + if (widget.message.failedToSend) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: const [ diff --git a/mobile/lib/views/main/conversation/settings.dart b/mobile/lib/views/main/conversation/settings.dart index 6eda0ba..4fd36c5 100644 --- a/mobile/lib/views/main/conversation/settings.dart +++ b/mobile/lib/views/main/conversation/settings.dart @@ -1,6 +1,9 @@ +import 'dart:io'; + import 'package:Envelope/components/custom_title_bar.dart'; import 'package:Envelope/models/friends.dart'; import 'package:Envelope/utils/encryption/crypto_utils.dart'; +import 'package:Envelope/utils/storage/write_file.dart'; import 'package:Envelope/views/main/conversation/create_add_users.dart'; import 'package:flutter/material.dart'; @@ -75,12 +78,15 @@ class _ConversationSettingsState extends State { Widget conversationName() { return Row( children: [ - const CustomCircleAvatar( - icon: Icon(Icons.people, size: 40), - imagePath: null, // TODO: Add image here + + CustomCircleAvatar( + icon: const Icon(Icons.people, size: 40), radius: 30, + image: widget.conversation.icon, ), + const SizedBox(width: 10), + Text( widget.conversation.name, style: const TextStyle( @@ -88,6 +94,7 @@ class _ConversationSettingsState extends State { fontWeight: FontWeight.w500, ), ), + widget.conversation.admin && !widget.conversation.twoUser ? IconButton( iconSize: 20, icon: const Icon(Icons.edit), @@ -96,8 +103,18 @@ class _ConversationSettingsState extends State { onPressed: () { Navigator.of(context).push( MaterialPageRoute(builder: (context) => ConversationEditDetails( - saveCallback: (String conversationName) async { + saveCallback: (String conversationName, File? file) async { + + File? writtenFile; + if (file != null) { + writtenFile = await writeImage( + widget.conversation.id, + file.readAsBytesSync(), + ); + } + widget.conversation.name = conversationName; + widget.conversation.icon = writtenFile; final db = await getDatabaseConnection(); db.update( diff --git a/mobile/lib/views/main/conversation/settings_user_list_item.dart b/mobile/lib/views/main/conversation/settings_user_list_item.dart index d4add1e..446702b 100644 --- a/mobile/lib/views/main/conversation/settings_user_list_item.dart +++ b/mobile/lib/views/main/conversation/settings_user_list_item.dart @@ -104,7 +104,6 @@ class _ConversationSettingsUserListItemState extends State[ CustomCircleAvatar( initials: widget.user.username[0].toUpperCase(), - imagePath: null, radius: 15, ), const SizedBox(width: 16), diff --git a/mobile/lib/views/main/friend/list_item.dart b/mobile/lib/views/main/friend/list_item.dart index 582f296..dc411ba 100644 --- a/mobile/lib/views/main/friend/list_item.dart +++ b/mobile/lib/views/main/friend/list_item.dart @@ -33,7 +33,6 @@ class _FriendListItemState extends State { children: [ CustomCircleAvatar( initials: widget.friend.username[0].toUpperCase(), - imagePath: null, ), const SizedBox(width: 16), Expanded( diff --git a/mobile/lib/views/main/friend/request_list_item.dart b/mobile/lib/views/main/friend/request_list_item.dart index 0f2c278..4eccf46 100644 --- a/mobile/lib/views/main/friend/request_list_item.dart +++ b/mobile/lib/views/main/friend/request_list_item.dart @@ -46,7 +46,6 @@ class _FriendRequestListItemState extends State { children: [ CustomCircleAvatar( initials: widget.friend.username[0].toUpperCase(), - imagePath: null, ), const SizedBox(width: 16), Expanded( diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index ae7df99..45cfa68 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -79,7 +79,6 @@ class _ProfileState extends State { children: [ const CustomCircleAvatar( icon: Icon(Icons.person, size: 40), - imagePath: null, // TODO: Add image here radius: 30, ), const SizedBox(width: 20), @@ -259,8 +258,8 @@ class _ProfileState extends State { }); return Column( - children: [ - Padding( + children: [ + Padding( padding: const EdgeInsets.all(20), child: QrImage( backgroundColor: Theme.of(context).colorScheme.primary, From 70f6d6546fcecf866097f494d19f7b99e32a7f71 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Wed, 31 Aug 2022 06:37:51 +0930 Subject: [PATCH 4/6] Add conversation image support --- Backend/Api/Friends/AcceptFriendRequest.go | 2 +- Backend/Api/Messages/AddConversationImage.go | 64 +++++++++++++++++++ Backend/Api/Messages/Conversations.go | 42 ++++++++---- Backend/Api/Messages/UpdateConversation.go | 7 +- Backend/Api/Routes.go | 1 + Backend/Database/Attachments.go | 42 ++++++++++++ Backend/Database/ConversationDetails.go | 7 +- Backend/Models/Conversations.go | 8 ++- .../lib/exceptions/update_data_exception.dart | 13 ++++ mobile/lib/models/conversations.dart | 12 ++++ mobile/lib/models/image_message.dart | 26 ++------ mobile/lib/utils/storage/conversations.dart | 44 +++++++++++-- mobile/lib/utils/storage/get_file.dart | 32 ++++++++++ .../lib/views/main/conversation/settings.dart | 16 ++++- 14 files changed, 268 insertions(+), 48 deletions(-) create mode 100644 Backend/Api/Messages/AddConversationImage.go create mode 100644 Backend/Database/Attachments.go create mode 100644 mobile/lib/exceptions/update_data_exception.dart create mode 100644 mobile/lib/utils/storage/get_file.dart diff --git a/Backend/Api/Friends/AcceptFriendRequest.go b/Backend/Api/Friends/AcceptFriendRequest.go index adfa0e5..aa9e233 100644 --- a/Backend/Api/Friends/AcceptFriendRequest.go +++ b/Backend/Api/Friends/AcceptFriendRequest.go @@ -32,7 +32,7 @@ func AcceptFriendRequest(w http.ResponseWriter, r *http.Request) { oldFriendRequest, err = Database.GetFriendRequestByID(friendRequestID) if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) + http.Error(w, "Not Found", http.StatusNotFound) return } diff --git a/Backend/Api/Messages/AddConversationImage.go b/Backend/Api/Messages/AddConversationImage.go new file mode 100644 index 0000000..1da2866 --- /dev/null +++ b/Backend/Api/Messages/AddConversationImage.go @@ -0,0 +1,64 @@ +package Messages + +import ( + "encoding/base64" + "encoding/json" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" + "github.com/gorilla/mux" +) + +// AddConversationImage adds an image for a conversation icon +func AddConversationImage(w http.ResponseWriter, r *http.Request) { + var ( + attachment Models.Attachment + conversationDetail Models.ConversationDetail + urlVars map[string]string + detailID string + decodedFile []byte + fileName string + ok bool + err error + ) + + urlVars = mux.Vars(r) + detailID, ok = urlVars["detailID"] + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + conversationDetail, err = Database.GetConversationDetailByID(detailID) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + err = json.NewDecoder(r.Body).Decode(&attachment) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + if attachment.Data == "" { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + decodedFile, err = base64.StdEncoding.DecodeString(attachment.Data) + fileName, err = Util.WriteFile(decodedFile) + attachment.FilePath = fileName + + conversationDetail.Attachment = attachment + + err = Database.UpdateConversationDetail(&conversationDetail) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index 27d1470..dde7583 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -2,6 +2,7 @@ package Messages import ( "encoding/json" + "fmt" "net/http" "net/url" "strings" @@ -14,10 +15,10 @@ import ( // EncryptedConversationList returns an encrypted list of all Conversations func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { var ( - userConversations []Models.UserConversation - userSession Models.Session - returnJSON []byte - err error + conversationDetails []Models.UserConversation + userSession Models.Session + returnJSON []byte + err error ) userSession, err = Auth.CheckCookie(r) @@ -26,7 +27,7 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { return } - userConversations, err = Database.GetUserConversationsByUserId( + conversationDetails, err = Database.GetUserConversationsByUserId( userSession.UserID.String(), ) if err != nil { @@ -34,7 +35,7 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { return } - returnJSON, err = json.MarshalIndent(userConversations, "", " ") + returnJSON, err = json.MarshalIndent(conversationDetails, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) return @@ -47,12 +48,14 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { // EncryptedConversationDetailsList returns an encrypted list of all ConversationDetails func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { var ( - userConversations []Models.ConversationDetail - query url.Values - conversationIds []string - returnJSON []byte - ok bool - err error + conversationDetails []Models.ConversationDetail + detail Models.ConversationDetail + query url.Values + conversationIds []string + returnJSON []byte + i int + ok bool + err error ) query = r.URL.Query() @@ -65,7 +68,7 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { // TODO: Fix error handling here conversationIds = strings.Split(conversationIds[0], ",") - userConversations, err = Database.GetConversationDetailsByIds( + conversationDetails, err = Database.GetConversationDetailsByIds( conversationIds, ) if err != nil { @@ -73,7 +76,18 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { return } - returnJSON, err = json.MarshalIndent(userConversations, "", " ") + for i, detail = range conversationDetails { + if detail.AttachmentID == nil { + continue + } + + conversationDetails[i].Attachment.ImageLink = fmt.Sprintf( + "http://192.168.1.5:8080/files/%s", + detail.Attachment.FilePath, + ) + } + + returnJSON, err = json.MarshalIndent(conversationDetails, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) return diff --git a/Backend/Api/Messages/UpdateConversation.go b/Backend/Api/Messages/UpdateConversation.go index 93b5215..4900ba8 100644 --- a/Backend/Api/Messages/UpdateConversation.go +++ b/Backend/Api/Messages/UpdateConversation.go @@ -10,16 +10,17 @@ import ( "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" ) -type RawUpdateConversationData struct { +type rawUpdateConversationData struct { ID string `json:"id"` Name string `json:"name"` Users []Models.ConversationDetailUser `json:"users"` UserConversations []Models.UserConversation `json:"user_conversations"` } +// UpdateConversation updates the conversation data, such as title, users, etc func UpdateConversation(w http.ResponseWriter, r *http.Request) { var ( - rawConversationData RawCreateConversationData + rawConversationData rawUpdateConversationData messageThread Models.ConversationDetail err error ) @@ -52,5 +53,5 @@ func UpdateConversation(w http.ResponseWriter, r *http.Request) { } } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 5892d46..90f0ed8 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -77,6 +77,7 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/conversation_details", Messages.EncryptedConversationDetailsList).Methods("GET") authAPI.HandleFunc("/conversations", Messages.CreateConversation).Methods("POST") authAPI.HandleFunc("/conversations", Messages.UpdateConversation).Methods("PUT") + authAPI.HandleFunc("/conversations/{detailID}/image", Messages.AddConversationImage).Methods("POST") authAPI.HandleFunc("/message", Messages.CreateMessage).Methods("POST") authAPI.HandleFunc("/messages/{associationKey}", Messages.Messages).Methods("GET") diff --git a/Backend/Database/Attachments.go b/Backend/Database/Attachments.go new file mode 100644 index 0000000..3097a04 --- /dev/null +++ b/Backend/Database/Attachments.go @@ -0,0 +1,42 @@ +package Database + +import ( + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GetAttachmentByID gets the attachment record by the id +func GetAttachmentByID(id string) (Models.MessageData, error) { + var ( + messageData Models.MessageData + err error + ) + + err = DB.Preload(clause.Associations). + First(&messageData, "id = ?", id). + Error + + return messageData, err +} + +// CreateAttachment creates the attachment record +func CreateAttachment(messageData *Models.MessageData) error { + var ( + err error + ) + + err = DB.Session(&gorm.Session{FullSaveAssociations: true}). + Create(messageData). + Error + + return err +} + +// DeleteAttachment deletes the attachment record +func DeleteAttachment(messageData *Models.MessageData) error { + return DB.Session(&gorm.Session{FullSaveAssociations: true}). + Delete(messageData). + Error +} diff --git a/Backend/Database/ConversationDetails.go b/Backend/Database/ConversationDetails.go index 9893022..af04edb 100644 --- a/Backend/Database/ConversationDetails.go +++ b/Backend/Database/ConversationDetails.go @@ -7,7 +7,8 @@ import ( "gorm.io/gorm/clause" ) -func GetConversationDetailById(id string) (Models.ConversationDetail, error) { +// GetConversationDetailByID gets by id +func GetConversationDetailByID(id string) (Models.ConversationDetail, error) { var ( messageThread Models.ConversationDetail err error @@ -21,6 +22,7 @@ func GetConversationDetailById(id string) (Models.ConversationDetail, error) { return messageThread, err } +// GetConversationDetailsByIds gets by multiple ids func GetConversationDetailsByIds(id []string) ([]Models.ConversationDetail, error) { var ( messageThread []Models.ConversationDetail @@ -35,12 +37,14 @@ func GetConversationDetailsByIds(id []string) ([]Models.ConversationDetail, erro return messageThread, err } +// CreateConversationDetail creates a ConversationDetail record func CreateConversationDetail(messageThread *Models.ConversationDetail) error { return DB.Session(&gorm.Session{FullSaveAssociations: true}). Create(messageThread). Error } +// UpdateConversationDetail updates a ConversationDetail record func UpdateConversationDetail(messageThread *Models.ConversationDetail) error { return DB.Session(&gorm.Session{FullSaveAssociations: true}). Where("id = ?", messageThread.ID). @@ -48,6 +52,7 @@ func UpdateConversationDetail(messageThread *Models.ConversationDetail) error { Error } +// DeleteConversationDetail deletes a ConversationDetail record func DeleteConversationDetail(messageThread *Models.ConversationDetail) error { return DB.Session(&gorm.Session{FullSaveAssociations: true}). Delete(messageThread). diff --git a/Backend/Models/Conversations.go b/Backend/Models/Conversations.go index fa88987..1c9e53a 100644 --- a/Backend/Models/Conversations.go +++ b/Backend/Models/Conversations.go @@ -7,9 +7,11 @@ import ( // ConversationDetail stores the name for the conversation type ConversationDetail struct { Base - Name string `gorm:"not null" json:"name"` // Stored encrypted - Users []ConversationDetailUser ` json:"users"` - TwoUser string `gorm:"not null" json:"two_user"` + Name string `gorm:"not null" json:"name"` // Stored encrypted + Users []ConversationDetailUser ` json:"users"` + TwoUser string `gorm:"not null" json:"two_user"` + AttachmentID *uuid.UUID ` json:"attachment_id"` + Attachment Attachment ` json:"attachment"` } // ConversationDetailUser all users associated with a customer diff --git a/mobile/lib/exceptions/update_data_exception.dart b/mobile/lib/exceptions/update_data_exception.dart new file mode 100644 index 0000000..8d1d6bb --- /dev/null +++ b/mobile/lib/exceptions/update_data_exception.dart @@ -0,0 +1,13 @@ + +class UpdateDataException implements Exception { + final String _message; + + UpdateDataException([ + this._message = 'An error occured while updating data.', + ]); + + @override + String toString() { + return _message; + } +} diff --git a/mobile/lib/models/conversations.dart b/mobile/lib/models/conversations.dart index f5c7134..d8222d3 100644 --- a/mobile/lib/models/conversations.dart +++ b/mobile/lib/models/conversations.dart @@ -335,6 +335,18 @@ class Conversation { return returnData; } + Map payloadImageJson() { + if (icon == null) { + return {}; + } + + return { + 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())), + 'mimetype': lookupMimeType(icon!.path), + 'extension': getExtension(icon!.path), + }; + } + Map toMap() { return { 'id': id, diff --git a/mobile/lib/models/image_message.dart b/mobile/lib/models/image_message.dart index e092d36..9d80dbf 100644 --- a/mobile/lib/models/image_message.dart +++ b/mobile/lib/models/image_message.dart @@ -2,9 +2,8 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:Envelope/utils/storage/session_cookie.dart'; +import 'package:Envelope/utils/storage/get_file.dart'; import 'package:Envelope/utils/storage/write_file.dart'; -import 'package:http/http.dart' as http; import 'package:mime/mime.dart'; import 'package:pointycastle/pointycastle.dart'; import 'package:uuid/uuid.dart'; @@ -55,25 +54,10 @@ class ImageMessage extends Message { base64.decode(json['message_data']['sender_id']), ); - var resp = await http.get( - Uri.parse(json['message_data']['attachment']['image_link']), - headers: { - 'cookie': await getSessionCookie(), - } - ); - - if (resp.statusCode != 200) { - throw Exception('Could not get attachment file'); - } - - var data = AesHelper.aesDecryptBytes( - base64.decode(symmetricKey), - resp.bodyBytes, - ); - - File file = await writeImage( + File file = await getFile( + json['message_data']['attachment']['image_link'], '${json['id']}', - data, + symmetricKey, ); return ImageMessage( @@ -127,7 +111,7 @@ class ImageMessage extends Message { Uint8List.fromList(base64.encode(symmetricKey).codeUnits), ), 'attachment': { - 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(file.readAsBytesSync())), + 'data': AesHelper.aesEncrypt(base64.encode(symmetricKey), Uint8List.fromList(file.readAsBytesSync())), 'mimetype': lookupMimeType(file.path), 'extension': getExtension(file.path), } diff --git a/mobile/lib/utils/storage/conversations.dart b/mobile/lib/utils/storage/conversations.dart index 985b9ea..b5fce2b 100644 --- a/mobile/lib/utils/storage/conversations.dart +++ b/mobile/lib/utils/storage/conversations.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:Envelope/exceptions/update_data_exception.dart'; +import 'package:Envelope/utils/storage/get_file.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; @@ -13,12 +15,17 @@ import '/utils/encryption/aes_helper.dart'; import '/utils/storage/database.dart'; import '/utils/storage/session_cookie.dart'; -Future updateConversation(Conversation conversation, { includeUsers = true } ) async { +Future updateConversation( + Conversation conversation, + { + includeUsers = false, + updatedImage = false, + } ) async { String sessionCookie = await getSessionCookie(); Map conversationJson = await conversation.payloadJson(includeUsers: includeUsers); - var x = await http.put( + var resp = await http.put( await MyProfile.getServerUrl('api/v1/auth/conversations'), headers: { 'Content-Type': 'application/json; charset=UTF-8', @@ -27,8 +34,28 @@ Future updateConversation(Conversation conversation, { includeUsers = true body: jsonEncode(conversationJson), ); - // TODO: Handle errors here - print(x.statusCode); + if (resp.statusCode != 204) { + throw UpdateDataException('Unable to update conversation, please try again later.'); + } + + if (!updatedImage) { + return; + } + + Map attachmentJson = conversation.payloadImageJson(); + + resp = await http.post( + await MyProfile.getServerUrl('api/v1/auth/conversations/${conversation.id}/image'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'cookie': sessionCookie, + }, + body: jsonEncode(attachmentJson), + ); + + if (resp.statusCode != 204) { + throw UpdateDataException('Unable to update conversation image, please try again later.'); + } } // TODO: Refactor this function @@ -116,6 +143,15 @@ Future updateConversations() async { ); } + // TODO: Handle exception here + if (conversationDetailJson['attachment_id'] != null) { + conversation.icon = await getFile( + conversationDetailJson['attachment']['image_link'], + conversation.id, + conversation.symmetricKey, + ); + } + await db.insert( 'conversations', conversation.toMap(), diff --git a/mobile/lib/utils/storage/get_file.dart b/mobile/lib/utils/storage/get_file.dart new file mode 100644 index 0000000..3047f67 --- /dev/null +++ b/mobile/lib/utils/storage/get_file.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '/utils/encryption/aes_helper.dart'; +import '/utils/storage/session_cookie.dart'; +import '/utils/storage/write_file.dart'; + +Future getFile(String link, String imageName, dynamic symmetricKey) async { + var resp = await http.get( + Uri.parse(link), + headers: { + 'cookie': await getSessionCookie(), + } + ); + + if (resp.statusCode != 200) { + throw Exception('Could not get attachment file'); + } + + var data = AesHelper.aesDecryptBytes( + symmetricKey, + resp.bodyBytes, + ); + + File file = await writeImage( + imageName, + data, + ); + + return file; +} diff --git a/mobile/lib/views/main/conversation/settings.dart b/mobile/lib/views/main/conversation/settings.dart index 4fd36c5..2c87896 100644 --- a/mobile/lib/views/main/conversation/settings.dart +++ b/mobile/lib/views/main/conversation/settings.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:Envelope/components/custom_title_bar.dart'; +import 'package:Envelope/components/flash_message.dart'; +import 'package:Envelope/exceptions/update_data_exception.dart'; import 'package:Envelope/models/friends.dart'; import 'package:Envelope/utils/encryption/crypto_utils.dart'; import 'package:Envelope/utils/storage/write_file.dart'; @@ -103,10 +105,14 @@ class _ConversationSettingsState extends State { onPressed: () { Navigator.of(context).push( MaterialPageRoute(builder: (context) => ConversationEditDetails( + // TODO: Move saveCallback to somewhere else saveCallback: (String conversationName, File? file) async { + bool updatedImage = false; + File? writtenFile; if (file != null) { + updatedImage = file.hashCode != widget.conversation.icon.hashCode; writtenFile = await writeImage( widget.conversation.id, file.readAsBytesSync(), @@ -124,7 +130,15 @@ class _ConversationSettingsState extends State { whereArgs: [widget.conversation.id], ); - await updateConversation(widget.conversation, includeUsers: true); + await updateConversation(widget.conversation, updatedImage: updatedImage) + .catchError((error) { + String message = error.toString(); + if (error.runtimeType != UpdateDataException) { + message = 'An error occured, please try again later'; + } + + showMessage(message, context); + }); setState(() {}); Navigator.pop(context); }, From 19fbb9c25a4d2a8df8ca01b37ab1625d646c115b Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Wed, 31 Aug 2022 20:55:46 +0930 Subject: [PATCH 5/6] Add profile images for user profiles --- Backend/Api/Auth/AddProfileImage.go | 50 +++++++++ Backend/Api/Auth/ChangeMessageExpiry.go | 4 +- Backend/Api/Auth/Login.go | 103 ++++++++---------- Backend/Api/Messages/Conversations.go | 1 - Backend/Api/Routes.go | 1 + Backend/Database/Seeder/UserSeeder.go | 12 ++ Backend/Models/Users.go | 16 ++- mobile/lib/models/my_profile.dart | 33 +++++- mobile/lib/views/authentication/login.dart | 21 ++-- .../lib/views/main/conversation/message.dart | 2 - mobile/lib/views/main/profile/profile.dart | 88 ++++++++++++++- 11 files changed, 252 insertions(+), 79 deletions(-) create mode 100644 Backend/Api/Auth/AddProfileImage.go diff --git a/Backend/Api/Auth/AddProfileImage.go b/Backend/Api/Auth/AddProfileImage.go new file mode 100644 index 0000000..31c7f64 --- /dev/null +++ b/Backend/Api/Auth/AddProfileImage.go @@ -0,0 +1,50 @@ +package Auth + +import ( + "encoding/base64" + "encoding/json" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" +) + +// AddProfileImage adds a profile image +func AddProfileImage(w http.ResponseWriter, r *http.Request) { + var ( + user Models.User + attachment Models.Attachment + decodedFile []byte + fileName string + err error + ) + + // Ignore error here, as middleware should handle auth + user, _ = CheckCookieCurrentUser(w, r) + + err = json.NewDecoder(r.Body).Decode(&attachment) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + if attachment.Data == "" { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + decodedFile, err = base64.StdEncoding.DecodeString(attachment.Data) + fileName, err = Util.WriteFile(decodedFile) + attachment.FilePath = fileName + + user.Attachment = attachment + + err = Database.UpdateUser(user.ID.String(), &user) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/Backend/Api/Auth/ChangeMessageExpiry.go b/Backend/Api/Auth/ChangeMessageExpiry.go index 8f8721f..acad218 100644 --- a/Backend/Api/Auth/ChangeMessageExpiry.go +++ b/Backend/Api/Auth/ChangeMessageExpiry.go @@ -10,7 +10,7 @@ import ( ) type rawChangeMessageExpiry struct { - MessageExpiry string `json:"message_exipry"` + MessageExpiry string `json:"message_expiry"` } // ChangeMessageExpiry handles changing default message expiry for user @@ -37,7 +37,7 @@ func ChangeMessageExpiry(w http.ResponseWriter, r *http.Request) { return } - user.AsymmetricPrivateKey = changeMessageExpiry.MessageExpiry + user.MessageExpiryDefault.Scan(changeMessageExpiry.MessageExpiry) err = Database.UpdateUser( user.ID.String(), diff --git a/Backend/Api/Auth/Login.go b/Backend/Api/Auth/Login.go index 61225af..eb8b516 100644 --- a/Backend/Api/Auth/Login.go +++ b/Backend/Api/Auth/Login.go @@ -3,6 +3,7 @@ package Auth import ( "database/sql/driver" "encoding/json" + "fmt" "net/http" "time" @@ -16,73 +17,43 @@ type credentials struct { } type loginResponse struct { - Status string `json:"status"` - Message string `json:"message"` - AsymmetricPublicKey string `json:"asymmetric_public_key"` - AsymmetricPrivateKey string `json:"asymmetric_private_key"` UserID string `json:"user_id"` Username string `json:"username"` + AsymmetricPublicKey string `json:"asymmetric_public_key"` + AsymmetricPrivateKey string `json:"asymmetric_private_key"` + SymmetricKey string `json:"symmetric_key"` MessageExpiryDefault string `json:"message_expiry_default"` + ImageLink string `json:"image_link"` } -func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) { +// Login logs the user into the system +func Login(w http.ResponseWriter, r *http.Request) { var ( - status = "error" + creds credentials + user Models.User + session Models.Session + expiresAt time.Time messageExpiryRaw driver.Value messageExpiry string + imageLink string returnJSON []byte err error ) - if code >= 200 && code <= 300 { - status = "success" - } - - messageExpiryRaw, _ = user.MessageExpiryDefault.Value() - messageExpiry, _ = messageExpiryRaw.(string) - - returnJSON, err = json.MarshalIndent(loginResponse{ - Status: status, - Message: message, - AsymmetricPublicKey: pubKey, - AsymmetricPrivateKey: privKey, - UserID: user.ID.String(), - Username: user.Username, - MessageExpiryDefault: messageExpiry, - }, "", " ") - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - // Return updated json - w.WriteHeader(code) - w.Write(returnJSON) -} - -// Login logs the user into the system -func Login(w http.ResponseWriter, r *http.Request) { - var ( - creds credentials - userData Models.User - session Models.Session - expiresAt time.Time - err error - ) err = json.NewDecoder(r.Body).Decode(&creds) if err != nil { - makeLoginResponse(w, http.StatusInternalServerError, "An error occurred", "", "", userData) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - userData, err = Database.GetUserByUsername(creds.Username) + user, err = Database.GetUserByUsername(creds.Username) if err != nil { - makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if !CheckPasswordHash(creds.Password, userData.Password) { - makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData) + if !CheckPasswordHash(creds.Password, user.Password) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -90,13 +61,13 @@ func Login(w http.ResponseWriter, r *http.Request) { expiresAt = time.Now().Add(12 * time.Hour) session = Models.Session{ - UserID: userData.ID, + UserID: user.ID, Expiry: expiresAt, } err = Database.CreateSession(&session) if err != nil { - makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -106,12 +77,32 @@ func Login(w http.ResponseWriter, r *http.Request) { Expires: expiresAt, }) - makeLoginResponse( - w, - http.StatusOK, - "Successfully logged in", - userData.AsymmetricPublicKey, - userData.AsymmetricPrivateKey, - userData, - ) + if user.AttachmentID != nil { + imageLink = fmt.Sprintf( + "http://192.168.1.5:8080/files/%s", + user.Attachment.FilePath, + ) + } + + messageExpiryRaw, _ = user.MessageExpiryDefault.Value() + messageExpiry, _ = messageExpiryRaw.(string) + + returnJSON, err = json.MarshalIndent(loginResponse{ + UserID: user.ID.String(), + Username: user.Username, + AsymmetricPublicKey: user.AsymmetricPublicKey, + AsymmetricPrivateKey: user.AsymmetricPrivateKey, + SymmetricKey: user.SymmetricKey, + MessageExpiryDefault: messageExpiry, + ImageLink: imageLink, + }, "", " ") + + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Return updated json + w.WriteHeader(http.StatusOK) + w.Write(returnJSON) } diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index dde7583..4e7b0cc 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -65,7 +65,6 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { return } - // TODO: Fix error handling here conversationIds = strings.Split(conversationIds[0], ",") conversationDetails, err = Database.GetConversationDetailsByIds( diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 90f0ed8..8b0c280 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -64,6 +64,7 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST") authAPI.HandleFunc("/message_expiry", Auth.ChangeMessageExpiry).Methods("POST") + authAPI.HandleFunc("/image", Auth.AddProfileImage).Methods("POST") authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET") diff --git a/Backend/Database/Seeder/UserSeeder.go b/Backend/Database/Seeder/UserSeeder.go index ce13b2a..c65a94e 100644 --- a/Backend/Database/Seeder/UserSeeder.go +++ b/Backend/Database/Seeder/UserSeeder.go @@ -1,6 +1,8 @@ package Seeder import ( + "encoding/base64" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" @@ -23,10 +25,16 @@ var userNames = []string{ func createUser(username string) (Models.User, error) { var ( userData Models.User + userKey aesKey password string err error ) + userKey, err = generateAesKey() + if err != nil { + panic(err) + } + password, err = Auth.HashPassword("password") if err != nil { return Models.User{}, err @@ -37,12 +45,16 @@ func createUser(username string) (Models.User, error) { Password: password, AsymmetricPrivateKey: encryptedPrivateKey, AsymmetricPublicKey: publicKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + encryptWithPublicKey(userKey.Key, decodedPublicKey), + ), } err = Database.CreateUser(&userData) return userData, err } +// SeedUsers used to create dummy users for testing & development func SeedUsers() { var ( i int diff --git a/Backend/Models/Users.go b/Backend/Models/Users.go index 685b774..811c3ab 100644 --- a/Backend/Models/Users.go +++ b/Backend/Models/Users.go @@ -3,6 +3,7 @@ package Models import ( "database/sql/driver" + "github.com/gofrs/uuid" "gorm.io/gorm" ) @@ -58,6 +59,17 @@ type User struct { ConfirmPassword string `gorm:"-" json:"confirm_password"` AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"` - MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM('fifteen_min', 'thirty_min', 'one_hour', 'three_hour', 'six_hour', 'twelve_hour', 'one_day', 'three_day')"` // Stored encrypted - + SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted + AttachmentID *uuid.UUID ` json:"attachment_id"` + Attachment Attachment ` json:"attachment"` + MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM( + 'fifteen_min', + 'thirty_min', + 'one_hour', + 'three_hour', + 'six_hour', + 'twelve_hour', + 'one_day', + 'three_day' + )"` // Stored encrypted } diff --git a/mobile/lib/models/my_profile.dart b/mobile/lib/models/my_profile.dart index 526e668..9db8655 100644 --- a/mobile/lib/models/my_profile.dart +++ b/mobile/lib/models/my_profile.dart @@ -1,6 +1,8 @@ import 'dart:convert'; +import 'dart:io'; -import 'package:Envelope/components/select_message_ttl.dart'; +import 'package:Envelope/utils/storage/get_file.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:pointycastle/impl.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -17,7 +19,9 @@ class MyProfile { String? friendId; RSAPrivateKey? privateKey; RSAPublicKey? publicKey; + String? symmetricKey; DateTime? loggedInAt; + File? image; String messageExpiryDefault = 'no_expiry'; MyProfile({ @@ -26,7 +30,9 @@ class MyProfile { this.friendId, this.privateKey, this.publicKey, + this.symmetricKey, this.loggedInAt, + this.image, required this.messageExpiryDefault, }); @@ -44,8 +50,10 @@ class MyProfile { username: json['username'], privateKey: privateKey, publicKey: publicKey, + symmetricKey: json['symmetric_key'], loggedInAt: loggedInAt, - messageExpiryDefault: json['message_expiry_default'] + messageExpiryDefault: json['message_expiry_default'], + image: json['file'] != null ? File(json['file']) : null, ); } @@ -57,7 +65,7 @@ class MyProfile { logged_in_at: $loggedInAt public_key: $publicKey private_key: $privateKey - '''; + '''; } String toJson() { @@ -70,8 +78,10 @@ class MyProfile { 'asymmetric_public_key': publicKey != null ? CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) : null, + 'symmetric_key': symmetricKey, 'logged_in_at': loggedInAt?.toIso8601String(), 'message_expiry_default': messageExpiryDefault, + 'file': image?.path, }); } @@ -80,7 +90,24 @@ class MyProfile { password, base64.decode(json['asymmetric_private_key']) ); + + json['symmetric_key'] = base64.encode(CryptoUtils.rsaDecrypt( + base64.decode(json['symmetric_key']), + CryptoUtils.rsaPrivateKeyFromPem(json['asymmetric_private_key']), + )); + + if (json['image_link'] != '') { + File profileIcon = await getFile( + json['image_link'], + json['user_id'], + json['symmetric_key'], + ); + + json['file'] = profileIcon.path; + } + MyProfile profile = MyProfile._fromJson(json); + final preferences = await SharedPreferences.getInstance(); preferences.setString('profile', profile.toJson()); return profile; diff --git a/mobile/lib/views/authentication/login.dart b/mobile/lib/views/authentication/login.dart index dd8e869..6dce978 100644 --- a/mobile/lib/views/authentication/login.dart +++ b/mobile/lib/views/authentication/login.dart @@ -8,30 +8,30 @@ import '/models/my_profile.dart'; import '/utils/storage/session_cookie.dart'; class LoginResponse { - final String status; - final String message; - final String publicKey; - final String privateKey; final String userId; final String username; + final String publicKey; + final String privateKey; + final String symmetricKey; + final String? imageLink; const LoginResponse({ - required this.status, - required this.message, required this.publicKey, required this.privateKey, + required this.symmetricKey, required this.userId, required this.username, + this.imageLink, }); factory LoginResponse.fromJson(Map json) { return LoginResponse( - status: json['status'], - message: json['message'], - publicKey: json['asymmetric_public_key'], - privateKey: json['asymmetric_private_key'], userId: json['user_id'], username: json['username'], + publicKey: json['asymmetric_public_key'], + privateKey: json['asymmetric_private_key'], + symmetricKey: json['symmetric_key'], + imageLink: json['image_link'], ); } } @@ -175,6 +175,7 @@ class _LoginWidgetState extends State { ModalRoute.withName('/home'), ); }).catchError((error) { + print(error); showMessage( 'Could not login to Envelope, please try again later.', context, diff --git a/mobile/lib/views/main/conversation/message.dart b/mobile/lib/views/main/conversation/message.dart index ee98aa5..5bdc982 100644 --- a/mobile/lib/views/main/conversation/message.dart +++ b/mobile/lib/views/main/conversation/message.dart @@ -150,8 +150,6 @@ class _ConversationMessageState extends State { if (delta == null) { return; } - - print(delta); }); } diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index 45cfa68..b40f4a0 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -1,10 +1,18 @@ import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:Envelope/components/file_picker.dart'; import 'package:Envelope/components/flash_message.dart'; +import 'package:Envelope/utils/encryption/aes_helper.dart'; import 'package:Envelope/utils/storage/session_cookie.dart'; +import 'package:Envelope/utils/storage/write_file.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:sliding_up_panel/sliding_up_panel.dart'; import 'package:http/http.dart' as http; @@ -31,6 +39,8 @@ class Profile extends StatefulWidget { class _ProfileState extends State { final PanelController _panelController = PanelController(); + bool showFileSelector = false; + @override Widget build(BuildContext context) { return Scaffold( @@ -63,7 +73,8 @@ class _ProfileState extends State { child: Column( children: [ usernameHeading(), - const SizedBox(height: 30), + fileSelector(), + SizedBox(height: showFileSelector ? 10 : 30), settings(), const SizedBox(height: 30), logout(), @@ -77,11 +88,20 @@ class _ProfileState extends State { Widget usernameHeading() { return Row( children: [ - const CustomCircleAvatar( - icon: Icon(Icons.person, size: 40), + + CustomCircleAvatar( + image: widget.profile.image, + icon: const Icon(Icons.person, size: 40), radius: 30, + editImageCallback: () { + setState(() { + showFileSelector = true; + }); + }, ), + const SizedBox(width: 20), + Expanded( flex: 1, child: Text( @@ -92,6 +112,7 @@ class _ProfileState extends State { ), ), ), + IconButton( onPressed: () => _panelController.open(), icon: const Icon(Icons.qr_code_2), @@ -100,6 +121,59 @@ class _ProfileState extends State { ); } + Widget fileSelector() { + if (!showFileSelector) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 10), + child: FilePicker( + cameraHandle: _setProfileImage, + galleryHandleSingle: _setProfileImage, + ) + ); + } + + Future _setProfileImage(XFile image) async { + widget.profile.image = await writeImage( + widget.profile.id, + File(image.path).readAsBytesSync(), + ); + + setState(() { + showFileSelector = false; + }); + + saveProfile(); + + Map payload = { + 'data': AesHelper.aesEncrypt( + widget.profile.symmetricKey!, + Uint8List.fromList(widget.profile.image!.readAsBytesSync()) + ), + 'mimetype': lookupMimeType(widget.profile.image!.path), + 'extension': getExtension(widget.profile.image!.path), + }; + + http.post( + await MyProfile.getServerUrl('api/v1/auth/image'), + headers: { + 'cookie': await getSessionCookie(), + }, + body: jsonEncode(payload), + ).then((http.Response response) { + if (response.statusCode == 204) { + return; + } + + showMessage( + 'Could not change your default message expiry, please try again later.', + context, + ); + }); + } + Widget logout() { bool isTesting = dotenv.env['ENVIRONMENT'] == 'development'; @@ -190,6 +264,8 @@ class _ProfileState extends State { context, ); }); + + saveProfile(); }, )) ); @@ -241,6 +317,7 @@ class _ProfileState extends State { privateKey: widget.profile.privateKey!, )) ); + saveProfile(); } ), ], @@ -281,4 +358,9 @@ class _ProfileState extends State { ] ); } + + Future saveProfile() async { + final preferences = await SharedPreferences.getInstance(); + preferences.setString('profile', widget.profile.toJson()); + } } From eb0bf8a06f93a70d2d2b5a664e369fb67f72994a Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Tue, 6 Sep 2022 18:15:00 +0930 Subject: [PATCH 6/6] Update attachements to send back filename not link --- Backend/Api/Auth/Login.go | 6 +--- Backend/Api/Messages/Conversations.go | 6 +--- Backend/Api/Messages/MessageThread.go | 6 +--- Backend/Database/Seeder/FriendSeeder.go | 29 ++++++++++++++++++ Backend/Database/Seeder/profile_image_enc.dat | Bin 0 -> 139520 bytes Backend/Models/Friends.go | 5 +-- mobile/lib/models/image_message.dart | 3 +- mobile/lib/models/my_profile.dart | 6 +++- mobile/lib/utils/storage/conversations.dart | 3 +- 9 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 Backend/Database/Seeder/profile_image_enc.dat diff --git a/Backend/Api/Auth/Login.go b/Backend/Api/Auth/Login.go index eb8b516..d217493 100644 --- a/Backend/Api/Auth/Login.go +++ b/Backend/Api/Auth/Login.go @@ -3,7 +3,6 @@ package Auth import ( "database/sql/driver" "encoding/json" - "fmt" "net/http" "time" @@ -78,10 +77,7 @@ func Login(w http.ResponseWriter, r *http.Request) { }) if user.AttachmentID != nil { - imageLink = fmt.Sprintf( - "http://192.168.1.5:8080/files/%s", - user.Attachment.FilePath, - ) + imageLink = user.Attachment.FilePath } messageExpiryRaw, _ = user.MessageExpiryDefault.Value() diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index 4e7b0cc..a1681da 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -2,7 +2,6 @@ package Messages import ( "encoding/json" - "fmt" "net/http" "net/url" "strings" @@ -80,10 +79,7 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { continue } - conversationDetails[i].Attachment.ImageLink = fmt.Sprintf( - "http://192.168.1.5:8080/files/%s", - detail.Attachment.FilePath, - ) + conversationDetails[i].Attachment.ImageLink = detail.Attachment.FilePath } returnJSON, err = json.MarshalIndent(conversationDetails, "", " ") diff --git a/Backend/Api/Messages/MessageThread.go b/Backend/Api/Messages/MessageThread.go index b9cb53e..686f1c1 100644 --- a/Backend/Api/Messages/MessageThread.go +++ b/Backend/Api/Messages/MessageThread.go @@ -2,7 +2,6 @@ package Messages import ( "encoding/json" - "fmt" "net/http" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" @@ -42,10 +41,7 @@ func Messages(w http.ResponseWriter, r *http.Request) { continue } - messages[i].MessageData.Attachment.ImageLink = fmt.Sprintf( - "http://192.168.1.5:8080/files/%s", - message.MessageData.Attachment.FilePath, - ) + messages[i].MessageData.Attachment.ImageLink = message.MessageData.Attachment.FilePath } returnJSON, err = json.MarshalIndent(messages, "", " ") diff --git a/Backend/Database/Seeder/FriendSeeder.go b/Backend/Database/Seeder/FriendSeeder.go index f3b5203..e317d13 100644 --- a/Backend/Database/Seeder/FriendSeeder.go +++ b/Backend/Database/Seeder/FriendSeeder.go @@ -2,6 +2,8 @@ package Seeder import ( "encoding/base64" + "io" + "os" "time" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" @@ -56,6 +58,28 @@ func seedFriend(userRequestTo, userRequestFrom Models.User, accepted bool) error return Database.CreateFriendRequest(&friendRequest) } +func copyProfileImage() error { + var ( + srcFile *os.File + dstFile *os.File + err error + ) + + srcFile, err = os.Open("./Database/Seeder/profile_image_enc.dat") + if err != nil { + return err + } + + dstFile, err = os.Create("./attachments/profile_image") + if err != nil { + return err + } + + defer dstFile.Close() + _, err = io.Copy(dstFile, srcFile) + return err +} + // SeedFriends creates dummy friends for testing/development func SeedFriends() { var ( @@ -66,6 +90,11 @@ func SeedFriends() { err error ) + err = copyProfileImage() + if err != nil { + panic(err) + } + primaryUser, err = Database.GetUserByUsername("testUser") if err != nil { panic(err) diff --git a/Backend/Database/Seeder/profile_image_enc.dat b/Backend/Database/Seeder/profile_image_enc.dat new file mode 100644 index 0000000000000000000000000000000000000000..f82798a917492caaa1f5c79994ec466d11d69c1c GIT binary patch literal 139520 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 CeQaF- literal 0 HcmV?d00001 diff --git a/Backend/Models/Friends.go b/Backend/Models/Friends.go index 967af7d..9dc892d 100644 --- a/Backend/Models/Friends.go +++ b/Backend/Models/Friends.go @@ -11,8 +11,9 @@ type FriendRequest struct { Base UserID uuid.UUID `gorm:"type:uuid;column:user_id;not null;" json:"user_id"` User User ` json:"user"` - FriendID string `gorm:"not null" json:"friend_id"` // Stored encrypted - FriendUsername string ` json:"friend_username"` // Stored encrypted + FriendID string `gorm:"not null" json:"friend_id"` // Stored encrypted + FriendUsername string ` json:"friend_username"` // Stored encrypted + FriendImagePath string ` json:"friend_image_path"` FriendPublicAsymmetricKey string ` json:"asymmetric_public_key"` // Stored encrypted SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted AcceptedAt sql.NullTime ` json:"accepted_at"` diff --git a/mobile/lib/models/image_message.dart b/mobile/lib/models/image_message.dart index 9d80dbf..ff11a8a 100644 --- a/mobile/lib/models/image_message.dart +++ b/mobile/lib/models/image_message.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:Envelope/models/my_profile.dart'; import 'package:Envelope/utils/storage/get_file.dart'; import 'package:Envelope/utils/storage/write_file.dart'; import 'package:mime/mime.dart'; @@ -55,7 +56,7 @@ class ImageMessage extends Message { ); File file = await getFile( - json['message_data']['attachment']['image_link'], + '$defaultServerUrl/files/${json['message_data']['attachment']['image_link']}', '${json['id']}', symmetricKey, ); diff --git a/mobile/lib/models/my_profile.dart b/mobile/lib/models/my_profile.dart index 9db8655..d5f80e7 100644 --- a/mobile/lib/models/my_profile.dart +++ b/mobile/lib/models/my_profile.dart @@ -22,6 +22,7 @@ class MyProfile { String? symmetricKey; DateTime? loggedInAt; File? image; + String? imageLink; String messageExpiryDefault = 'no_expiry'; MyProfile({ @@ -33,6 +34,7 @@ class MyProfile { this.symmetricKey, this.loggedInAt, this.image, + this.imageLink, required this.messageExpiryDefault, }); @@ -54,6 +56,7 @@ class MyProfile { loggedInAt: loggedInAt, messageExpiryDefault: json['message_expiry_default'], image: json['file'] != null ? File(json['file']) : null, + imageLink: json['image_link'], ); } @@ -82,6 +85,7 @@ class MyProfile { 'logged_in_at': loggedInAt?.toIso8601String(), 'message_expiry_default': messageExpiryDefault, 'file': image?.path, + 'image_link': imageLink, }); } @@ -98,7 +102,7 @@ class MyProfile { if (json['image_link'] != '') { File profileIcon = await getFile( - json['image_link'], + '$defaultServerUrl/files/${['image_link']}', json['user_id'], json['symmetric_key'], ); diff --git a/mobile/lib/utils/storage/conversations.dart b/mobile/lib/utils/storage/conversations.dart index b5fce2b..27ba927 100644 --- a/mobile/lib/utils/storage/conversations.dart +++ b/mobile/lib/utils/storage/conversations.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:Envelope/exceptions/update_data_exception.dart'; import 'package:Envelope/utils/storage/get_file.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; import 'package:sqflite/sqflite.dart'; @@ -146,7 +147,7 @@ Future updateConversations() async { // TODO: Handle exception here if (conversationDetailJson['attachment_id'] != null) { conversation.icon = await getFile( - conversationDetailJson['attachment']['image_link'], + '$defaultServerUrl/files/${conversationDetailJson['attachment']['image_link']}', conversation.id, conversation.symmetricKey, );