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/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..d217493 100644 --- a/Backend/Api/Auth/Login.go +++ b/Backend/Api/Auth/Login.go @@ -16,73 +16,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 +60,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 +76,29 @@ 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 = 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/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..a1681da 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -14,10 +14,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 +26,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 +34,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 +47,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() @@ -62,10 +64,9 @@ func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { return } - // TODO: Fix error handling here conversationIds = strings.Split(conversationIds[0], ",") - userConversations, err = Database.GetConversationDetailsByIds( + conversationDetails, err = Database.GetConversationDetailsByIds( conversationIds, ) if err != nil { @@ -73,7 +74,15 @@ 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 = detail.Attachment.FilePath + } + + returnJSON, err = json.MarshalIndent(conversationDetails, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) return 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..686f1c1 100644 --- a/Backend/Api/Messages/MessageThread.go +++ b/Backend/Api/Messages/MessageThread.go @@ -14,9 +14,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 +36,14 @@ 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 = message.MessageData.Attachment.FilePath + } + returnJSON, err = json.MarshalIndent(messages, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) 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 999a2f2..8b0c280 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...") @@ -63,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") @@ -76,7 +78,12 @@ 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") + + // 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/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/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/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/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/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/Database/Seeder/profile_image_enc.dat b/Backend/Database/Seeder/profile_image_enc.dat new file mode 100644 index 0000000..f82798a Binary files /dev/null and b/Backend/Database/Seeder/profile_image_enc.dat differ diff --git a/Backend/Models/Attachments.go b/Backend/Models/Attachments.go new file mode 100644 index 0000000..739369e --- /dev/null +++ b/Backend/Models/Attachments.go @@ -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"` +} 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/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/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/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/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/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/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 new file mode 100644 index 0000000..7160e0d --- /dev/null +++ b/mobile/lib/components/file_picker.dart @@ -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 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? 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/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 new file mode 100644 index 0000000..2fe3fcf --- /dev/null +++ b/mobile/lib/components/view_image.dart @@ -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, + ), + ) + ), + ); + } +} + 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 e7d760d..d8222d3 100644 --- a/mobile/lib/models/conversations.dart +++ b/mobile/lib/models/conversations.dart @@ -1,11 +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'; @@ -34,7 +38,7 @@ Future createConversation(String title, List friends, bool status: ConversationStatus.pending, isRead: true, ); - + await db.insert( 'conversations', conversation.toMap(), @@ -142,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'], @@ -151,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, ); } @@ -164,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'], @@ -173,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, ); }); } @@ -184,7 +201,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 != ? @@ -219,6 +236,7 @@ class Conversation { bool twoUser; ConversationStatus status; bool isRead; + File? icon; Conversation({ required this.id, @@ -229,6 +247,7 @@ class Conversation { required this.twoUser, required this.status, required this.isRead, + this.icon, }); @@ -297,13 +316,35 @@ 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 payloadImageJson() { + if (icon == null) { + return {}; + } + + return { + 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())), + 'mimetype': lookupMimeType(icon!.path), + 'extension': getExtension(icon!.path), + }; } Map toMap() { @@ -316,6 +357,7 @@ class Conversation { 'two_user': twoUser ? 1 : 0, 'status': status.index, 'is_read': isRead ? 1 : 0, + 'file': icon != null ? icon!.path : null, }; } @@ -348,11 +390,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'] ?? '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 new file mode 100644 index 0000000..ff11a8a --- /dev/null +++ b/mobile/lib/models/image_message.dart @@ -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 fromJson(Map 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 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> 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, + id, + messageDataId, + ); + + Map 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 { + '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 + '''; + } +} diff --git a/mobile/lib/models/messages.dart b/mobile/lib/models/messages.dart index e88594f..debb69f 100644 --- a/mobile/lib/models/messages.dart +++ b/mobile/lib/models/messages.dart @@ -1,17 +1,17 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:typed_data'; -import 'package:pointycastle/export.dart'; +import 'package:pointycastle/pointycastle.dart'; import 'package:uuid/uuid.dart'; +import '/models/image_message.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 '/models/text_messages.dart'; +import '/models/conversations.dart'; import '/utils/encryption/crypto_utils.dart'; import '/utils/storage/database.dart'; -import '/utils/strings.dart'; const messageTypeReceiver = 'receiver'; const messageTypeSender = 'sender'; @@ -30,11 +30,28 @@ Future> getMessagesForThread(Conversation conversation) async { ); return List.generate(maps.length, (i) { - return Message( + 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'], 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 +59,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 +82,13 @@ class Message { required this.failedToSend, }); - - 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 { + 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'); @@ -110,11 +96,6 @@ 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); @@ -122,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, @@ -150,20 +129,11 @@ 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 messages; + } - return jsonEncode({ - 'message_data': messageData, - 'message': messages, - }); + String getContent() { + return ''; } Map toMap() { @@ -171,7 +141,6 @@ class Message { 'id': id, 'symmetric_key': symmetricKey, 'user_symmetric_key': userSymmetricKey, - 'data': data, 'sender_id': senderId, 'sender_username': senderUsername, 'association_key': associationKey, @@ -179,19 +148,4 @@ class Message { 'failed_to_send': failedToSend ? 1 : 0, }; } - - @override - String toString() { - return ''' - - - id: $id - data: $data - senderId: $senderId - senderUsername: $senderUsername - associationKey: $associationKey - createdAt: $createdAt - '''; - } - } diff --git a/mobile/lib/models/my_profile.dart b/mobile/lib/models/my_profile.dart index 526e668..d5f80e7 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,10 @@ class MyProfile { String? friendId; RSAPrivateKey? privateKey; RSAPublicKey? publicKey; + String? symmetricKey; DateTime? loggedInAt; + File? image; + String? imageLink; String messageExpiryDefault = 'no_expiry'; MyProfile({ @@ -26,7 +31,10 @@ class MyProfile { this.friendId, this.privateKey, this.publicKey, + this.symmetricKey, this.loggedInAt, + this.image, + this.imageLink, required this.messageExpiryDefault, }); @@ -44,8 +52,11 @@ 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, + imageLink: json['image_link'], ); } @@ -57,7 +68,7 @@ class MyProfile { logged_in_at: $loggedInAt public_key: $publicKey private_key: $privateKey - '''; + '''; } String toJson() { @@ -70,8 +81,11 @@ 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, + 'image_link': imageLink, }); } @@ -80,7 +94,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( + '$defaultServerUrl/files/${['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/models/text_messages.dart b/mobile/lib/models/text_messages.dart new file mode 100644 index 0000000..e9ba715 --- /dev/null +++ b/mobile/lib/models/text_messages.dart @@ -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 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 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) 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, + id, + messageDataId, + ); + + Map 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 { + '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/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/conversations.dart b/mobile/lib/utils/storage/conversations.dart index 985b9ea..27ba927 100644 --- a/mobile/lib/utils/storage/conversations.dart +++ b/mobile/lib/utils/storage/conversations.dart @@ -1,6 +1,9 @@ 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'; @@ -13,12 +16,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 +35,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 +144,15 @@ Future updateConversations() async { ); } + // TODO: Handle exception here + if (conversationDetailJson['attachment_id'] != null) { + conversation.icon = await getFile( + '$defaultServerUrl/files/${conversationDetailJson['attachment']['image_link']}', + conversation.id, + conversation.symmetricKey, + ); + } + await db.insert( 'conversations', conversation.toMap(), diff --git a/mobile/lib/utils/storage/database.dart b/mobile/lib/utils/storage/database.dart index e643f53..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 ); '''); @@ -63,6 +64,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/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/utils/storage/messages.dart b/mobile/lib/utils/storage/messages.dart index da715e0..fd7ced7 100644 --- a/mobile/lib/utils/storage/messages.dart +++ b/mobile/lib/utils/storage/messages.dart @@ -1,72 +1,121 @@ import 'dart:convert'; +import 'dart:io'; +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) 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); - Message message = Message( - id: messageId, - symmetricKey: '', - userSymmetricKey: '', - senderId: currentUser.userId, - senderUsername: profile.username, - data: data, - associationKey: currentUser.associationKey, - createdAt: DateTime.now().toIso8601String(), - failedToSend: false, - ); + List messages = []; + List> messagesToSend = []; final db = await getDatabaseConnection(); - await db.insert( - 'messages', - message.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); + if (data != null) { + 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, + ); + } + + for (File file in files) { + + String messageId = uuid.v4(); + + 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 { @@ -89,10 +138,17 @@ Future updateMessageThread(Conversation conversation, {MyProfile? profile} final db = await getDatabaseConnection(); for (var i = 0; i < messageThreadJson.length; i++) { - Message message = Message.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/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/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 572f855..5eabbec 100644 --- a/mobile/lib/views/main/conversation/detail.dart +++ b/mobile/lib/views/main/conversation/detail.dart @@ -1,11 +1,15 @@ -import 'package:Envelope/components/custom_title_bar.dart'; +import 'dart:io'; + +import 'package:Envelope/views/main/conversation/message.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'; import '/utils/storage/messages.dart'; -import '/utils/time.dart'; import '/views/main/conversation/settings.dart'; class ConversationDetail extends StatefulWidget{ @@ -17,7 +21,6 @@ class ConversationDetail extends StatefulWidget{ @override _ConversationDetailState createState() => _ConversationDetailState(); - } class _ConversationDetailState extends State { @@ -30,6 +33,9 @@ class _ConversationDetailState extends State { TextEditingController msgController = TextEditingController(); + bool showFilePicker = false; + List selectedImages = []; + @override Widget build(BuildContext context) { return Scaffold( @@ -44,97 +50,24 @@ 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( 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(), ], ), ); @@ -152,38 +85,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( @@ -194,79 +95,200 @@ 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 - ), + return ConversationMessage( + message: messages[index], + profile: profile, + index: index, + ); + }, + ); + } + + 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, + ), ), - 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, - ) + ], + ), + + 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 + ), + ), ), ), - const SizedBox(height: 1.5), - Row( - mainAxisAlignment: messages[index].senderUsername == profile.username ? - MainAxisAlignment.end : - MainAxisAlignment.start, - children: [ - const SizedBox(width: 10), - usernameOrFailedToSend(index), - ], + ), + + ], + ); + }, + ) + ); + } + + 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(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], + + 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, + ), ), - index != 0 ? - const SizedBox(height: 20) : - const SizedBox.shrink(), - ], - ) + + 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 = ''; + selectedImages = []; + }); + }, + child: Icon( + Icons.send, + color: Theme.of(context).colorScheme.onPrimary, + size: 22 + ), + backgroundColor: Theme.of(context).primaryColor, + ), + ), + ), + const SizedBox(width: 10), + ], + ), + + showFilePicker ? + FilePicker( + cameraHandle: (XFile image) {}, + 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/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 a94e900..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( @@ -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/message.dart b/mobile/lib/views/main/conversation/message.dart new file mode 100644 index 0000000..5bdc982 --- /dev/null +++ b/mobile/lib/views/main/conversation/message.dart @@ -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 { + + 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: ( + widget.message.senderId == widget.profile.id ? + Alignment.topRight : + Alignment.topLeft + ), + child: Column( + crossAxisAlignment: widget.message.senderId == widget.profile.id ? + CrossAxisAlignment.end : + CrossAxisAlignment.start, + children: [ + + messageContent(context), + + const SizedBox(height: 1.5), + + Row( + mainAxisAlignment: widget.message.senderId == widget.profile.id ? + MainAxisAlignment.end : + MainAxisAlignment.start, + children: [ + const SizedBox(width: 10), + usernameOrFailedToSend(), + ], + ), + + const SizedBox(height: 1.5), + + Row( + mainAxisAlignment: widget.message.senderId == widget.profile.id ? + MainAxisAlignment.end : + MainAxisAlignment.start, + children: [ + 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((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 [ + 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/lib/views/main/conversation/settings.dart b/mobile/lib/views/main/conversation/settings.dart index 35939e1..2c87896 100644 --- a/mobile/lib/views/main/conversation/settings.dart +++ b/mobile/lib/views/main/conversation/settings.dart @@ -1,6 +1,11 @@ +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'; import 'package:Envelope/views/main/conversation/create_add_users.dart'; import 'package:flutter/material.dart'; @@ -75,12 +80,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 +96,7 @@ class _ConversationSettingsState extends State { fontWeight: FontWeight.w500, ), ), + widget.conversation.admin && !widget.conversation.twoUser ? IconButton( iconSize: 20, icon: const Icon(Icons.edit), @@ -96,8 +105,22 @@ class _ConversationSettingsState extends State { onPressed: () { Navigator.of(context).push( MaterialPageRoute(builder: (context) => ConversationEditDetails( - saveCallback: (String conversationName) async { + // 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(), + ); + } + widget.conversation.name = conversationName; + widget.conversation.icon = writtenFile; final db = await getDatabaseConnection(); db.update( @@ -107,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); }, @@ -143,9 +174,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..446702b 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'); + } + }, ); } @@ -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..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,12 +88,20 @@ class _ProfileState extends State { Widget usernameHeading() { return Row( children: [ - const CustomCircleAvatar( - icon: Icon(Icons.person, size: 40), - imagePath: null, // TODO: Add image here + + 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( @@ -93,6 +112,7 @@ class _ProfileState extends State { ), ), ), + IconButton( onPressed: () => _panelController.open(), icon: const Icon(Icons.qr_code_2), @@ -101,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'; @@ -191,6 +264,8 @@ class _ProfileState extends State { context, ); }); + + saveProfile(); }, )) ); @@ -242,6 +317,7 @@ class _ProfileState extends State { privateKey: widget.profile.privateKey!, )) ); + saveProfile(); } ), ], @@ -259,8 +335,8 @@ class _ProfileState extends State { }); return Column( - children: [ - Padding( + children: [ + Padding( padding: const EdgeInsets.all(20), child: QrImage( backgroundColor: Theme.of(context).colorScheme.primary, @@ -282,4 +358,9 @@ class _ProfileState extends State { ] ); } + + Future saveProfile() async { + final preferences = await SharedPreferences.getInstance(); + preferences.setString('profile', widget.profile.toJson()); + } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 128580d..d22d858 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: @@ -184,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: @@ -191,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: @@ -198,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: @@ -429,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 9a4d284..78fa8b3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -25,6 +25,9 @@ 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 + path_provider: ^2.0.11 + mime: ^1.0.2 dev_dependencies: flutter_test: