feature/add-attachment-support into develop 3 years ago
| @ -1 +1,2 @@ | |||||
| /mobile/.env | /mobile/.env | ||||
| /Backend/attachments/* | |||||
| @ -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) | |||||
| } | |||||
| @ -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) | |||||
| } | |||||
| @ -1,41 +1,55 @@ | |||||
| package Messages | package Messages | ||||
| import ( | import ( | ||||
| "encoding/base64" | |||||
| "encoding/json" | "encoding/json" | ||||
| "net/http" | "net/http" | ||||
| "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" | "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" | ||||
| "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" | "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"` | MessageData Models.MessageData `json:"message_data"` | ||||
| Messages []Models.Message `json:"message"` | Messages []Models.Message `json:"message"` | ||||
| } | } | ||||
| // CreateMessage sends a message | |||||
| func CreateMessage(w http.ResponseWriter, r *http.Request) { | func CreateMessage(w http.ResponseWriter, r *http.Request) { | ||||
| var ( | var ( | ||||
| rawMessageData RawMessageData | |||||
| messagesData []rawMessageData | |||||
| messageData rawMessageData | |||||
| decodedFile []byte | |||||
| fileName string | |||||
| err error | err error | ||||
| ) | ) | ||||
| err = json.NewDecoder(r.Body).Decode(&rawMessageData) | |||||
| err = json.NewDecoder(r.Body).Decode(&messagesData) | |||||
| if err != nil { | if err != nil { | ||||
| http.Error(w, "Error", http.StatusInternalServerError) | http.Error(w, "Error", http.StatusInternalServerError) | ||||
| return | return | ||||
| } | } | ||||
| err = Database.CreateMessageData(&rawMessageData.MessageData) | |||||
| 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 { | if err != nil { | ||||
| http.Error(w, "Error", http.StatusInternalServerError) | http.Error(w, "Error", http.StatusInternalServerError) | ||||
| return | return | ||||
| } | } | ||||
| err = Database.CreateMessages(&rawMessageData.Messages) | |||||
| err = Database.CreateMessages(&messageData.Messages) | |||||
| if err != nil { | if err != nil { | ||||
| http.Error(w, "Error", http.StatusInternalServerError) | http.Error(w, "Error", http.StatusInternalServerError) | ||||
| return | return | ||||
| } | } | ||||
| } | |||||
| w.WriteHeader(http.StatusOK) | w.WriteHeader(http.StatusOK) | ||||
| } | } | ||||
| @ -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 | |||||
| } | |||||
| @ -0,0 +1,11 @@ | |||||
| package Models | |||||
| // Attachment holds the attachment data | |||||
| type Attachment struct { | |||||
| Base | |||||
| 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"` | |||||
| } | |||||
| @ -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 | |||||
| } | |||||
| @ -0,0 +1,106 @@ | |||||
| 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(XFile image)? cameraHandle; | |||||
| final Function(XFile image)? galleryHandleSingle; | |||||
| final Function(List<XFile> images)? galleryHandleMultiple; | |||||
| // TODO: Implement. Perhaps after first release? | |||||
| 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: () async { | |||||
| final XFile? image = await _picker.pickImage(source: ImageSource.camera); | |||||
| if (image == null) { | |||||
| return; | |||||
| } | |||||
| cameraHandle!(image); | |||||
| }, | |||||
| context: context, | |||||
| ), | |||||
| _filePickerSelection( | |||||
| hasHandle: galleryHandleSingle != null, | |||||
| icon: Icons.image, | |||||
| onTap: () async { | |||||
| final XFile? image = await _picker.pickImage(source: ImageSource.gallery); | |||||
| if (image == null) { | |||||
| return; | |||||
| } | |||||
| galleryHandleSingle!(image); | |||||
| }, | |||||
| context: context, | |||||
| ), | |||||
| _filePickerSelection( | |||||
| hasHandle: galleryHandleMultiple != null, | |||||
| icon: Icons.image, | |||||
| onTap: () async { | |||||
| final List<XFile>? 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, | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,35 @@ | |||||
| 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: InteractiveViewer( | |||||
| panEnabled: false, | |||||
| minScale: 1, | |||||
| maxScale: 4, | |||||
| child: Image.file( | |||||
| message.file, | |||||
| ), | |||||
| ) | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } | |||||
| @ -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; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,145 @@ | |||||
| 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'; | |||||
| 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 { | |||||
| File file; | |||||
| ImageMessage({ | |||||
| id, | |||||
| symmetricKey, | |||||
| userSymmetricKey, | |||||
| senderId, | |||||
| senderUsername, | |||||
| associationKey, | |||||
| createdAt, | |||||
| failedToSend, | |||||
| required this.file, | |||||
| }) : super( | |||||
| id: id, | |||||
| symmetricKey: symmetricKey, | |||||
| userSymmetricKey: userSymmetricKey, | |||||
| senderId: senderId, | |||||
| senderUsername: senderUsername, | |||||
| associationKey: associationKey, | |||||
| createdAt: createdAt, | |||||
| failedToSend: failedToSend, | |||||
| ); | |||||
| static Future<ImageMessage> fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) async { | |||||
| 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']), | |||||
| ); | |||||
| File file = await getFile( | |||||
| '$defaultServerUrl/files/${json['message_data']['attachment']['image_link']}', | |||||
| '${json['id']}', | |||||
| symmetricKey, | |||||
| ); | |||||
| 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, | |||||
| file: file, | |||||
| ); | |||||
| } | |||||
| @override | |||||
| Map<String, dynamic> toMap() { | |||||
| return { | |||||
| 'id': id, | |||||
| 'symmetric_key': symmetricKey, | |||||
| 'user_symmetric_key': userSymmetricKey, | |||||
| 'file': file.path, | |||||
| 'sender_id': senderId, | |||||
| 'sender_username': senderUsername, | |||||
| 'association_key': associationKey, | |||||
| 'created_at': createdAt, | |||||
| 'failed_to_send': failedToSend ? 1 : 0, | |||||
| }; | |||||
| } | |||||
| Future<Map<String, dynamic>> payloadJson(Conversation conversation) async { | |||||
| final String messageDataId = (const Uuid()).v4(); | |||||
| final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||||
| final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||||
| List<Map<String, String>> messages = await super.payloadJsonBase( | |||||
| symmetricKey, | |||||
| userSymmetricKey, | |||||
| conversation, | |||||
| id, | |||||
| messageDataId, | |||||
| ); | |||||
| Map<String, dynamic> messageData = { | |||||
| 'id': messageDataId, | |||||
| 'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), | |||||
| 'symmetric_key': AesHelper.aesEncrypt( | |||||
| userSymmetricKey, | |||||
| Uint8List.fromList(base64.encode(symmetricKey).codeUnits), | |||||
| ), | |||||
| 'attachment': { | |||||
| 'data': AesHelper.aesEncrypt(base64.encode(symmetricKey), Uint8List.fromList(file.readAsBytesSync())), | |||||
| 'mimetype': lookupMimeType(file.path), | |||||
| 'extension': getExtension(file.path), | |||||
| } | |||||
| }; | |||||
| return <String, dynamic>{ | |||||
| 'message_data': messageData, | |||||
| 'message': messages, | |||||
| }; | |||||
| } | |||||
| @override | |||||
| String getContent() { | |||||
| return 'Image'; | |||||
| } | |||||
| @override | |||||
| String toString() { | |||||
| return ''' | |||||
| id: $id | |||||
| file: ${file.path}, | |||||
| senderId: $senderId | |||||
| senderUsername: $senderUsername | |||||
| associationKey: $associationKey | |||||
| createdAt: $createdAt | |||||
| '''; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,135 @@ | |||||
| 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<String, dynamic> 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, | |||||
| ); | |||||
| } | |||||
| @override | |||||
| Map<String, dynamic> 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<Map<String, dynamic>> payloadJson(Conversation conversation) async { | |||||
| final String messageDataId = (const Uuid()).v4(); | |||||
| final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||||
| final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32)); | |||||
| List<Map<String, String>> messages = await super.payloadJsonBase( | |||||
| symmetricKey, | |||||
| userSymmetricKey, | |||||
| conversation, | |||||
| id, | |||||
| messageDataId, | |||||
| ); | |||||
| Map<String, String> messageData = { | |||||
| 'id': id, | |||||
| '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 <String, dynamic>{ | |||||
| '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 | |||||
| '''; | |||||
| } | |||||
| } | |||||
| @ -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<File> 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; | |||||
| } | |||||
| @ -0,0 +1,26 @@ | |||||
| import 'dart:io'; | |||||
| import 'dart:typed_data'; | |||||
| import 'package:path_provider/path_provider.dart'; | |||||
| Future<String> get _localPath async { | |||||
| final directory = await getApplicationDocumentsDirectory(); | |||||
| return directory.path; | |||||
| } | |||||
| Future<File> _localFile(String fileName) async { | |||||
| final path = await _localPath; | |||||
| return File('$path/$fileName'); | |||||
| } | |||||
| Future<File> 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; | |||||
| } | |||||
| @ -0,0 +1,241 @@ | |||||
| 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 StatefulWidget { | |||||
| 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 | |||||
| _ConversationMessageState createState() => _ConversationMessageState(); | |||||
| } | |||||
| class _ConversationMessageState extends State<ConversationMessage> { | |||||
| List<PopupMenuEntry<String>> 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: ( | |||||
| widget.message.senderId == widget.profile.id ? | |||||
| Alignment.topRight : | |||||
| Alignment.topLeft | |||||
| ), | |||||
| child: Column( | |||||
| crossAxisAlignment: widget.message.senderId == widget.profile.id ? | |||||
| CrossAxisAlignment.end : | |||||
| CrossAxisAlignment.start, | |||||
| children: <Widget>[ | |||||
| messageContent(context), | |||||
| const SizedBox(height: 1.5), | |||||
| Row( | |||||
| mainAxisAlignment: widget.message.senderId == widget.profile.id ? | |||||
| MainAxisAlignment.end : | |||||
| MainAxisAlignment.start, | |||||
| children: <Widget>[ | |||||
| const SizedBox(width: 10), | |||||
| usernameOrFailedToSend(), | |||||
| ], | |||||
| ), | |||||
| const SizedBox(height: 1.5), | |||||
| Row( | |||||
| mainAxisAlignment: widget.message.senderId == widget.profile.id ? | |||||
| MainAxisAlignment.end : | |||||
| MainAxisAlignment.start, | |||||
| children: <Widget>[ | |||||
| const SizedBox(width: 10), | |||||
| Text( | |||||
| convertToAgo(widget.message.createdAt), | |||||
| textAlign: widget.message.senderId == widget.profile.id ? | |||||
| TextAlign.left : | |||||
| TextAlign.right, | |||||
| style: TextStyle( | |||||
| fontSize: 12, | |||||
| color: Colors.grey[500], | |||||
| ), | |||||
| ), | |||||
| ], | |||||
| ), | |||||
| widget.index != 0 ? | |||||
| const SizedBox(height: 20) : | |||||
| const SizedBox.shrink(), | |||||
| ], | |||||
| ) | |||||
| ), | |||||
| ); | |||||
| } | |||||
| 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<void>((String? delta) async { | |||||
| if (delta == null) { | |||||
| return; | |||||
| } | |||||
| }); | |||||
| } | |||||
| void _storePosition(TapDownDetails details) { | |||||
| _tapPosition = details.globalPosition; | |||||
| } | |||||
| Widget messageContent(BuildContext context) { | |||||
| if (widget.message.runtimeType == ImageMessage) { | |||||
| return GestureDetector( | |||||
| onTap: () { | |||||
| Navigator.push(context, MaterialPageRoute(builder: (context) { | |||||
| return ViewImage( | |||||
| 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( | |||||
| (widget.message as ImageMessage).file, | |||||
| fit: BoxFit.fill, | |||||
| ), | |||||
| ), | |||||
| ), | |||||
| ); | |||||
| } | |||||
| 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( | |||||
| 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() { | |||||
| if (widget.message.senderId != widget.profile.id) { | |||||
| return Text( | |||||
| widget.message.senderUsername, | |||||
| style: TextStyle( | |||||
| fontSize: 12, | |||||
| color: Colors.grey[300], | |||||
| ), | |||||
| ); | |||||
| } | |||||
| if (widget.message.failedToSend) { | |||||
| return Row( | |||||
| mainAxisAlignment: MainAxisAlignment.end, | |||||
| children: const <Widget>[ | |||||
| 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(); | |||||
| } | |||||
| } | |||||