From f56ccfe9427241a9a7bda439ab1bf1cc100fff38 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Sun, 28 Aug 2022 16:43:36 +0930 Subject: [PATCH] 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: