#3 feature/add-attachment-support

Merged
tovi merged 6 commits from feature/add-attachment-support into develop 2 years ago
  1. +1
    -0
      .gitignore
  2. +50
    -0
      Backend/Api/Auth/AddProfileImage.go
  3. +2
    -2
      Backend/Api/Auth/ChangeMessageExpiry.go
  4. +41
    -54
      Backend/Api/Auth/Login.go
  5. +1
    -1
      Backend/Api/Friends/AcceptFriendRequest.go
  6. +64
    -0
      Backend/Api/Messages/AddConversationImage.go
  7. +16
    -7
      Backend/Api/Messages/Conversations.go
  8. +19
    -5
      Backend/Api/Messages/CreateMessage.go
  9. +10
    -0
      Backend/Api/Messages/MessageThread.go
  10. +4
    -3
      Backend/Api/Messages/UpdateConversation.go
  11. +7
    -0
      Backend/Api/Routes.go
  12. +42
    -0
      Backend/Database/Attachments.go
  13. +6
    -1
      Backend/Database/ConversationDetails.go
  14. +1
    -0
      Backend/Database/Init.go
  15. +8
    -1
      Backend/Database/Messages.go
  16. +29
    -0
      Backend/Database/Seeder/FriendSeeder.go
  17. +12
    -0
      Backend/Database/Seeder/UserSeeder.go
  18. BIN
      Backend/Database/Seeder/profile_image_enc.dat
  19. +11
    -0
      Backend/Models/Attachments.go
  20. +2
    -0
      Backend/Models/Conversations.go
  21. +1
    -0
      Backend/Models/Friends.go
  22. +3
    -1
      Backend/Models/Messages.go
  23. +14
    -2
      Backend/Models/Users.go
  24. +46
    -0
      Backend/Util/Files.go
  25. +6
    -0
      mobile/ios/Runner/Info.plist
  26. +66
    -31
      mobile/lib/components/custom_circle_avatar.dart
  27. +106
    -0
      mobile/lib/components/file_picker.dart
  28. +0
    -1
      mobile/lib/components/user_search_result.dart
  29. +35
    -0
      mobile/lib/components/view_image.dart
  30. +13
    -0
      mobile/lib/exceptions/update_data_exception.dart
  31. +45
    -3
      mobile/lib/models/conversations.dart
  32. +145
    -0
      mobile/lib/models/image_message.dart
  33. +36
    -82
      mobile/lib/models/messages.dart
  34. +33
    -2
      mobile/lib/models/my_profile.dart
  35. +135
    -0
      mobile/lib/models/text_messages.dart
  36. +8
    -2
      mobile/lib/utils/encryption/aes_helper.dart
  37. +41
    -4
      mobile/lib/utils/storage/conversations.dart
  38. +3
    -1
      mobile/lib/utils/storage/database.dart
  39. +32
    -0
      mobile/lib/utils/storage/get_file.dart
  40. +69
    -13
      mobile/lib/utils/storage/messages.dart
  41. +26
    -0
      mobile/lib/utils/storage/write_file.dart
  42. +11
    -10
      mobile/lib/views/authentication/login.dart
  43. +0
    -1
      mobile/lib/views/main/conversation/create_add_users_list.dart
  44. +163
    -141
      mobile/lib/views/main/conversation/detail.dart
  45. +49
    -7
      mobile/lib/views/main/conversation/edit_details.dart
  46. +3
    -1
      mobile/lib/views/main/conversation/list.dart
  47. +3
    -4
      mobile/lib/views/main/conversation/list_item.dart
  48. +241
    -0
      mobile/lib/views/main/conversation/message.dart
  49. +39
    -8
      mobile/lib/views/main/conversation/settings.dart
  50. +0
    -1
      mobile/lib/views/main/conversation/settings_user_list_item.dart
  51. +0
    -1
      mobile/lib/views/main/friend/list_item.dart
  52. +0
    -1
      mobile/lib/views/main/friend/request_list_item.dart
  53. +85
    -4
      mobile/lib/views/main/profile/profile.dart
  54. +85
    -1
      mobile/pubspec.lock
  55. +3
    -0
      mobile/pubspec.yaml

+ 1
- 0
.gitignore View File

@ -1 +1,2 @@
/mobile/.env
/Backend/attachments/*

+ 50
- 0
Backend/Api/Auth/AddProfileImage.go View File

@ -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)
}

+ 2
- 2
Backend/Api/Auth/ChangeMessageExpiry.go View File

@ -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(),


+ 41
- 54
Backend/Api/Auth/Login.go View File

@ -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"`
}
func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) {
var (
status = "error"
messageExpiryRaw driver.Value
messageExpiry 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)
ImageLink string `json:"image_link"`
}
// Login logs the user into the system
func Login(w http.ResponseWriter, r *http.Request) {
var (
creds credentials
userData Models.User
user Models.User
session Models.Session
expiresAt time.Time
messageExpiryRaw driver.Value
messageExpiry string
imageLink string
returnJSON []byte
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)
}

+ 1
- 1
Backend/Api/Friends/AcceptFriendRequest.go View File

@ -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
}


+ 64
- 0
Backend/Api/Messages/AddConversationImage.go View File

@ -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)
}

+ 16
- 7
Backend/Api/Messages/Conversations.go View File

@ -14,7 +14,7 @@ import (
// EncryptedConversationList returns an encrypted list of all Conversations
func EncryptedConversationList(w http.ResponseWriter, r *http.Request) {
var (
userConversations []Models.UserConversation
conversationDetails []Models.UserConversation
userSession Models.Session
returnJSON []byte
err error
@ -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,10 +47,12 @@ 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
conversationDetails []Models.ConversationDetail
detail Models.ConversationDetail
query url.Values
conversationIds []string
returnJSON []byte
i int
ok bool
err error
)
@ -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


+ 19
- 5
Backend/Api/Messages/CreateMessage.go View File

@ -1,41 +1,55 @@
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
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)
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(&rawMessageData.Messages)
err = Database.CreateMessages(&messageData.Messages)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
}

+ 10
- 0
Backend/Api/Messages/MessageThread.go View File

@ -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)


+ 4
- 3
Backend/Api/Messages/UpdateConversation.go View File

@ -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)
}

+ 7
- 0
Backend/Api/Routes.go View File

@ -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))
}

+ 42
- 0
Backend/Database/Attachments.go View File

@ -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
}

+ 6
- 1
Backend/Database/ConversationDetails.go View File

@ -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).


+ 1
- 0
Backend/Database/Init.go View File

@ -20,6 +20,7 @@ var DB *gorm.DB
func getModels() []interface{} {
return []interface{}{
&Models.Session{},
&Models.Attachment{},
&Models.User{},
&Models.FriendRequest{},
&Models.MessageData{},


+ 8
- 1
Backend/Database/Messages.go View File

@ -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).


+ 29
- 0
Backend/Database/Seeder/FriendSeeder.go View File

@ -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)


+ 12
- 0
Backend/Database/Seeder/UserSeeder.go View File

@ -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


BIN
Backend/Database/Seeder/profile_image_enc.dat View File


+ 11
- 0
Backend/Models/Attachments.go View File

@ -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"`
}

+ 2
- 0
Backend/Models/Conversations.go View File

@ -10,6 +10,8 @@ type ConversationDetail struct {
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


+ 1
- 0
Backend/Models/Friends.go View File

@ -13,6 +13,7 @@ type FriendRequest struct {
User User ` json:"user"`
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"`


+ 3
- 1
Backend/Models/Messages.go View File

@ -11,7 +11,9 @@ import (
// encrypted through the Message.SymmetricKey
type MessageData struct {
Base
Data string `gorm:"not null" json:"data"` // 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
}


+ 14
- 2
Backend/Models/Users.go View File

@ -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
}

+ 46
- 0
Backend/Util/Files.go View File

@ -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
}

+ 6
- 0
mobile/ios/Runner/Info.plist View File

@ -47,5 +47,11 @@
<true/>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan QR codes</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Upload images for screen background</string>
<key>NSCameraUsageDescription</key>
<string>Upload image from camera for screen background</string>
<key>NSMicrophoneUsageDescription</key>
<string>Post videos to profile</string>
</dict>
</plist>

+ 66
- 31
mobile/lib/components/custom_circle_avatar.dart View File

@ -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.image,
this.editImageCallback,
this.radius = 20,
}) : super(key: key);
@override
_CustomCircleAvatarState createState() => _CustomCircleAvatarState();
}
class _CustomCircleAvatarState extends State<CustomCircleAvatar>{
AvatarTypes type = AvatarTypes.image;
@override
void initState() {
super.initState();
if (widget.imagePath != null) {
type = AvatarTypes.image;
return;
}
Widget avatar() {
AvatarTypes? type;
if (widget.icon != null) {
if (icon != null) {
type = AvatarTypes.icon;
return;
}
if (widget.initials != null) {
if (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,
child: Text(initials!),
radius: radius,
);
}
if (type == AvatarTypes.icon) {
return CircleAvatar(
backgroundColor: Colors.grey[300],
child: widget.icon,
radius: widget.radius,
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),
]
);
}
}

+ 106
- 0
mobile/lib/components/file_picker.dart View File

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class FilePicker extends StatelessWidget {
FilePicker({
Key? key,
this.cameraHandle,
this.galleryHandleSingle,
this.galleryHandleMultiple,
this.fileHandle,
}) : super(key: key);
final Function(XFile image)? cameraHandle;
final Function(XFile image)? galleryHandleSingle;
final Function(List<XFile> images)? galleryHandleMultiple;
// TODO: Implement. Perhaps after first release?
final Function()? fileHandle;
final ImagePicker _picker = ImagePicker();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10, left: 5, right: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_filePickerSelection(
hasHandle: cameraHandle != null,
icon: Icons.camera_alt,
onTap: () async {
final XFile? image = await _picker.pickImage(source: ImageSource.camera);
if (image == null) {
return;
}
cameraHandle!(image);
},
context: context,
),
_filePickerSelection(
hasHandle: galleryHandleSingle != null,
icon: Icons.image,
onTap: () async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image == null) {
return;
}
galleryHandleSingle!(image);
},
context: context,
),
_filePickerSelection(
hasHandle: galleryHandleMultiple != null,
icon: Icons.image,
onTap: () async {
final List<XFile>? images = await _picker.pickMultiImage();
if (images == null) {
return;
}
galleryHandleMultiple!(images);
},
context: context,
),
_filePickerSelection(
hasHandle: fileHandle != null,
icon: Icons.file_present_sharp,
onTap: () {
},
context: context,
),
],
)
);
}
Widget _filePickerSelection({
required bool hasHandle,
required IconData icon,
required Function() onTap,
required BuildContext context
}) {
if (!hasHandle) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(left: 5, right: 5),
child: GestureDetector(
onTap: onTap,
child: Container(
height: 75,
width: 75,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(25),
),
child: Icon(
icon,
size: 40,
),
),
),
);
}
}

+ 0
- 1
mobile/lib/components/user_search_result.dart View File

@ -41,7 +41,6 @@ class _UserSearchResultState extends State<UserSearchResult>{
CustomCircleAvatar(
initials: widget.user.username[0].toUpperCase(),
icon: const Icon(Icons.person, size: 80),
imagePath: null,
radius: 50,
),
const SizedBox(height: 10),


+ 35
- 0
mobile/lib/components/view_image.dart View File

@ -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,
),
)
),
);
}
}

+ 13
- 0
mobile/lib/exceptions/update_data_exception.dart View File

@ -0,0 +1,13 @@
class UpdateDataException implements Exception {
final String _message;
UpdateDataException([
this._message = 'An error occured while updating data.',
]);
@override
String toString() {
return _message;
}
}

+ 45
- 3
mobile/lib/models/conversations.dart View File

@ -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';
@ -142,6 +146,11 @@ Future<Conversation> 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<Conversation> 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<List<Conversation>> 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<List<Conversation>> getConversations() async {
twoUser: maps[i]['two_user'] == 1,
status: ConversationStatus.values[maps[i]['status']],
isRead: maps[i]['is_read'] == 1,
icon: file,
);
});
}
@ -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<String, dynamic> 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<String, dynamic> payloadImageJson() {
if (icon == null) {
return {};
}
return {
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())),
'mimetype': lookupMimeType(icon!.path),
'extension': getExtension(icon!.path),
};
}
Map<String, dynamic> 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'],


+ 145
- 0
mobile/lib/models/image_message.dart View File

@ -0,0 +1,145 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:Envelope/models/my_profile.dart';
import 'package:Envelope/utils/storage/get_file.dart';
import 'package:Envelope/utils/storage/write_file.dart';
import 'package:mime/mime.dart';
import 'package:pointycastle/pointycastle.dart';
import 'package:uuid/uuid.dart';
import '/models/conversations.dart';
import '/models/messages.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/strings.dart';
class ImageMessage extends Message {
File file;
ImageMessage({
id,
symmetricKey,
userSymmetricKey,
senderId,
senderUsername,
associationKey,
createdAt,
failedToSend,
required this.file,
}) : super(
id: id,
symmetricKey: symmetricKey,
userSymmetricKey: userSymmetricKey,
senderId: senderId,
senderUsername: senderUsername,
associationKey: associationKey,
createdAt: createdAt,
failedToSend: failedToSend,
);
static Future<ImageMessage> fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) async {
var userSymmetricKey = CryptoUtils.rsaDecrypt(
base64.decode(json['symmetric_key']),
privKey,
);
var symmetricKey = AesHelper.aesDecrypt(
userSymmetricKey,
base64.decode(json['message_data']['symmetric_key']),
);
var senderId = AesHelper.aesDecrypt(
base64.decode(symmetricKey),
base64.decode(json['message_data']['sender_id']),
);
File file = await getFile(
'$defaultServerUrl/files/${json['message_data']['attachment']['image_link']}',
'${json['id']}',
symmetricKey,
);
return ImageMessage(
id: json['id'],
symmetricKey: symmetricKey,
userSymmetricKey: base64.encode(userSymmetricKey),
senderId: senderId,
senderUsername: 'Unknown',
associationKey: json['association_key'],
createdAt: json['created_at'],
failedToSend: false,
file: file,
);
}
@override
Map<String, dynamic> toMap() {
return {
'id': id,
'symmetric_key': symmetricKey,
'user_symmetric_key': userSymmetricKey,
'file': file.path,
'sender_id': senderId,
'sender_username': senderUsername,
'association_key': associationKey,
'created_at': createdAt,
'failed_to_send': failedToSend ? 1 : 0,
};
}
Future<Map<String, dynamic>> payloadJson(Conversation conversation) async {
final String messageDataId = (const Uuid()).v4();
final symmetricKey = AesHelper.deriveKey(generateRandomString(32));
final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32));
List<Map<String, String>> messages = await super.payloadJsonBase(
symmetricKey,
userSymmetricKey,
conversation,
id,
messageDataId,
);
Map<String, dynamic> messageData = {
'id': messageDataId,
'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)),
'symmetric_key': AesHelper.aesEncrypt(
userSymmetricKey,
Uint8List.fromList(base64.encode(symmetricKey).codeUnits),
),
'attachment': {
'data': AesHelper.aesEncrypt(base64.encode(symmetricKey), Uint8List.fromList(file.readAsBytesSync())),
'mimetype': lookupMimeType(file.path),
'extension': getExtension(file.path),
}
};
return <String, dynamic>{
'message_data': messageData,
'message': messages,
};
}
@override
String getContent() {
return 'Image';
}
@override
String toString() {
return '''
id: $id
file: ${file.path},
senderId: $senderId
senderUsername: $senderUsername
associationKey: $associationKey
createdAt: $createdAt
''';
}
}

+ 36
- 82
mobile/lib/models/messages.dart View File

@ -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,36 +30,51 @@ Future<List<Message>> 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'],
data: maps[i]['data'],
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'],
text: maps[i]['data'],
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,
);
});
}
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<String, dynamic> json, RSAPrivateKey privKey) {
var userSymmetricKey = CryptoUtils.rsaDecrypt(
base64.decode(json['symmetric_key']),
privKey,
);
var symmetricKey = AesHelper.aesDecrypt(
userSymmetricKey,
base64.decode(json['message_data']['symmetric_key']),
);
var senderId = AesHelper.aesDecrypt(
base64.decode(symmetricKey),
base64.decode(json['message_data']['sender_id']),
);
var data = AesHelper.aesDecrypt(
base64.decode(symmetricKey),
base64.decode(json['message_data']['data']),
);
return 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<String> payloadJson(Conversation conversation, String messageId) async {
Future<List<Map<String, String>>> 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<Map<String, String>> messages = [];
List<ConversationUser> 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<String, String> 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(<String, dynamic>{
'message_data': messageData,
'message': messages,
});
String getContent() {
return '';
}
Map<String, dynamic> 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
''';
}
}

+ 33
- 2
mobile/lib/models/my_profile.dart View File

@ -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'],
);
}
@ -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;


+ 135
- 0
mobile/lib/models/text_messages.dart View File

@ -0,0 +1,135 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:pointycastle/pointycastle.dart';
import 'package:uuid/uuid.dart';
import '/models/conversations.dart';
import '/models/messages.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/strings.dart';
class TextMessage extends Message {
String text;
TextMessage({
id,
symmetricKey,
userSymmetricKey,
senderId,
senderUsername,
associationKey,
createdAt,
failedToSend,
required this.text,
}) : super(
id: id,
symmetricKey: symmetricKey,
userSymmetricKey: userSymmetricKey,
senderId: senderId,
senderUsername: senderUsername,
associationKey: associationKey,
createdAt: createdAt,
failedToSend: failedToSend,
);
factory TextMessage.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) {
var userSymmetricKey = CryptoUtils.rsaDecrypt(
base64.decode(json['symmetric_key']),
privKey,
);
var symmetricKey = AesHelper.aesDecrypt(
userSymmetricKey,
base64.decode(json['message_data']['symmetric_key']),
);
var senderId = AesHelper.aesDecrypt(
base64.decode(symmetricKey),
base64.decode(json['message_data']['sender_id']),
);
var data = AesHelper.aesDecrypt(
base64.decode(symmetricKey),
base64.decode(json['message_data']['data']),
);
return TextMessage(
id: json['id'],
symmetricKey: symmetricKey,
userSymmetricKey: base64.encode(userSymmetricKey),
senderId: senderId,
senderUsername: 'Unknown',
associationKey: json['association_key'],
createdAt: json['created_at'],
failedToSend: false,
text: data,
);
}
@override
Map<String, dynamic> toMap() {
return {
'id': id,
'symmetric_key': symmetricKey,
'user_symmetric_key': userSymmetricKey,
'data': text,
'sender_id': senderId,
'sender_username': senderUsername,
'association_key': associationKey,
'created_at': createdAt,
'failed_to_send': failedToSend ? 1 : 0,
};
}
Future<Map<String, dynamic>> payloadJson(Conversation conversation) async {
final String messageDataId = (const Uuid()).v4();
final symmetricKey = AesHelper.deriveKey(generateRandomString(32));
final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32));
List<Map<String, String>> messages = await super.payloadJsonBase(
symmetricKey,
userSymmetricKey,
conversation,
id,
messageDataId,
);
Map<String, String> messageData = {
'id': id,
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)),
'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)),
'symmetric_key': AesHelper.aesEncrypt(
userSymmetricKey,
Uint8List.fromList(base64.encode(symmetricKey).codeUnits),
),
};
return <String, dynamic>{
'message_data': messageData,
'message': messages,
};
}
@override
String getContent() {
return text;
}
@override
String toString() {
return '''
id: $id
data: $text,
senderId: $senderId
senderUsername: $senderUsername
associationKey: $associationKey
createdAt: $createdAt
''';
}
}

+ 8
- 2
mobile/lib/utils/encryption/aes_helper.dart View File

@ -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) {


+ 41
- 4
mobile/lib/utils/storage/conversations.dart View File

@ -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<void> updateConversation(Conversation conversation, { includeUsers = true } ) async {
Future<void> updateConversation(
Conversation conversation,
{
includeUsers = false,
updatedImage = false,
} ) async {
String sessionCookie = await getSessionCookie();
Map<String, dynamic> conversationJson = await conversation.payloadJson(includeUsers: includeUsers);
var x = await http.put(
var resp = await http.put(
await MyProfile.getServerUrl('api/v1/auth/conversations'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
@ -27,8 +35,28 @@ Future<void> 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<String, dynamic> attachmentJson = conversation.payloadImageJson();
resp = await http.post(
await MyProfile.getServerUrl('api/v1/auth/conversations/${conversation.id}/image'),
headers: <String, String>{
'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<void> 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(),


+ 3
- 1
mobile/lib/utils/storage/database.dart View File

@ -39,7 +39,8 @@ Future<Database> getDatabaseConnection() async {
name TEXT,
two_user INTEGER,
status INTEGER,
is_read INTEGER
is_read INTEGER,
file TEXT
);
''');
@ -63,6 +64,7 @@ Future<Database> getDatabaseConnection() async {
symmetric_key TEXT,
user_symmetric_key TEXT,
data TEXT,
file TEXT,
sender_id TEXT,
sender_username TEXT,
association_key TEXT,


+ 32
- 0
mobile/lib/utils/storage/get_file.dart View File

@ -0,0 +1,32 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import '/utils/encryption/aes_helper.dart';
import '/utils/storage/session_cookie.dart';
import '/utils/storage/write_file.dart';
Future<File> getFile(String link, String imageName, dynamic symmetricKey) async {
var resp = await http.get(
Uri.parse(link),
headers: {
'cookie': await getSessionCookie(),
}
);
if (resp.statusCode != 200) {
throw Exception('Could not get attachment file');
}
var data = AesHelper.aesDecryptBytes(
symmetricKey,
resp.bodyBytes,
);
File file = await writeImage(
imageName,
data,
);
return file;
}

+ 69
- 13
mobile/lib/utils/storage/messages.dart View File

@ -1,63 +1,111 @@
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<void> sendMessage(Conversation conversation, String data) async {
Future<void> sendMessage(Conversation conversation, {
String? data,
List<File> 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(
List<Message> messages = [];
List<Map<String, dynamic>> messagesToSend = [];
final db = await getDatabaseConnection();
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,
data: data,
associationKey: currentUser.associationKey,
createdAt: DateTime.now().toIso8601String(),
failedToSend: false,
file: writtenFile,
);
final db = await getDatabaseConnection();
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: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie,
},
body: messageJson,
);
})
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',
@ -65,6 +113,7 @@ Future<void> sendMessage(Conversation conversation, String data) async {
where: 'id = ?',
whereArgs: [message.id],
);
}
throw exception;
});
}
@ -89,8 +138,15 @@ Future<void> 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<String, dynamic>,
var messageJson = messageThreadJson[i] as Map<String, dynamic>;
var message = messageJson['message_data']['attachment_id'] != null ?
await ImageMessage.fromJson(
messageJson,
profile.privateKey!,
) :
TextMessage.fromJson(
messageJson,
profile.privateKey!,
);


+ 26
- 0
mobile/lib/utils/storage/write_file.dart View File

@ -0,0 +1,26 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
Future<String> get _localPath async {
final directory = await getApplicationDocumentsDirectory();
return directory.path;
}
Future<File> _localFile(String fileName) async {
final path = await _localPath;
return File('$path/$fileName');
}
Future<File> writeImage(String fileName, Uint8List data) async {
final file = await _localFile(fileName);
// Write the file
return file.writeAsBytes(data);
}
String getExtension(String fileName) {
return fileName.split('.').last;
}

+ 11
- 10
mobile/lib/views/authentication/login.dart View File

@ -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<String, dynamic> 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<LoginWidget> {
ModalRoute.withName('/home'),
);
}).catchError((error) {
print(error);
showMessage(
'Could not login to Envelope, please try again later.',
context,


+ 0
- 1
mobile/lib/views/main/conversation/create_add_users_list.dart View File

@ -40,7 +40,6 @@ class _ConversationAddFriendItemState extends State<ConversationAddFriendItem> {
children: <Widget>[
CustomCircleAvatar(
initials: widget.friend.username[0].toUpperCase(),
imagePath: null,
),
const SizedBox(width: 16),
Expanded(


+ 163
- 141
mobile/lib/views/main/conversation/detail.dart View File

@ -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<ConversationDetail> {
@ -30,6 +33,9 @@ class _ConversationDetailState extends State<ConversationDetail> {
TextEditingController msgController = TextEditingController();
bool showFilePicker = false;
List<File> selectedImages = [];
@override
Widget build(BuildContext context) {
return Scaffold(
@ -61,21 +67,143 @@ class _ConversationDetailState extends State<ConversationDetail> {
body: Stack(
children: <Widget>[
messagesView(),
Align(
newMessageContent(),
],
),
);
}
Future<void> fetchMessages() async {
profile = await MyProfile.getProfile();
messages = await getMessagesForThread(widget.conversation);
setState(() {});
}
@override
void initState() {
super.initState();
fetchMessages();
}
Widget messagesView() {
if (messages.isEmpty) {
return const Center(
child: Text('No Messages'),
);
}
return ListView.builder(
itemCount: messages.length,
shrinkWrap: true,
padding: EdgeInsets.only(
top: 10,
bottom: selectedImages.isEmpty ? 90 : 160,
),
reverse: true,
itemBuilder: (context, index) {
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,
),
),
],
),
SizedBox(
height: 60,
width: 70,
child: Align(
alignment: Alignment.topRight,
child: GestureDetector(
onTap: () {
setState(() {
selectedImages.removeAt(i);
});
},
child: Container(
height: 20,
width: 20,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onPrimary,
borderRadius: BorderRadius.circular(30),
),
child: Icon(
Icons.cancel,
color: Theme.of(context).primaryColor,
size: 20
),
),
),
),
),
],
);
},
)
);
}
Widget newMessageContent() {
return Align(
alignment: Alignment.bottomLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 200.0,
constraints: BoxConstraints(
maxHeight: selectedImages.isEmpty ?
200.0 :
270.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(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
showSelectedImages(),
Row(
children: <Widget>[
GestureDetector(
onTap: (){
setState(() {
showFilePicker = !showFilePicker;
});
},
child: Container(
height: 30,
@ -91,7 +219,9 @@ class _ConversationDetailState extends State<ConversationDetail> {
),
),
),
const SizedBox(width: 15,),
Expanded(
child: TextField(
decoration: InputDecoration(
@ -105,20 +235,28 @@ class _ConversationDetailState extends State<ConversationDetail> {
controller: msgController,
),
),
const SizedBox(width: 15),
Container(
SizedBox(
width: 45,
height: 45,
child: FittedBox(
child: FloatingActionButton(
onPressed: () async {
if (msgController.text == '') {
if (msgController.text == '' && selectedImages.isEmpty) {
return;
}
await sendMessage(widget.conversation, msgController.text);
await sendMessage(
widget.conversation,
data: msgController.text != '' ? msgController.text : null,
files: selectedImages,
);
messages = await getMessagesForThread(widget.conversation);
setState(() {});
setState(() {
msgController.text = '';
selectedImages = [];
});
},
child: Icon(
Icons.send,
@ -132,141 +270,25 @@ class _ConversationDetailState extends State<ConversationDetail> {
const SizedBox(width: 10),
],
),
),
),
),
],
),
);
}
Future<void> fetchMessages() async {
profile = await MyProfile.getProfile();
messages = await getMessagesForThread(widget.conversation);
setState(() {});
}
@override
void initState() {
super.initState();
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 <Widget>[
Icon(
Icons.warning_rounded,
color: Colors.red,
size: 20,
),
Text(
'Failed to send',
style: TextStyle(color: Colors.red, fontSize: 12),
textAlign: TextAlign.right,
),
],
);
}
return const SizedBox.shrink();
}
Widget messagesView() {
if (messages.isEmpty) {
return const Center(
child: Text('No Messages'),
);
showFilePicker ?
FilePicker(
cameraHandle: (XFile image) {},
galleryHandleMultiple: (List<XFile> images) async {
for (var img in images) {
selectedImages.add(File(img.path));
}
return ListView.builder(
itemCount: messages.length,
shrinkWrap: true,
padding: const EdgeInsets.only(top: 10,bottom: 90),
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: <Widget>[
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: (
messages[index].senderUsername == profile.username ?
Theme.of(context).colorScheme.primary :
Theme.of(context).colorScheme.tertiary
),
),
padding: const EdgeInsets.all(12),
child: Text(
messages[index].data,
style: TextStyle(
fontSize: 15,
color: messages[index].senderUsername == profile.username ?
Theme.of(context).colorScheme.onPrimary :
Theme.of(context).colorScheme.onTertiary,
)
),
),
const SizedBox(height: 1.5),
Row(
mainAxisAlignment: messages[index].senderUsername == profile.username ?
MainAxisAlignment.end :
MainAxisAlignment.start,
children: <Widget>[
const SizedBox(width: 10),
usernameOrFailedToSend(index),
setState(() {
showFilePicker = false;
});
},
fileHandle: () {},
) :
const SizedBox.shrink(),
],
),
const SizedBox(height: 1.5),
Row(
mainAxisAlignment: messages[index].senderUsername == profile.username ?
MainAxisAlignment.end :
MainAxisAlignment.start,
children: <Widget>[
const SizedBox(width: 10),
Text(
convertToAgo(messages[index].createdAt),
textAlign: messages[index].senderUsername == profile.username ?
TextAlign.left :
TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
index != 0 ?
const SizedBox(height: 20) :
const SizedBox.shrink(),
],
)
),
);
},
);
}
}

+ 49
- 7
mobile/lib/views/main/conversation/edit_details.dart View File

@ -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<ConversationEditDetails> {
List<Conversation> 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();
}
@ -76,7 +84,7 @@ class _ConversationEditDetails extends State<ConversationEditDetails> {
children: <Widget>[
Text(
widget.conversation != null ?
widget.conversation!.name + " Settings" :
widget.conversation!.name + ' Settings' :
'Add Conversation',
style: const TextStyle(
fontSize: 16,
@ -91,6 +99,7 @@ class _ConversationEditDetails extends State<ConversationEditDetails> {
),
),
),
body: Center(
child: Padding(
padding: const EdgeInsets.only(
@ -102,12 +111,40 @@ class _ConversationEditDetails extends State<ConversationEditDetails> {
key: _formKey,
child: Column(
children: [
const CustomCircleAvatar(
CustomCircleAvatar(
icon: const Icon(Icons.people, size: 60),
imagePath: null,
image: conversationIcon,
radius: 50,
editImageCallback: () {
setState(() {
showFileSelector = true;
});
},
),
const SizedBox(height: 30),
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,
@ -125,19 +162,24 @@ class _ConversationEditDetails extends State<ConversationEditDetails> {
return null;
},
),
const SizedBox(height: 30),
ElevatedButton(
style: buttonStyle,
onPressed: () {
if (!_formKey.currentState!.validate()) {
// TODO: Show error here
return;
}
widget.saveCallback(conversationNameController.text);
widget.saveCallback(
conversationNameController.text,
conversationIcon,
);
},
child: const Text('Save'),
),
],
),
),


+ 3
- 1
mobile/lib/views/main/conversation/list.dart View File

@ -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';
@ -66,7 +68,7 @@ class _ConversationListState extends State<ConversationList> {
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ConversationEditDetails(
saveCallback: (String conversationName) {
saveCallback: (String conversationName, File? file) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ConversationAddFriendsList(
friends: friends,


+ 3
- 4
mobile/lib/views/main/conversation/list_item.dart View File

@ -43,7 +43,7 @@ class _ConversationListItemState extends State<ConversationListItem> {
children: <Widget>[
CustomCircleAvatar(
initials: widget.conversation.name[0].toUpperCase(),
imagePath: null,
image: widget.conversation.icon,
),
const SizedBox(width: 16),
Expanded(
@ -60,11 +60,10 @@ class _ConversationListItemState extends State<ConversationListItem> {
),
recentMessage != null ?
const SizedBox(height: 2) :
const SizedBox.shrink()
,
const SizedBox.shrink(),
recentMessage != null ?
Text(
recentMessage!.data,
recentMessage!.getContent(),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(


+ 241
- 0
mobile/lib/views/main/conversation/message.dart View File

@ -0,0 +1,241 @@
import 'package:Envelope/components/view_image.dart';
import 'package:Envelope/models/image_message.dart';
import 'package:Envelope/models/my_profile.dart';
import 'package:Envelope/utils/time.dart';
import 'package:flutter/material.dart';
import '/models/messages.dart';
@immutable
class ConversationMessage extends StatefulWidget {
const ConversationMessage({
Key? key,
required this.message,
required this.profile,
required this.index,
}) : super(key: key);
final Message message;
final MyProfile profile;
final int index;
@override
_ConversationMessageState createState() => _ConversationMessageState();
}
class _ConversationMessageState extends State<ConversationMessage> {
List<PopupMenuEntry<String>> menuItems = [];
Offset? _tapPosition;
bool showDownloadButton = false;
bool showDeleteButton = false;
@override
void initState() {
super.initState();
showDownloadButton = widget.message.runtimeType == ImageMessage;
showDeleteButton = widget.message.senderId == widget.profile.id;
if (showDownloadButton) {
menuItems.add(PopupMenuItem(
value: 'download',
child: Row(
children: const [
Icon(Icons.download),
SizedBox(
width: 10,
),
Text('Download')
],
),
));
}
if (showDeleteButton) {
menuItems.add(PopupMenuItem(
value: 'delete',
child: Row(
children: const [
Icon(Icons.delete),
SizedBox(
width: 10,
),
Text('Delete')
],
),
));
}
setState(() {});
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(left: 14,right: 14,top: 0,bottom: 0),
child: Align(
alignment: (
widget.message.senderId == widget.profile.id ?
Alignment.topRight :
Alignment.topLeft
),
child: Column(
crossAxisAlignment: widget.message.senderId == widget.profile.id ?
CrossAxisAlignment.end :
CrossAxisAlignment.start,
children: <Widget>[
messageContent(context),
const SizedBox(height: 1.5),
Row(
mainAxisAlignment: widget.message.senderId == widget.profile.id ?
MainAxisAlignment.end :
MainAxisAlignment.start,
children: <Widget>[
const SizedBox(width: 10),
usernameOrFailedToSend(),
],
),
const SizedBox(height: 1.5),
Row(
mainAxisAlignment: widget.message.senderId == widget.profile.id ?
MainAxisAlignment.end :
MainAxisAlignment.start,
children: <Widget>[
const SizedBox(width: 10),
Text(
convertToAgo(widget.message.createdAt),
textAlign: widget.message.senderId == widget.profile.id ?
TextAlign.left :
TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
widget.index != 0 ?
const SizedBox(height: 20) :
const SizedBox.shrink(),
],
)
),
);
}
void _showCustomMenu() {
final Size overlay = MediaQuery.of(context).size;
int addVerticalOffset = 75 * menuItems.length;
// TODO: Implement download & delete methods
showMenu(
context: context,
items: menuItems,
position: RelativeRect.fromRect(
Offset(_tapPosition!.dx, (_tapPosition!.dy - addVerticalOffset)) & const Size(40, 40),
Offset.zero & overlay
)
)
.then<void>((String? delta) async {
if (delta == null) {
return;
}
});
}
void _storePosition(TapDownDetails details) {
_tapPosition = details.globalPosition;
}
Widget messageContent(BuildContext context) {
if (widget.message.runtimeType == ImageMessage) {
return GestureDetector(
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) {
return ViewImage(
message: (widget.message as ImageMessage)
);
}));
},
onLongPress: _showCustomMenu,
onTapDown: _storePosition,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 350, maxWidth: 250),
child: ClipRRect(
borderRadius: BorderRadius.circular(20), child: Image.file(
(widget.message as ImageMessage).file,
fit: BoxFit.fill,
),
),
),
);
}
return GestureDetector(
onLongPress: _showCustomMenu,
onTapDown: _storePosition,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: (
widget.message.senderId == widget.profile.id ?
Theme.of(context).colorScheme.primary :
Theme.of(context).colorScheme.tertiary
),
),
padding: const EdgeInsets.all(12),
child: Text(
widget.message.getContent(),
style: TextStyle(
fontSize: 15,
color: widget.message.senderId == widget.profile.id ?
Theme.of(context).colorScheme.onPrimary :
Theme.of(context).colorScheme.onTertiary,
),
),
),
);
}
Widget usernameOrFailedToSend() {
if (widget.message.senderId != widget.profile.id) {
return Text(
widget.message.senderUsername,
style: TextStyle(
fontSize: 12,
color: Colors.grey[300],
),
);
}
if (widget.message.failedToSend) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: const <Widget>[
Icon(
Icons.warning_rounded,
color: Colors.red,
size: 20,
),
Text(
'Failed to send',
style: TextStyle(color: Colors.red, fontSize: 12),
textAlign: TextAlign.right,
),
],
);
}
return const SizedBox.shrink();
}
}

+ 39
- 8
mobile/lib/views/main/conversation/settings.dart View File

@ -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<ConversationSettings> {
Widget conversationName() {
return Row(
children: <Widget> [
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<ConversationSettings> {
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<ConversationSettings> {
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<ConversationSettings> {
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<ConversationSettings> {
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: () {


+ 0
- 1
mobile/lib/views/main/conversation/settings_user_list_item.dart View File

@ -104,7 +104,6 @@ class _ConversationSettingsUserListItemState extends State<ConversationSettingsU
children: <Widget>[
CustomCircleAvatar(
initials: widget.user.username[0].toUpperCase(),
imagePath: null,
radius: 15,
),
const SizedBox(width: 16),


+ 0
- 1
mobile/lib/views/main/friend/list_item.dart View File

@ -33,7 +33,6 @@ class _FriendListItemState extends State<FriendListItem> {
children: <Widget>[
CustomCircleAvatar(
initials: widget.friend.username[0].toUpperCase(),
imagePath: null,
),
const SizedBox(width: 16),
Expanded(


+ 0
- 1
mobile/lib/views/main/friend/request_list_item.dart View File

@ -46,7 +46,6 @@ class _FriendRequestListItemState extends State<FriendRequestListItem> {
children: <Widget>[
CustomCircleAvatar(
initials: widget.friend.username[0].toUpperCase(),
imagePath: null,
),
const SizedBox(width: 16),
Expanded(


+ 85
- 4
mobile/lib/views/main/profile/profile.dart View File

@ -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<Profile> {
final PanelController _panelController = PanelController();
bool showFileSelector = false;
@override
Widget build(BuildContext context) {
return Scaffold(
@ -63,7 +73,8 @@ class _ProfileState extends State<Profile> {
child: Column(
children: <Widget>[
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<Profile> {
Widget usernameHeading() {
return Row(
children: <Widget> [
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<Profile> {
),
),
),
IconButton(
onPressed: () => _panelController.open(),
icon: const Icon(Icons.qr_code_2),
@ -101,6 +121,59 @@ class _ProfileState extends State<Profile> {
);
}
Widget fileSelector() {
if (!showFileSelector) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 10),
child: FilePicker(
cameraHandle: _setProfileImage,
galleryHandleSingle: _setProfileImage,
)
);
}
Future<void> _setProfileImage(XFile image) async {
widget.profile.image = await writeImage(
widget.profile.id,
File(image.path).readAsBytesSync(),
);
setState(() {
showFileSelector = false;
});
saveProfile();
Map<String, dynamic> 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<Profile> {
context,
);
});
saveProfile();
},
))
);
@ -242,6 +317,7 @@ class _ProfileState extends State<Profile> {
privateKey: widget.profile.privateKey!,
))
);
saveProfile();
}
),
],
@ -282,4 +358,9 @@ class _ProfileState extends State<Profile> {
]
);
}
Future<void> saveProfile() async {
final preferences = await SharedPreferences.getInstance();
preferences.setString('profile', widget.profile.toJson());
}
}

+ 85
- 1
mobile/pubspec.lock View File

@ -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"

+ 3
- 0
mobile/pubspec.yaml View File

@ -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:


Loading…
Cancel
Save