Browse Source

Finish sending messages

pull/1/head
Tovi Jaeschke-Rogers 2 years ago
parent
commit
a6f54d5ef8
26 changed files with 907 additions and 433 deletions
  1. +9
    -7
      Backend/Api/Auth/Login.go
  2. +23
    -5
      Backend/Api/Messages/CreateMessage.go
  3. +1
    -3
      Backend/Api/Routes.go
  4. +39
    -0
      Backend/Database/MessageData.go
  5. +15
    -3
      Backend/Database/Messages.go
  6. +90
    -53
      Backend/Database/Seeder/MessageSeeder.go
  7. +1
    -1
      Backend/Database/Seeder/UserSeeder.go
  8. +4
    -0
      Backend/Models/Base.go
  9. +10
    -11
      Backend/Models/Messages.go
  10. +104
    -0
      mobile/lib/models/conversation_users.dart
  11. +0
    -14
      mobile/lib/models/conversations.dart
  12. +33
    -28
      mobile/lib/models/friends.dart
  13. +97
    -20
      mobile/lib/models/messages.dart
  14. +21
    -5
      mobile/lib/utils/storage/conversations.dart
  15. +16
    -2
      mobile/lib/utils/storage/database.dart
  16. +2
    -0
      mobile/lib/utils/storage/friends.dart
  17. +91
    -55
      mobile/lib/utils/storage/messages.dart
  18. +8
    -0
      mobile/lib/utils/strings.dart
  19. +5
    -4
      mobile/lib/views/authentication/login.dart
  20. +2
    -8
      mobile/lib/views/authentication/signup.dart
  21. +32
    -21
      mobile/lib/views/main/conversation_detail.dart
  22. +116
    -93
      mobile/lib/views/main/conversation_list.dart
  23. +116
    -91
      mobile/lib/views/main/friend_list.dart
  24. +49
    -9
      mobile/lib/views/main/home.dart
  25. +21
    -0
      mobile/pubspec.lock
  26. +2
    -0
      mobile/pubspec.yaml

+ 9
- 7
Backend/Api/Auth/Login.go View File

@ -20,9 +20,10 @@ type loginResponse struct {
AsymmetricPublicKey string `json:"asymmetric_public_key"` AsymmetricPublicKey string `json:"asymmetric_public_key"`
AsymmetricPrivateKey string `json:"asymmetric_private_key"` AsymmetricPrivateKey string `json:"asymmetric_private_key"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Username string `json:"username"`
} }
func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, userId string) {
func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) {
var ( var (
status string = "error" status string = "error"
returnJson []byte returnJson []byte
@ -37,7 +38,8 @@ func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey
Message: message, Message: message,
AsymmetricPublicKey: pubKey, AsymmetricPublicKey: pubKey,
AsymmetricPrivateKey: privKey, AsymmetricPrivateKey: privKey,
UserID: userId,
UserID: user.ID.String(),
Username: user.Username,
}, "", " ") }, "", " ")
if err != nil { if err != nil {
http.Error(w, "Error", http.StatusInternalServerError) http.Error(w, "Error", http.StatusInternalServerError)
@ -61,18 +63,18 @@ func Login(w http.ResponseWriter, r *http.Request) {
err = json.NewDecoder(r.Body).Decode(&creds) err = json.NewDecoder(r.Body).Decode(&creds)
if err != nil { if err != nil {
makeLoginResponse(w, http.StatusInternalServerError, "An error occurred", "", "", "")
makeLoginResponse(w, http.StatusInternalServerError, "An error occurred", "", "", userData)
return return
} }
userData, err = Database.GetUserByUsername(creds.Username) userData, err = Database.GetUserByUsername(creds.Username)
if err != nil { if err != nil {
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", "")
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData)
return return
} }
if !CheckPasswordHash(creds.Password, userData.Password) { if !CheckPasswordHash(creds.Password, userData.Password) {
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", "")
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData)
return return
} }
@ -86,7 +88,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
err = Database.CreateSession(&session) err = Database.CreateSession(&session)
if err != nil { if err != nil {
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", "")
makeLoginResponse(w, http.StatusUnauthorized, "An error occurred", "", "", userData)
return return
} }
@ -102,6 +104,6 @@ func Login(w http.ResponseWriter, r *http.Request) {
"Successfully logged in", "Successfully logged in",
userData.AsymmetricPublicKey, userData.AsymmetricPublicKey,
userData.AsymmetricPrivateKey, userData.AsymmetricPrivateKey,
userData.ID.String(),
userData,
) )
} }

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

@ -2,22 +2,40 @@ package Messages
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
) )
type RawMessageData struct {
MessageData Models.MessageData `json:"message_data"`
Messages []Models.Message `json:"message"`
}
func CreateMessage(w http.ResponseWriter, r *http.Request) { func CreateMessage(w http.ResponseWriter, r *http.Request) {
var ( var (
message Models.Message
err error
rawMessageData RawMessageData
err error
) )
err = json.NewDecoder(r.Body).Decode(&message)
err = json.NewDecoder(r.Body).Decode(&rawMessageData)
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 { if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return return
} }
fmt.Println(message)
w.WriteHeader(http.StatusOK)
} }

+ 1
- 3
Backend/Api/Routes.go View File

@ -68,9 +68,7 @@ func InitApiEndpoints(router *mux.Router) {
authApi.HandleFunc("/conversations", Messages.EncryptedConversationList).Methods("GET") authApi.HandleFunc("/conversations", Messages.EncryptedConversationList).Methods("GET")
authApi.HandleFunc("/conversation_details", Messages.EncryptedConversationDetailsList).Methods("GET") authApi.HandleFunc("/conversation_details", Messages.EncryptedConversationDetailsList).Methods("GET")
// authApi.HandleFunc("/user/{userID}", Friends.Friend).Methods("GET")
// authApi.HandleFunc("/user/{userID}/request", Friends.FriendRequest).Methods("POST")
// Define routes for messages // Define routes for messages
authApi.HandleFunc("/message", Messages.CreateMessage).Methods("POST")
authApi.HandleFunc("/messages/{threadKey}", Messages.Messages).Methods("GET") authApi.HandleFunc("/messages/{threadKey}", Messages.Messages).Methods("GET")
} }

+ 39
- 0
Backend/Database/MessageData.go View File

@ -0,0 +1,39 @@
package Database
import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func GetMessageDataById(id string) (Models.MessageData, error) {
var (
messageData Models.MessageData
err error
)
err = DB.Preload(clause.Associations).
First(&messageData, "id = ?", id).
Error
return messageData, err
}
func CreateMessageData(messageData *Models.MessageData) error {
var (
err error
)
err = DB.Session(&gorm.Session{FullSaveAssociations: true}).
Create(messageData).
Error
return err
}
func DeleteMessageData(messageData *Models.MessageData) error {
return DB.Session(&gorm.Session{FullSaveAssociations: true}).
Delete(messageData).
Error
}

+ 15
- 3
Backend/Database/Messages.go View File

@ -20,14 +20,14 @@ func GetMessageById(id string) (Models.Message, error) {
return message, err return message, err
} }
func GetMessagesByThreadKey(threadKey string) ([]Models.Message, error) {
func GetMessagesByThreadKey(associationKey string) ([]Models.Message, error) {
var ( var (
messages []Models.Message messages []Models.Message
err error err error
) )
err = DB.Preload(clause.Associations).
Find(&messages, "message_thread_key = ?", threadKey).
err = DB.Preload("MessageData").
Find(&messages, "association_key = ?", associationKey).
Error Error
return messages, err return messages, err
@ -45,6 +45,18 @@ func CreateMessage(message *Models.Message) error {
return err return err
} }
func CreateMessages(messages *[]Models.Message) error {
var (
err error
)
err = DB.Session(&gorm.Session{FullSaveAssociations: true}).
Create(messages).
Error
return err
}
func DeleteMessage(message *Models.Message) error { func DeleteMessage(message *Models.Message) error {
return DB.Session(&gorm.Session{FullSaveAssociations: true}). return DB.Session(&gorm.Session{FullSaveAssociations: true}).
Delete(message). Delete(message).


+ 90
- 53
Backend/Database/Seeder/MessageSeeder.go View File

@ -7,60 +7,93 @@ import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
) )
func seedMessage( func seedMessage(
primaryUser, secondaryUser Models.User, primaryUser, secondaryUser Models.User,
primaryUserThreadKey, secondaryUserThreadKey string,
thread Models.ConversationDetail,
primaryUserAssociationKey, secondaryUserAssociationKey string,
i int, i int,
) error { ) error {
var ( var (
message Models.Message message Models.Message
messageData Models.MessageData messageData Models.MessageData
key aesKey
key, userKey aesKey
keyCiphertext []byte
plaintext string plaintext string
dataCiphertext []byte dataCiphertext []byte
senderIdCiphertext []byte senderIdCiphertext []byte
friendId []byte
err error err error
) )
key, err = generateAesKey()
plaintext = "Test Message"
userKey, err = generateAesKey()
if err != nil { if err != nil {
panic(err) panic(err)
} }
plaintext = "Test Message"
key, err = generateAesKey()
if err != nil {
panic(err)
}
dataCiphertext, err = key.aesEncrypt([]byte(plaintext)) dataCiphertext, err = key.aesEncrypt([]byte(plaintext))
if err != nil { if err != nil {
panic(err) panic(err)
} }
senderIdCiphertext, err = key.aesEncrypt([]byte(primaryUser.ID.String()))
friendId, err = base64.StdEncoding.DecodeString(primaryUser.FriendID)
if err != nil {
panic(err)
}
friendId, err = decryptWithPrivateKey(friendId, decodedPrivateKey)
if err != nil {
panic(err)
}
senderIdCiphertext, err = key.aesEncrypt(friendId)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if i%2 == 0 { if i%2 == 0 {
senderIdCiphertext, err = key.aesEncrypt([]byte(secondaryUser.ID.String()))
friendId, err = base64.StdEncoding.DecodeString(secondaryUser.FriendID)
if err != nil {
panic(err)
}
friendId, err = decryptWithPrivateKey(friendId, decodedPrivateKey)
if err != nil {
panic(err)
}
senderIdCiphertext, err = key.aesEncrypt(friendId)
if err != nil { if err != nil {
panic(err) panic(err)
} }
} }
keyCiphertext, err = userKey.aesEncrypt(
[]byte(base64.StdEncoding.EncodeToString(key.Key)),
)
if err != nil {
panic(err)
}
messageData = Models.MessageData{ messageData = Models.MessageData{
Data: base64.StdEncoding.EncodeToString(dataCiphertext),
SenderID: base64.StdEncoding.EncodeToString(senderIdCiphertext),
Data: base64.StdEncoding.EncodeToString(dataCiphertext),
SenderID: base64.StdEncoding.EncodeToString(senderIdCiphertext),
SymmetricKey: base64.StdEncoding.EncodeToString(keyCiphertext),
} }
message = Models.Message{ message = Models.Message{
MessageData: messageData, MessageData: messageData,
SymmetricKey: base64.StdEncoding.EncodeToString( SymmetricKey: base64.StdEncoding.EncodeToString(
encryptWithPublicKey(key.Key, decodedPublicKey),
encryptWithPublicKey(userKey.Key, decodedPublicKey),
), ),
MessageThreadKey: primaryUserThreadKey,
AssociationKey: primaryUserAssociationKey,
} }
err = Database.CreateMessage(&message) err = Database.CreateMessage(&message)
@ -68,22 +101,15 @@ func seedMessage(
return err return err
} }
// The symmetric key would be encrypted with secondary users public key in production
// But due to using the same pub/priv key pair for all users, we will just duplicate it
message = Models.Message{ message = Models.Message{
MessageDataID: message.MessageDataID,
MessageData: messageData,
SymmetricKey: base64.StdEncoding.EncodeToString( SymmetricKey: base64.StdEncoding.EncodeToString(
encryptWithPublicKey(key.Key, decodedPublicKey),
encryptWithPublicKey(userKey.Key, decodedPublicKey),
), ),
MessageThreadKey: secondaryUserThreadKey,
}
err = Database.CreateMessage(&message)
if err != nil {
return err
AssociationKey: secondaryUserAssociationKey,
} }
return err
return Database.CreateMessage(&message)
} }
func seedConversationDetail(key aesKey) (Models.ConversationDetail, error) { func seedConversationDetail(key aesKey) (Models.ConversationDetail, error) {
@ -132,13 +158,11 @@ func seedUpdateUserConversation(
func seedUserConversation( func seedUserConversation(
user Models.User, user Models.User,
threadID uuid.UUID, threadID uuid.UUID,
messageThreadKey string,
key aesKey, key aesKey,
) (Models.UserConversation, error) { ) (Models.UserConversation, error) {
var ( var (
messageThreadUser Models.UserConversation messageThreadUser Models.UserConversation
threadIdCiphertext []byte threadIdCiphertext []byte
keyCiphertext []byte
adminCiphertext []byte adminCiphertext []byte
err error err error
) )
@ -148,11 +172,6 @@ func seedUserConversation(
return messageThreadUser, err return messageThreadUser, err
} }
keyCiphertext, err = key.aesEncrypt([]byte(messageThreadKey))
if err != nil {
return messageThreadUser, err
}
adminCiphertext, err = key.aesEncrypt([]byte("true")) adminCiphertext, err = key.aesEncrypt([]byte("true"))
if err != nil { if err != nil {
return messageThreadUser, err return messageThreadUser, err
@ -161,7 +180,6 @@ func seedUserConversation(
messageThreadUser = Models.UserConversation{ messageThreadUser = Models.UserConversation{
UserID: user.ID, UserID: user.ID,
ConversationDetailID: base64.StdEncoding.EncodeToString(threadIdCiphertext), ConversationDetailID: base64.StdEncoding.EncodeToString(threadIdCiphertext),
MessageThreadKey: base64.StdEncoding.EncodeToString(keyCiphertext),
Admin: base64.StdEncoding.EncodeToString(adminCiphertext), Admin: base64.StdEncoding.EncodeToString(adminCiphertext),
SymmetricKey: base64.StdEncoding.EncodeToString( SymmetricKey: base64.StdEncoding.EncodeToString(
encryptWithPublicKey(key.Key, decodedPublicKey), encryptWithPublicKey(key.Key, decodedPublicKey),
@ -174,24 +192,24 @@ func seedUserConversation(
func SeedMessages() { func SeedMessages() {
var ( var (
messageThread Models.ConversationDetail
key aesKey
primaryUser Models.User
primaryUserThreadKey string
secondaryUser Models.User
secondaryUserThreadKey string
userJson string
thread Models.ConversationDetail
i int
err error
messageThread Models.ConversationDetail
key aesKey
primaryUser Models.User
primaryUserAssociationKey string
secondaryUser Models.User
secondaryUserAssociationKey string
primaryUserFriendId []byte
secondaryUserFriendId []byte
userJson string
i int
err error
) )
key, err = generateAesKey() key, err = generateAesKey()
messageThread, err = seedConversationDetail(key) messageThread, err = seedConversationDetail(key)
primaryUserThreadKey = Util.RandomString(32)
secondaryUserThreadKey = Util.RandomString(32)
primaryUserAssociationKey = Util.RandomString(32)
secondaryUserAssociationKey = Util.RandomString(32)
primaryUser, err = Database.GetUserByUsername("testUser") primaryUser, err = Database.GetUserByUsername("testUser")
if err != nil { if err != nil {
@ -201,7 +219,6 @@ func SeedMessages() {
_, err = seedUserConversation( _, err = seedUserConversation(
primaryUser, primaryUser,
messageThread.ID, messageThread.ID,
primaryUserThreadKey,
key, key,
) )
@ -213,29 +230,50 @@ func SeedMessages() {
_, err = seedUserConversation( _, err = seedUserConversation(
secondaryUser, secondaryUser,
messageThread.ID, messageThread.ID,
secondaryUserThreadKey,
key, key,
) )
primaryUserFriendId, err = base64.StdEncoding.DecodeString(primaryUser.FriendID)
if err != nil {
panic(err)
}
primaryUserFriendId, err = decryptWithPrivateKey(primaryUserFriendId, decodedPrivateKey)
if err != nil {
panic(err)
}
secondaryUserFriendId, err = base64.StdEncoding.DecodeString(secondaryUser.FriendID)
if err != nil {
panic(err)
}
secondaryUserFriendId, err = decryptWithPrivateKey(secondaryUserFriendId, decodedPrivateKey)
if err != nil {
panic(err)
}
userJson = fmt.Sprintf( userJson = fmt.Sprintf(
` `
[ [
{ {
"id": "%s", "id": "%s",
"username": "%s", "username": "%s",
"admin": "true"
"admin": "true",
"association_key": "%s"
}, },
{ {
"id": "%s", "id": "%s",
"username": "%s", "username": "%s",
"admin": "true"
"admin": "true",
"association_key": "%s"
} }
] ]
`, `,
primaryUser.ID.String(),
string(primaryUserFriendId),
primaryUser.Username, primaryUser.Username,
secondaryUser.ID.String(),
primaryUserAssociationKey,
string(secondaryUserFriendId),
secondaryUser.Username, secondaryUser.Username,
secondaryUserAssociationKey,
) )
messageThread, err = seedUpdateUserConversation( messageThread, err = seedUpdateUserConversation(
@ -248,9 +286,8 @@ func SeedMessages() {
err = seedMessage( err = seedMessage(
primaryUser, primaryUser,
secondaryUser, secondaryUser,
primaryUserThreadKey,
secondaryUserThreadKey,
thread,
primaryUserAssociationKey,
secondaryUserAssociationKey,
i, i,
) )
if err != nil { if err != nil {


+ 1
- 1
Backend/Database/Seeder/UserSeeder.go View File

@ -49,7 +49,7 @@ func createUser(username string) (Models.User, error) {
publicUserData = Models.Friend{ publicUserData = Models.Friend{
Username: base64.StdEncoding.EncodeToString(usernameCiphertext), Username: base64.StdEncoding.EncodeToString(usernameCiphertext),
AsymmetricPublicKey: base64.StdEncoding.EncodeToString([]byte(publicKey)),
AsymmetricPublicKey: publicKey,
} }
err = Database.CreateFriend(&publicUserData) err = Database.CreateFriend(&publicUserData)


+ 4
- 0
Backend/Models/Base.go View File

@ -17,6 +17,10 @@ func (base *Base) BeforeCreate(tx *gorm.DB) error {
err error err error
) )
if !base.ID.IsNil() {
return nil
}
id, err = uuid.NewV4() id, err = uuid.NewV4()
if err != nil { if err != nil {
return err return err


+ 10
- 11
Backend/Models/Messages.go View File

@ -9,24 +9,24 @@ import (
// TODO: Add support for images // TODO: Add support for images
type MessageData struct { type MessageData struct {
Base Base
Data string `gorm:"not null" json:"data"` // Stored encrypted
SenderID string `gorm:"not null" json:"sender_id"`
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
} }
type Message struct { type Message struct {
Base Base
MessageDataID uuid.UUID `json:"-"`
MessageData MessageData `json:"message_data"`
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
MessageThreadKey string `gorm:"not null" json:"message_thread_key"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
MessageDataID uuid.UUID `json:"message_data_id"`
MessageData MessageData `json:"message_data"`
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
AssociationKey string `gorm:"not null" json:"association_key"` // TODO: This links all encrypted messages for a user in a thread together. Find a way to fix this
CreatedAt time.Time `gorm:"not null" json:"created_at"`
} }
// TODO: Rename to ConversationDetails
type ConversationDetail struct { type ConversationDetail struct {
Base Base
Name string `gorm:"not null" json:"name"`
Users string `json:"users"` // Stored as encrypted JSON
Name string `gorm:"not null" json:"name"` // Stored encrypted
Users string `json:"users"` // Stored as encrypted JSON
} }
type UserConversation struct { type UserConversation struct {
@ -34,7 +34,6 @@ type UserConversation struct {
UserID uuid.UUID `gorm:"type:uuid;column:user_id;not null;" json:"user_id"` UserID uuid.UUID `gorm:"type:uuid;column:user_id;not null;" json:"user_id"`
User User `json:"user"` User User `json:"user"`
ConversationDetailID string `gorm:"not null" json:"conversation_detail_id"` // Stored encrypted ConversationDetailID string `gorm:"not null" json:"conversation_detail_id"` // Stored encrypted
MessageThreadKey string `gorm:"not null" json:"message_thread_key"` // Stored encrypted
Admin string `gorm:"not null" json:"admin"` // Bool if user is admin of thread, stored encrypted Admin string `gorm:"not null" json:"admin"` // Bool if user is admin of thread, stored encrypted
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
} }

+ 104
- 0
mobile/lib/models/conversation_users.dart View File

@ -0,0 +1,104 @@
import '/utils/storage/database.dart';
import '/models/conversations.dart';
class ConversationUser{
String id;
String conversationId;
String username;
String associationKey;
String admin;
ConversationUser({
required this.id,
required this.conversationId,
required this.username,
required this.associationKey,
required this.admin,
});
factory ConversationUser.fromJson(Map<String, dynamic> json, String conversationId) {
return ConversationUser(
id: json['id'],
conversationId: conversationId,
username: json['username'],
associationKey: json['association_key'],
admin: json['admin'],
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'conversation_id': conversationId,
'username': username,
'association_key': associationKey,
'admin': admin,
};
}
}
// A method that retrieves all the dogs from the dogs table.
Future<List<ConversationUser>> getConversationUsers(Conversation conversation) async {
final db = await getDatabaseConnection();
final List<Map<String, dynamic>> maps = await db.query(
'conversation_users',
where: 'conversation_id = ?',
whereArgs: [conversation.id],
);
return List.generate(maps.length, (i) {
return ConversationUser(
id: maps[i]['id'],
conversationId: maps[i]['conversation_id'],
username: maps[i]['username'],
associationKey: maps[i]['association_key'],
admin: maps[i]['admin'],
);
});
}
Future<ConversationUser> getConversationUserById(Conversation conversation, String id) async {
final db = await getDatabaseConnection();
final List<Map<String, dynamic>> maps = await db.query(
'conversation_users',
where: 'conversation_id = ? AND id = ?',
whereArgs: [conversation.id, id],
);
if (maps.length != 1) {
throw ArgumentError('Invalid conversation_id or id');
}
return ConversationUser(
id: maps[0]['id'],
conversationId: maps[0]['conversation_id'],
username: maps[0]['username'],
associationKey: maps[0]['association_key'],
admin: maps[0]['admin'],
);
}
Future<ConversationUser> getConversationUserByUsername(Conversation conversation, String username) async {
final db = await getDatabaseConnection();
final List<Map<String, dynamic>> maps = await db.query(
'conversation_users',
where: 'conversation_id = ? AND username = ?',
whereArgs: [conversation.id, username],
);
if (maps.length != 1) {
throw ArgumentError('Invalid conversation_id or username');
}
return ConversationUser(
id: maps[0]['id'],
conversationId: maps[0]['conversation_id'],
username: maps[0]['username'],
associationKey: maps[0]['association_key'],
admin: maps[0]['admin'],
);
}

+ 0
- 14
mobile/lib/models/conversations.dart View File

@ -18,21 +18,17 @@ class Conversation {
String id; String id;
String userId; String userId;
String conversationDetailId; String conversationDetailId;
String messageThreadKey;
String symmetricKey; String symmetricKey;
bool admin; bool admin;
String name; String name;
String? users;
Conversation({ Conversation({
required this.id, required this.id,
required this.userId, required this.userId,
required this.conversationDetailId, required this.conversationDetailId,
required this.messageThreadKey,
required this.symmetricKey, required this.symmetricKey,
required this.admin, required this.admin,
required this.name, required this.name,
this.users,
}); });
@ -47,11 +43,6 @@ class Conversation {
base64.decode(json['conversation_detail_id']), base64.decode(json['conversation_detail_id']),
); );
var threadKey = AesHelper.aesDecrypt(
symmetricKeyDecrypted,
base64.decode(json['message_thread_key']),
);
var admin = AesHelper.aesDecrypt( var admin = AesHelper.aesDecrypt(
symmetricKeyDecrypted, symmetricKeyDecrypted,
base64.decode(json['admin']), base64.decode(json['admin']),
@ -61,7 +52,6 @@ class Conversation {
id: json['id'], id: json['id'],
userId: json['user_id'], userId: json['user_id'],
conversationDetailId: detailId, conversationDetailId: detailId,
messageThreadKey: threadKey,
symmetricKey: base64.encode(symmetricKeyDecrypted), symmetricKey: base64.encode(symmetricKeyDecrypted),
admin: admin == 'true', admin: admin == 'true',
name: 'Unknown', name: 'Unknown',
@ -84,11 +74,9 @@ admin: $admin''';
'id': id, 'id': id,
'user_id': userId, 'user_id': userId,
'conversation_detail_id': conversationDetailId, 'conversation_detail_id': conversationDetailId,
'message_thread_key': messageThreadKey,
'symmetric_key': symmetricKey, 'symmetric_key': symmetricKey,
'admin': admin ? 1 : 0, 'admin': admin ? 1 : 0,
'name': name, 'name': name,
'users': users,
}; };
} }
} }
@ -105,11 +93,9 @@ Future<List<Conversation>> getConversations() async {
id: maps[i]['id'], id: maps[i]['id'],
userId: maps[i]['user_id'], userId: maps[i]['user_id'],
conversationDetailId: maps[i]['conversation_detail_id'], conversationDetailId: maps[i]['conversation_detail_id'],
messageThreadKey: maps[i]['message_thread_key'],
symmetricKey: maps[i]['symmetric_key'], symmetricKey: maps[i]['symmetric_key'],
admin: maps[i]['admin'] == 1, admin: maps[i]['admin'] == 1,
name: maps[i]['name'], name: maps[i]['name'],
users: maps[i]['users'],
); );
}); });
} }

+ 33
- 28
mobile/lib/models/friends.dart View File

@ -16,17 +16,19 @@ Friend findFriendByFriendId(List<Friend> friends, String id) {
class Friend{ class Friend{
String id; String id;
String userId; String userId;
String? username;
String username;
String friendId; String friendId;
String friendSymmetricKey; String friendSymmetricKey;
String asymmetricPublicKey;
String acceptedAt; String acceptedAt;
Friend({ Friend({
required this.id, required this.id,
required this.userId, required this.userId,
required this.username,
required this.friendId, required this.friendId,
required this.friendSymmetricKey, required this.friendSymmetricKey,
required this.asymmetricPublicKey,
required this.acceptedAt, required this.acceptedAt,
this.username
}); });
factory Friend.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) { factory Friend.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) {
@ -43,8 +45,10 @@ class Friend{
return Friend( return Friend(
id: json['id'], id: json['id'],
userId: json['user_id'], userId: json['user_id'],
username: '',
friendId: String.fromCharCodes(friendIdDecrypted), friendId: String.fromCharCodes(friendIdDecrypted),
friendSymmetricKey: base64.encode(friendSymmetricKeyDecrypted), friendSymmetricKey: base64.encode(friendSymmetricKeyDecrypted),
asymmetricPublicKey: '',
acceptedAt: json['accepted_at'], acceptedAt: json['accepted_at'],
); );
} }
@ -68,6 +72,7 @@ class Friend{
'username': username, 'username': username,
'friend_id': friendId, 'friend_id': friendId,
'symmetric_key': friendSymmetricKey, 'symmetric_key': friendSymmetricKey,
'asymmetric_public_key': asymmetricPublicKey,
'accepted_at': acceptedAt, 'accepted_at': acceptedAt,
}; };
} }
@ -86,35 +91,35 @@ Future<List<Friend>> getFriends() async {
userId: maps[i]['user_id'], userId: maps[i]['user_id'],
friendId: maps[i]['friend_id'], friendId: maps[i]['friend_id'],
friendSymmetricKey: maps[i]['symmetric_key'], friendSymmetricKey: maps[i]['symmetric_key'],
asymmetricPublicKey: maps[i]['asymmetric_public_key'],
acceptedAt: maps[i]['accepted_at'], acceptedAt: maps[i]['accepted_at'],
username: maps[i]['username'], username: maps[i]['username'],
); );
}); });
} }
// Future<Friend> getFriendByUserId(String userId) async {
// final db = await getDatabaseConnection();
//
// List<dynamic> whereArguments = [userId];
//
// final List<Map<String, dynamic>> maps = await db.query(
// 'friends',
// where: 'friend_id = ?',
// whereArgs: whereArguments,
// );
//
// print(userId);
//
// if (maps.length != 1) {
// throw ArgumentError('Invalid user id');
// }
//
// return Friend(
// id: maps[0]['id'],
// userId: maps[0]['user_id'],
// friendId: maps[0]['friend_id'],
// friendSymmetricKey: maps[0]['symmetric_key'],
// acceptedAt: maps[0]['accepted_at'],
// username: maps[0]['username'],
// );
// }
Future<Friend> getFriendByFriendId(String userId) async {
final db = await getDatabaseConnection();
List<dynamic> whereArguments = [userId];
final List<Map<String, dynamic>> maps = await db.query(
'friends',
where: 'friend_id = ?',
whereArgs: whereArguments,
);
if (maps.length != 1) {
throw ArgumentError('Invalid user id');
}
return Friend(
id: maps[0]['id'],
userId: maps[0]['user_id'],
friendId: maps[0]['friend_id'],
friendSymmetricKey: maps[0]['symmetric_key'],
asymmetricPublicKey: maps[0]['asymmetric_public_key'],
acceptedAt: maps[0]['accepted_at'],
username: maps[0]['username'],
);
}

+ 97
- 20
mobile/lib/models/messages.dart View File

@ -1,8 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:uuid/uuid.dart';
import 'package:Envelope/models/conversation_users.dart';
import 'package:Envelope/models/conversations.dart';
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/utils/encryption/crypto_utils.dart'; import '/utils/encryption/crypto_utils.dart';
import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/aes_helper.dart';
import '/utils/storage/database.dart'; import '/utils/storage/database.dart';
import '/utils/strings.dart';
import '/models/friends.dart'; import '/models/friends.dart';
const messageTypeSender = 'sender'; const messageTypeSender = 'sender';
@ -11,49 +17,116 @@ const messageTypeReceiver = 'receiver';
class Message { class Message {
String id; String id;
String symmetricKey; String symmetricKey;
String messageThreadKey;
String userSymmetricKey;
String data; String data;
String senderId; String senderId;
String senderUsername; String senderUsername;
String associationKey;
String createdAt; String createdAt;
Message({ Message({
required this.id, required this.id,
required this.symmetricKey, required this.symmetricKey,
required this.messageThreadKey,
required this.userSymmetricKey,
required this.data, required this.data,
required this.senderId, required this.senderId,
required this.senderUsername, required this.senderUsername,
required this.associationKey,
required this.createdAt, required this.createdAt,
}); });
factory Message.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) { factory Message.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) {
var symmetricKey = CryptoUtils.rsaDecrypt(
var userSymmetricKey = CryptoUtils.rsaDecrypt(
base64.decode(json['symmetric_key']), base64.decode(json['symmetric_key']),
privKey, privKey,
); );
var data = AesHelper.aesDecrypt(
symmetricKey,
base64.decode(json['message_data']['data']),
var symmetricKey = AesHelper.aesDecrypt(
userSymmetricKey,
base64.decode(json['message_data']['symmetric_key']),
); );
var senderId = AesHelper.aesDecrypt( var senderId = AesHelper.aesDecrypt(
symmetricKey,
base64.decode(symmetricKey),
base64.decode(json['message_data']['sender_id']), base64.decode(json['message_data']['sender_id']),
); );
var data = AesHelper.aesDecrypt(
base64.decode(symmetricKey),
base64.decode(json['message_data']['data']),
);
return Message( return Message(
id: json['id'], id: json['id'],
messageThreadKey: json['message_thread_key'],
symmetricKey: base64.encode(symmetricKey),
symmetricKey: symmetricKey,
userSymmetricKey: base64.encode(userSymmetricKey),
data: data, data: data,
senderId: senderId, senderId: senderId,
senderUsername: 'Unknown', // TODO
senderUsername: 'Unknown',
associationKey: json['association_key'],
createdAt: json['created_at'], createdAt: json['created_at'],
); );
} }
Future<String> toJson(Conversation conversation, String messageDataId) async {
final preferences = await SharedPreferences.getInstance();
RSAPublicKey publicKey = CryptoUtils.rsaPublicKeyFromPem(preferences.getString('asymmetricPublicKey')!);
final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32));
final symmetricKey = AesHelper.deriveKey(generateRandomString(32));
List<Map<String, String>> messages = [];
String id = '';
List<ConversationUser> conversationUsers = await getConversationUsers(conversation);
for (var i = 0; i < conversationUsers.length; i++) {
ConversationUser user = conversationUsers[i];
if (preferences.getString('username') == user.username) {
id = user.id;
messages.add({
'message_data_id': messageDataId,
'symmetric_key': base64.encode(CryptoUtils.rsaEncrypt(
userSymmetricKey,
publicKey,
)),
'association_key': user.associationKey,
});
continue;
}
Friend friend = await getFriendByFriendId(user.id);
RSAPublicKey friendPublicKey = CryptoUtils.rsaPublicKeyFromPem(friend.asymmetricPublicKey);
messages.add({
'message_data_id': messageDataId,
'symmetric_key': base64.encode(CryptoUtils.rsaEncrypt(
userSymmetricKey,
friendPublicKey,
)),
'association_key': user.associationKey,
});
}
Map<String, String> messageData = {
'id': messageDataId,
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(data.codeUnits)),
'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(id.codeUnits)),
'symmetric_key': AesHelper.aesEncrypt(
userSymmetricKey,
Uint8List.fromList(base64.encode(symmetricKey).codeUnits),
),
};
return jsonEncode(<String, dynamic>{
'message_data': messageData,
'message': messages,
});
}
@override @override
String toString() { String toString() {
return ''' return '''
@ -63,6 +136,7 @@ class Message {
data: $data data: $data
senderId: $senderId senderId: $senderId
senderUsername: $senderUsername senderUsername: $senderUsername
associationKey: $associationKey
createdAt: $createdAt createdAt: $createdAt
'''; ''';
} }
@ -70,37 +144,40 @@ class Message {
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
'message_thread_key': messageThreadKey,
'symmetric_key': symmetricKey, 'symmetric_key': symmetricKey,
'user_symmetric_key': userSymmetricKey,
'data': data, 'data': data,
'sender_id': senderId, 'sender_id': senderId,
'sender_username': senderUsername, 'sender_username': senderUsername,
'association_key': associationKey,
'created_at': createdAt, 'created_at': createdAt,
}; };
} }
} }
Future<List<Message>> getMessagesForThread(String messageThreadKey) async {
Future<List<Message>> getMessagesForThread(Conversation conversation) async {
final db = await getDatabaseConnection(); final db = await getDatabaseConnection();
List<dynamic> whereArguments = [messageThreadKey];
final List<Map<String, dynamic>> maps = await db.query(
'messages',
where: 'message_thread_key = ?',
whereArgs: whereArguments,
orderBy: 'created_at DESC',
final List<Map<String, dynamic>> maps = await db.rawQuery(
'''
SELECT * FROM messages WHERE association_key IN (
SELECT association_key FROM conversation_users WHERE conversation_id = ?
)
ORDER BY created_at DESC;
''',
[conversation.id]
); );
return List.generate(maps.length, (i) { return List.generate(maps.length, (i) {
return Message( return Message(
id: maps[i]['id'], id: maps[i]['id'],
messageThreadKey: maps[i]['message_thread_key'],
symmetricKey: maps[i]['symmetric_key'], symmetricKey: maps[i]['symmetric_key'],
userSymmetricKey: maps[i]['user_symmetric_key'],
data: maps[i]['data'], data: maps[i]['data'],
senderId: maps[i]['sender_id'], senderId: maps[i]['sender_id'],
senderUsername: maps[i]['sender_username'], senderUsername: maps[i]['sender_username'],
associationKey: maps[i]['association_key'],
createdAt: maps[i]['created_at'], createdAt: maps[i]['created_at'],
); );
}); });


+ 21
- 5
mobile/lib/utils/storage/conversations.dart View File

@ -4,6 +4,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import '/models/conversations.dart'; import '/models/conversations.dart';
import '/models/conversation_users.dart';
import '/utils/storage/database.dart'; import '/utils/storage/database.dart';
import '/utils/storage/session_cookie.dart'; import '/utils/storage/session_cookie.dart';
import '/utils/storage/encryption_keys.dart'; import '/utils/storage/encryption_keys.dart';
@ -65,15 +66,30 @@ Future<void> updateConversations() async {
base64.decode(conversationDetailJson['name']), base64.decode(conversationDetailJson['name']),
); );
conversation.users = AesHelper.aesDecrypt(
base64.decode(conversation.symmetricKey),
base64.decode(conversationDetailJson['users']),
);
await db.insert( await db.insert(
'conversations', 'conversations',
conversation.toMap(), conversation.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
List<dynamic> usersData = json.decode(
AesHelper.aesDecrypt(
base64.decode(conversation.symmetricKey),
base64.decode(conversationDetailJson['users']),
)
);
for (var i = 0; i < usersData.length; i++) {
ConversationUser conversationUser = ConversationUser.fromJson(
usersData[i] as Map<String, dynamic>,
conversation.id,
);
await db.insert(
'conversation_users',
conversationUser.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
} }
} }

+ 16
- 2
mobile/lib/utils/storage/database.dart View File

@ -24,6 +24,7 @@ Future<Database> getDatabaseConnection() async {
username TEXT, username TEXT,
friend_id TEXT, friend_id TEXT,
symmetric_key TEXT, symmetric_key TEXT,
asymmetric_public_key TEXT,
accepted_at TEXT accepted_at TEXT
); );
'''); ''');
@ -35,7 +36,6 @@ Future<Database> getDatabaseConnection() async {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT, user_id TEXT,
conversation_detail_id TEXT, conversation_detail_id TEXT,
message_thread_key TEXT,
symmetric_key TEXT, symmetric_key TEXT,
admin INTEGER, admin INTEGER,
name TEXT, name TEXT,
@ -43,18 +43,32 @@ Future<Database> getDatabaseConnection() async {
); );
'''); ''');
await db.execute(
'''
CREATE TABLE IF NOT EXISTS conversation_users(
id TEXT PRIMARY KEY,
conversation_id TEXT,
username TEXT,
data TEXT,
association_key TEXT,
admin TEXT
);
''');
await db.execute( await db.execute(
''' '''
CREATE TABLE IF NOT EXISTS messages( CREATE TABLE IF NOT EXISTS messages(
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
message_thread_key TEXT,
symmetric_key TEXT, symmetric_key TEXT,
user_symmetric_key TEXT,
data TEXT, data TEXT,
sender_id TEXT, sender_id TEXT,
sender_username TEXT, sender_username TEXT,
association_key TEXT,
created_at TEXT created_at TEXT
); );
'''); ''');
}, },
// Set the version. This executes the onCreate function and provides a // Set the version. This executes the onCreate function and provides a
// path to perform database upgrades and downgrades. // path to perform database upgrades and downgrades.


+ 2
- 0
mobile/lib/utils/storage/friends.dart View File

@ -67,6 +67,8 @@ Future<void> updateFriends() async {
base64.decode(friendJson['username']), base64.decode(friendJson['username']),
); );
friend.asymmetricPublicKey = friendJson['asymmetric_public_key'];
await db.insert( await db.insert(
'friends', 'friends',
friend.toMap(), friend.toMap(),


+ 91
- 55
mobile/lib/utils/storage/messages.dart View File

@ -1,76 +1,112 @@
import 'dart:convert'; import 'dart:convert';
import 'package:Envelope/models/messages.dart';
import 'package:uuid/uuid.dart';
import 'package:Envelope/models/conversation_users.dart';
import 'package:intl/intl.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:pointycastle/export.dart'; import 'package:pointycastle/export.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:Envelope/models/conversations.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/utils/storage/session_cookie.dart'; import '/utils/storage/session_cookie.dart';
import '/utils/storage/encryption_keys.dart'; import '/utils/storage/encryption_keys.dart';
import '/utils/storage/database.dart'; import '/utils/storage/database.dart';
import '/models/conversations.dart'; import '/models/conversations.dart';
// TODO: Move this to table
Map<String, Map<String, String>> _mapUsers(String users) {
List<dynamic> usersJson = jsonDecode(users);
Map<String, Map<String, String>> mapped = {};
for (var i = 0; i < usersJson.length; i++) {
mapped[usersJson[i]['id']] = {
'username': usersJson[i]['username'],
'admin': usersJson[i]['admin'],
};
}
return mapped;
}
import '/models/messages.dart';
Future<void> updateMessageThread(Conversation conversation, {RSAPrivateKey? privKey}) async { Future<void> updateMessageThread(Conversation conversation, {RSAPrivateKey? privKey}) async {
privKey ??= await getPrivateKey();
var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/messages/${conversation.messageThreadKey}'),
headers: {
'cookie': await getSessionCookie(),
}
privKey ??= await getPrivateKey();
final preferences = await SharedPreferences.getInstance();
String username = preferences.getString('username')!;
ConversationUser currentUser = await getConversationUserByUsername(conversation, username);
var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/messages/${currentUser.associationKey}'),
headers: {
'cookie': await getSessionCookie(),
}
);
if (resp.statusCode != 200) {
throw Exception(resp.body);
}
List<dynamic> messageThreadJson = jsonDecode(resp.body);
final db = await getDatabaseConnection();
for (var i = 0; i < messageThreadJson.length; i++) {
Message message = Message.fromJson(
messageThreadJson[i] as Map<String, dynamic>,
privKey,
); );
if (resp.statusCode != 200) {
throw Exception(resp.body);
}
var mapped = _mapUsers(conversation.users!);
ConversationUser messageUser = await getConversationUserById(conversation, message.senderId);
message.senderUsername = messageUser.username;
List<dynamic> messageThreadJson = jsonDecode(resp.body);
final db = await getDatabaseConnection();
for (var i = 0; i < messageThreadJson.length; i++) {
Message message = Message.fromJson(
messageThreadJson[i] as Map<String, dynamic>,
privKey,
);
// TODO: Fix this
message.senderUsername = mapped[message.senderId]!['username']!;
await db.insert(
'messages',
message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await db.insert(
'messages',
message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
} }
Future<void> updateMessageThreads({List<Conversation>? conversations}) async { Future<void> updateMessageThreads({List<Conversation>? conversations}) async {
RSAPrivateKey privKey = await getPrivateKey();
RSAPrivateKey privKey = await getPrivateKey();
conversations ??= await getConversations();
conversations ??= await getConversations();
for (var i = 0; i < conversations.length; i++) {
await updateMessageThread(conversations[i], privKey: privKey);
}
for (var i = 0; i < conversations.length; i++) {
await updateMessageThread(conversations[i], privKey: privKey);
}
} }
Future<void> sendMessage(Conversation conversation, String data) async {
final preferences = await SharedPreferences.getInstance();
final userId = preferences.getString('userId');
final username = preferences.getString('username');
if (userId == null || username == null) {
throw Exception('Invalid user id');
}
var uuid = const Uuid();
final String messageDataId = uuid.v4();
ConversationUser currentUser = await getConversationUserByUsername(conversation, username);
Message message = Message(
id: messageDataId,
symmetricKey: '',
userSymmetricKey: '',
senderId: userId,
senderUsername: username,
data: data,
createdAt: DateTime.now().toIso8601String(),
associationKey: currentUser.associationKey,
);
final db = await getDatabaseConnection();
print(await db.query('messages'));
await db.insert(
'messages',
message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
String messageJson = await message.toJson(conversation, messageDataId);
final resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/message'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': await getSessionCookie(),
},
body: messageJson,
);
// TODO: If statusCode not successfull, mark as needing resend
print(resp.statusCode);
}

+ 8
- 0
mobile/lib/utils/strings.dart View File

@ -0,0 +1,8 @@
import 'dart:math';
String generateRandomString(int len) {
var r = Random();
const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
return List.generate(len, (index) => _chars[r.nextInt(_chars.length)]).join();
}

+ 5
- 4
mobile/lib/views/authentication/login.dart View File

@ -14,6 +14,7 @@ class LoginResponse {
final String asymmetricPublicKey; final String asymmetricPublicKey;
final String asymmetricPrivateKey; final String asymmetricPrivateKey;
final String userId; final String userId;
final String username;
const LoginResponse({ const LoginResponse({
required this.status, required this.status,
@ -21,6 +22,7 @@ class LoginResponse {
required this.asymmetricPublicKey, required this.asymmetricPublicKey,
required this.asymmetricPrivateKey, required this.asymmetricPrivateKey,
required this.userId, required this.userId,
required this.username,
}); });
factory LoginResponse.fromJson(Map<String, dynamic> json) { factory LoginResponse.fromJson(Map<String, dynamic> json) {
@ -30,6 +32,7 @@ class LoginResponse {
asymmetricPublicKey: json['asymmetric_public_key'], asymmetricPublicKey: json['asymmetric_public_key'],
asymmetricPrivateKey: json['asymmetric_private_key'], asymmetricPrivateKey: json['asymmetric_private_key'],
userId: json['user_id'], userId: json['user_id'],
username: json['username'],
); );
} }
} }
@ -60,14 +63,14 @@ Future<LoginResponse> login(context, String username, String password) async {
var rsaPrivPem = AesHelper.aesDecrypt(password, base64.decode(response.asymmetricPrivateKey)); var rsaPrivPem = AesHelper.aesDecrypt(password, base64.decode(response.asymmetricPrivateKey));
debugPrint(rsaPrivPem);
var rsaPriv = CryptoUtils.rsaPrivateKeyFromPem(rsaPrivPem); var rsaPriv = CryptoUtils.rsaPrivateKeyFromPem(rsaPrivPem);
setPrivateKey(rsaPriv); setPrivateKey(rsaPriv);
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
preferences.setBool('islogin', true); preferences.setBool('islogin', true);
preferences.setString('userId', response.userId); preferences.setString('userId', response.userId);
preferences.setString('username', response.username);
preferences.setString('asymmetricPublicKey', response.asymmetricPublicKey);
return response; return response;
} }
@ -75,8 +78,6 @@ Future<LoginResponse> login(context, String username, String password) async {
class Login extends StatelessWidget { class Login extends StatelessWidget {
const Login({Key? key}) : super(key: key); const Login({Key? key}) : super(key: key);
static const String _title = 'Envelope';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(


+ 2
- 8
mobile/lib/views/authentication/signup.dart View File

@ -47,25 +47,19 @@ Future<SignupResponse> signUp(context, String username, String password, String
'asymmetric_private_key': encRsaPriv, 'asymmetric_private_key': encRsaPriv,
}), }),
); );
SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body)); SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body));
if (resp.statusCode != 201) { if (resp.statusCode != 201) {
throw Exception(response.message); throw Exception(response.message);
} }
debugPrint(rsaPubPem);
debugPrint(rsaPrivPem);
debugPrint(resp.body);
return response; return response;
} }
class Signup extends StatelessWidget { class Signup extends StatelessWidget {
const Signup({Key? key}) : super(key: key); const Signup({Key? key}) : super(key: key);
static const String _title = 'Envelope';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -113,7 +107,7 @@ class _SignupWidgetState extends State<SignupWidget> {
minimumSize: const Size.fromHeight(50), minimumSize: const Size.fromHeight(50),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: 20,
fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.red, color: Colors.red,
), ),


+ 32
- 21
mobile/lib/views/main/conversation_detail.dart View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '/models/conversations.dart'; import '/models/conversations.dart';
import '/models/messages.dart'; import '/models/messages.dart';
import '/utils/storage/messages.dart';
String convertToAgo(String input){ String convertToAgo(String input){
DateTime time = DateTime.parse(input); DateTime time = DateTime.parse(input);
@ -36,7 +37,9 @@ class ConversationDetail extends StatefulWidget{
class _ConversationDetailState extends State<ConversationDetail> { class _ConversationDetailState extends State<ConversationDetail> {
List<Message> messages = []; List<Message> messages = [];
String userId = '';
String username = '';
TextEditingController msgController = TextEditingController();
@override @override
void initState() { void initState() {
@ -44,10 +47,10 @@ class _ConversationDetailState extends State<ConversationDetail> {
fetchMessages(); fetchMessages();
} }
void fetchMessages() async {
Future<void> fetchMessages() async {
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
userId = preferences.getString('userId')!;
messages = await getMessagesForThread(widget.conversation.messageThreadKey);
username = preferences.getString('username')!;
messages = await getMessagesForThread(widget.conversation);
setState(() {}); setState(() {});
} }
@ -84,7 +87,7 @@ class _ConversationDetailState extends State<ConversationDetail> {
], ],
), ),
), ),
const Icon(Icons.settings,color: Colors.black54,),
const Icon(Icons.settings,color: Colors.black54),
], ],
), ),
), ),
@ -95,35 +98,35 @@ class _ConversationDetailState extends State<ConversationDetail> {
ListView.builder( ListView.builder(
itemCount: messages.length, itemCount: messages.length,
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.only(top: 10,bottom: 10),
padding: const EdgeInsets.only(top: 10,bottom: 90),
reverse: true, reverse: true,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return Container( return Container(
padding: const EdgeInsets.only(left: 14,right: 14,top: 0,bottom: 0), padding: const EdgeInsets.only(left: 14,right: 14,top: 0,bottom: 0),
child: Align( child: Align(
alignment: ( alignment: (
messages[index].senderId == userId ?
Alignment.topLeft:
Alignment.topRight
messages[index].senderUsername == username ?
Alignment.topRight :
Alignment.topLeft
), ),
child: Column( child: Column(
crossAxisAlignment: messages[index].senderId == userId ?
CrossAxisAlignment.start :
CrossAxisAlignment.end,
crossAxisAlignment: messages[index].senderUsername == username ?
CrossAxisAlignment.end :
CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
color: ( color: (
messages[index].senderId == userId ?
Colors.grey.shade200 :
Colors.blue[200]
messages[index].senderUsername == username ?
Colors.blue[200] :
Colors.grey.shade200
), ),
), ),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text(messages[index].data, style: const TextStyle(fontSize: 15)), child: Text(messages[index].data, style: const TextStyle(fontSize: 15)),
), ),
messages[index].senderId != userId ?
messages[index].senderUsername != username ?
Text(messages[index].senderUsername) : Text(messages[index].senderUsername) :
const SizedBox.shrink(), const SizedBox.shrink(),
Text( Text(
@ -167,24 +170,32 @@ class _ConversationDetailState extends State<ConversationDetail> {
), ),
), ),
const SizedBox(width: 15,), const SizedBox(width: 15,),
const Expanded(
Expanded(
child: TextField( child: TextField(
decoration: InputDecoration(
decoration: const InputDecoration(
hintText: "Write message...", hintText: "Write message...",
hintStyle: TextStyle(color: Colors.black54), hintStyle: TextStyle(color: Colors.black54),
border: InputBorder.none, border: InputBorder.none,
), ),
maxLines: null, maxLines: null,
controller: msgController,
), ),
), ),
const SizedBox(width: 15,),
const SizedBox(width: 15),
FloatingActionButton( FloatingActionButton(
onPressed: () {
onPressed: () async {
if (msgController.text == '') {
return;
}
await sendMessage(widget.conversation, msgController.text);
messages = await getMessagesForThread(widget.conversation);
setState(() {});
msgController.text = '';
}, },
child: const Icon(Icons.send,color: Colors.white,size: 18,), child: const Icon(Icons.send,color: Colors.white,size: 18,),
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
elevation: 0,
), ),
const SizedBox(width: 10),
], ],
), ),
), ),


+ 116
- 93
mobile/lib/views/main/conversation_list.dart View File

@ -1,110 +1,133 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '/models/conversations.dart'; import '/models/conversations.dart';
import '/views/main/conversation_list_item.dart'; import '/views/main/conversation_list_item.dart';
import '/utils/storage/messages.dart';
class ConversationList extends StatefulWidget { class ConversationList extends StatefulWidget {
const ConversationList({Key? key}) : super(key: key);
final List<Conversation> conversations;
const ConversationList({
Key? key,
required this.conversations,
}) : super(key: key);
@override
State<ConversationList> createState() => _ConversationListState();
@override
State<ConversationList> createState() => _ConversationListState();
} }
class _ConversationListState extends State<ConversationList> { class _ConversationListState extends State<ConversationList> {
List<Conversation> conversations = [];
List<Conversation> conversations = [];
@override
void initState() {
super.initState();
fetchConversations();
}
void fetchConversations() async {
conversations = await getConversations();
setState(() {});
}
@override
void initState() {
super.initState();
conversations.addAll(widget.conversations);
setState(() {});
}
Widget list() {
void filterSearchResults(String query) {
List<Conversation> dummySearchList = [];
dummySearchList.addAll(widget.conversations);
if (conversations.isEmpty) {
return const Center(
child: Text('No Conversations'),
);
if(query.isNotEmpty) {
List<Conversation> dummyListData = [];
dummySearchList.forEach((item) {
if (item.name.toLowerCase().contains(query)) {
dummyListData.add(item);
} }
return ListView.builder(
itemCount: conversations.length,
shrinkWrap: true,
padding: const EdgeInsets.only(top: 16),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, i) {
return ConversationListItem(
conversation: conversations[i],
);
},
);
});
setState(() {
conversations.clear();
conversations.addAll(dummyListData);
});
return;
} }
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SafeArea(
child: Padding(
padding: const EdgeInsets.only(left: 16,right: 16,top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const Text("Conversations",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),),
Container(
padding: const EdgeInsets.only(left: 8,right: 8,top: 2,bottom: 2),
height: 30,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: Colors.pink[50],
),
child: Row(
children: const <Widget>[
Icon(Icons.add,color: Colors.pink,size: 20,),
SizedBox(width: 2,),
Text("Add",style: TextStyle(fontSize: 14,fontWeight: FontWeight.bold),),
],
),
)
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: TextField(
decoration: InputDecoration(
hintText: "Search...",
hintStyle: TextStyle(color: Colors.grey.shade600),
prefixIcon: Icon(Icons.search,color: Colors.grey.shade600, size: 20,),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.all(8),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(
color: Colors.grey.shade100
)
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: list(),
),
],
),
),
);
setState(() {
conversations.clear();
conversations.addAll(widget.conversations);
});
}
Widget list() {
if (conversations.isEmpty) {
return const Center(
child: Text('No Conversations'),
);
} }
return ListView.builder(
itemCount: conversations.length,
shrinkWrap: true,
padding: const EdgeInsets.only(top: 16),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, i) {
return ConversationListItem(
conversation: conversations[i],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SafeArea(
child: Padding(
padding: const EdgeInsets.only(left: 16,right: 16,top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const Text("Conversations",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),),
Container(
padding: const EdgeInsets.only(left: 8,right: 8,top: 2,bottom: 2),
height: 30,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: Colors.pink[50],
),
child: Row(
children: const <Widget>[
Icon(Icons.add,color: Colors.pink,size: 20,),
SizedBox(width: 2,),
Text("Add",style: TextStyle(fontSize: 14,fontWeight: FontWeight.bold),),
],
),
)
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: TextField(
decoration: InputDecoration(
hintText: "Search...",
hintStyle: TextStyle(color: Colors.grey.shade600),
prefixIcon: Icon(Icons.search,color: Colors.grey.shade600, size: 20,),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.all(8),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(
color: Colors.grey.shade100
)
),
),
onChanged: (value) => filterSearchResults(value.toLowerCase())
),
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: list(),
),
],
),
),
);
}
} }

+ 116
- 91
mobile/lib/views/main/friend_list.dart View File

@ -3,108 +3,133 @@ import '/models/friends.dart';
import '/views/main/friend_list_item.dart'; import '/views/main/friend_list_item.dart';
class FriendList extends StatefulWidget { class FriendList extends StatefulWidget {
const FriendList({Key? key}) : super(key: key);
final List<Friend> friends;
const FriendList({
Key? key,
required this.friends,
}) : super(key: key);
@override
State<FriendList> createState() => _FriendListState();
@override
State<FriendList> createState() => _FriendListState();
} }
class _FriendListState extends State<FriendList> { class _FriendListState extends State<FriendList> {
List<Friend> friends = [];
List<Friend> friends = [];
List<Friend> friendsDuplicate = [];
@override
void initState() {
super.initState();
fetchFriends();
}
void fetchFriends() async {
friends = await getFriends();
setState(() {});
}
@override
void initState() {
super.initState();
friends.addAll(widget.friends);
setState(() {});
}
Widget list() {
void filterSearchResults(String query) {
List<Friend> dummySearchList = [];
dummySearchList.addAll(widget.friends);
if (friends.isEmpty) {
return const Center(
child: Text('No Friends'),
);
if(query.isNotEmpty) {
List<Friend> dummyListData = [];
dummySearchList.forEach((item) {
if(item.username.toLowerCase().contains(query)) {
dummyListData.add(item);
} }
});
setState(() {
friends.clear();
friends.addAll(dummyListData);
});
return;
}
return ListView.builder(
itemCount: friends.length,
shrinkWrap: true,
padding: const EdgeInsets.only(top: 16),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, i) {
return FriendListItem(
id: friends[i].id,
username: friends[i].username!,
);
},
);
setState(() {
friends.clear();
friends.addAll(widget.friends);
});
}
Widget list() {
if (friends.isEmpty) {
return const Center(
child: Text('No Friends'),
);
} }
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SafeArea(
child: Padding(
padding: const EdgeInsets.only(left: 16,right: 16,top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const Text("Friends",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),),
Container(
padding: const EdgeInsets.only(left: 8,right: 8,top: 2,bottom: 2),
height: 30,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: Colors.pink[50],
),
child: Row(
children: const <Widget>[
Icon(Icons.add,color: Colors.pink,size: 20,),
SizedBox(width: 2,),
Text("Add",style: TextStyle(fontSize: 14,fontWeight: FontWeight.bold),),
],
),
)
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: TextField(
decoration: InputDecoration(
hintText: "Search...",
hintStyle: TextStyle(color: Colors.grey.shade600),
prefixIcon: Icon(Icons.search,color: Colors.grey.shade600, size: 20,),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.all(8),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(
color: Colors.grey.shade100
)
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: list(),
),
],
return ListView.builder(
itemCount: friends.length,
shrinkWrap: true,
padding: const EdgeInsets.only(top: 16),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, i) {
return FriendListItem(
id: friends[i].id,
username: friends[i].username,
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SafeArea(
child: Padding(
padding: const EdgeInsets.only(left: 16,right: 16,top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const Text("Friends",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),),
Container(
padding: const EdgeInsets.only(left: 8,right: 8,top: 2,bottom: 2),
height: 30,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: Colors.pink[50],
),
child: Row(
children: const <Widget>[
Icon(Icons.add,color: Colors.pink,size: 20,),
SizedBox(width: 2,),
Text("Add",style: TextStyle(fontSize: 14,fontWeight: FontWeight.bold),),
],
),
)
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: TextField(
decoration: InputDecoration(
hintText: "Search...",
hintStyle: TextStyle(color: Colors.grey.shade600),
prefixIcon: Icon(Icons.search,color: Colors.grey.shade600, size: 20,),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.all(8),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(
color: Colors.grey.shade100
)
),
), ),
onChanged: (value) => filterSearchResults(value.toLowerCase())
), ),
);
}
),
Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: list(),
),
],
),
),
);
}
} }

+ 49
- 9
mobile/lib/views/main/home.dart View File

@ -6,6 +6,8 @@ import '/views/main/profile.dart';
import '/utils/storage/friends.dart'; import '/utils/storage/friends.dart';
import '/utils/storage/conversations.dart'; import '/utils/storage/conversations.dart';
import '/utils/storage/messages.dart'; import '/utils/storage/messages.dart';
import '/models/conversations.dart';
import '/models/friends.dart';
class Home extends StatefulWidget { class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key); const Home({Key? key}) : super(key: key);
@ -15,6 +17,17 @@ class Home extends StatefulWidget {
} }
class _HomeState extends State<Home> { class _HomeState extends State<Home> {
List<Conversation> conversations = [];
List<Friend> friends = [];
bool isLoading = true;
int _selectedIndex = 0;
List<Widget> _widgetOptions = <Widget>[
const ConversationList(conversations: []),
const FriendList(friends: []),
const Profile(),
];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -26,6 +39,18 @@ class _HomeState extends State<Home> {
await updateFriends(); await updateFriends();
await updateConversations(); await updateConversations();
await updateMessageThreads(); await updateMessageThreads();
conversations = await getConversations();
friends = await getFriends();
setState(() {
_widgetOptions = <Widget>[
ConversationList(conversations: conversations),
FriendList(friends: friends),
const Profile(),
];
isLoading = false;
});
} }
// TODO: Do server GET check here // TODO: Do server GET check here
@ -36,26 +61,41 @@ class _HomeState extends State<Home> {
} }
} }
int _selectedIndex = 0;
static const List<Widget> _widgetOptions = <Widget>[
ConversationList(),
FriendList(),
Profile(),
];
void _onItemTapped(int index) { void _onItemTapped(int index) {
setState(() { setState(() {
_selectedIndex = index; _selectedIndex = index;
}); });
} }
Widget loading() {
return Stack(
children: <Widget>[
const Opacity(
opacity: 0.1,
child: ModalBarrier(dismissible: false, color: Colors.black),
),
Center(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget> [
CircularProgressIndicator(),
SizedBox(height: 25),
Text("Loading..."),
],
)
),
]
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return WillPopScope( return WillPopScope(
onWillPop: () async => false, onWillPop: () async => false,
child: Scaffold( child: Scaffold(
body: _widgetOptions.elementAt(_selectedIndex),
bottomNavigationBar: BottomNavigationBar(
body: isLoading ? loading() : _widgetOptions.elementAt(_selectedIndex),
bottomNavigationBar: isLoading ? const SizedBox.shrink() : BottomNavigationBar(
currentIndex: _selectedIndex, currentIndex: _selectedIndex,
onTap: _onItemTapped, onTap: _onItemTapped,
selectedItemColor: Colors.red, selectedItemColor: Colors.red,


+ 21
- 0
mobile/pubspec.lock View File

@ -57,6 +57,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
crypto:
dependency: transitive
description:
name: crypto
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -135,6 +142,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.1" version: "4.0.1"
intl:
dependency: "direct main"
description:
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.0"
js: js:
dependency: transitive dependency: transitive
description: description:
@ -357,6 +371,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
uuid:
dependency: "direct main"
description:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.6"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:


+ 2
- 0
mobile/pubspec.yaml View File

@ -20,6 +20,8 @@ dependencies:
sqflite: ^2.0.2 sqflite: ^2.0.2
path: 1.8.1 path: 1.8.1
flutter_dotenv: ^5.0.2 flutter_dotenv: ^5.0.2
intl: ^0.17.0
uuid: ^3.0.6
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:


Loading…
Cancel
Save