Browse Source

WIP - Adding image support

pull/3/head
Tovi Jaeschke-Rogers 2 years ago
parent
commit
f56ccfe942
21 changed files with 580 additions and 222 deletions
  1. +1
    -0
      .gitignore
  2. +28
    -14
      Backend/Api/Messages/CreateMessage.go
  3. +14
    -0
      Backend/Api/Messages/MessageThread.go
  4. +5
    -0
      Backend/Api/Routes.go
  5. +8
    -1
      Backend/Database/Messages.go
  6. +5
    -2
      Backend/Models/Attachments.go
  7. +5
    -3
      Backend/Models/Messages.go
  8. +46
    -0
      Backend/Util/Files.go
  9. +30
    -0
      mobile/lib/components/view_image.dart
  10. +3
    -3
      mobile/lib/models/conversations.dart
  11. +41
    -12
      mobile/lib/models/image_message.dart
  12. +38
    -11
      mobile/lib/models/messages.dart
  13. +7
    -3
      mobile/lib/models/text_messages.dart
  14. +8
    -2
      mobile/lib/utils/encryption/aes_helper.dart
  15. +1
    -0
      mobile/lib/utils/storage/database.dart
  16. +100
    -57
      mobile/lib/utils/storage/messages.dart
  17. +26
    -0
      mobile/lib/utils/storage/write_file.dart
  18. +18
    -113
      mobile/lib/views/main/conversation/detail.dart
  19. +158
    -0
      mobile/lib/views/main/conversation/message.dart
  20. +36
    -1
      mobile/pubspec.lock
  21. +2
    -0
      mobile/pubspec.yaml

+ 1
- 0
.gitignore View File

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

+ 28
- 14
Backend/Api/Messages/CreateMessage.go View File

@ -1,40 +1,54 @@
package Messages package Messages
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Util"
) )
type RawMessageData struct {
type rawMessageData struct {
MessageData Models.MessageData `json:"message_data"` MessageData Models.MessageData `json:"message_data"`
Messages []Models.Message `json:"message"` Messages []Models.Message `json:"message"`
} }
// CreateMessage sends a message
func CreateMessage(w http.ResponseWriter, r *http.Request) { func CreateMessage(w http.ResponseWriter, r *http.Request) {
var ( var (
rawMessageData RawMessageData
err error
messagesData []rawMessageData
messageData rawMessageData
decodedFile []byte
fileName string
err error
) )
err = json.NewDecoder(r.Body).Decode(&rawMessageData)
err = json.NewDecoder(r.Body).Decode(&messagesData)
if err != nil { if err != nil {
http.Error(w, "Error", http.StatusInternalServerError) http.Error(w, "Error", http.StatusInternalServerError)
return return
} }
err = Database.CreateMessageData(&rawMessageData.MessageData)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
err = Database.CreateMessages(&rawMessageData.Messages)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
for _, messageData = range messagesData {
if messageData.MessageData.Data == "" {
decodedFile, err = base64.StdEncoding.DecodeString(messageData.MessageData.Attachment.Data)
fileName, err = Util.WriteFile(decodedFile)
messageData.MessageData.Attachment.FilePath = fileName
}
err = Database.CreateMessageData(&messageData.MessageData)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
err = Database.CreateMessages(&messageData.Messages)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)


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

@ -2,6 +2,7 @@ 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/Database"
@ -14,9 +15,11 @@ import (
func Messages(w http.ResponseWriter, r *http.Request) { func Messages(w http.ResponseWriter, r *http.Request) {
var ( var (
messages []Models.Message messages []Models.Message
message Models.Message
urlVars map[string]string urlVars map[string]string
associationKey string associationKey string
returnJSON []byte returnJSON []byte
i int
ok bool ok bool
err error err error
) )
@ -34,6 +37,17 @@ func Messages(w http.ResponseWriter, r *http.Request) {
return return
} }
for i, message = range messages {
if message.MessageData.AttachmentID == nil {
continue
}
messages[i].MessageData.Attachment.ImageLink = fmt.Sprintf(
"http://192.168.1.5:8080/files/%s",
message.MessageData.Attachment.FilePath,
)
}
returnJSON, err = json.MarshalIndent(messages, "", " ") returnJSON, err = json.MarshalIndent(messages, "", " ")
if err != nil { if err != nil {
http.Error(w, "Error", http.StatusInternalServerError) http.Error(w, "Error", http.StatusInternalServerError)


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

@ -44,6 +44,7 @@ func InitAPIEndpoints(router *mux.Router) {
var ( var (
api *mux.Router api *mux.Router
authAPI *mux.Router authAPI *mux.Router
fs http.Handler
) )
log.Println("Initializing API routes...") log.Println("Initializing API routes...")
@ -79,4 +80,8 @@ func InitAPIEndpoints(router *mux.Router) {
authAPI.HandleFunc("/message", Messages.CreateMessage).Methods("POST") authAPI.HandleFunc("/message", Messages.CreateMessage).Methods("POST")
authAPI.HandleFunc("/messages/{associationKey}", Messages.Messages).Methods("GET") 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))
} }

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

@ -7,7 +7,8 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
func GetMessageById(id string) (Models.Message, error) {
// GetMessageByID gets a message
func GetMessageByID(id string) (Models.Message, error) {
var ( var (
message Models.Message message Models.Message
err error err error
@ -20,6 +21,8 @@ func GetMessageById(id string) (Models.Message, error) {
return message, err return message, err
} }
// GetMessagesByAssociationKey for getting whole thread
// TODO: Add pagination
func GetMessagesByAssociationKey(associationKey string) ([]Models.Message, error) { func GetMessagesByAssociationKey(associationKey string) ([]Models.Message, error) {
var ( var (
messages []Models.Message messages []Models.Message
@ -27,12 +30,14 @@ func GetMessagesByAssociationKey(associationKey string) ([]Models.Message, error
) )
err = DB.Preload("MessageData"). err = DB.Preload("MessageData").
Preload("MessageData.Attachment").
Find(&messages, "association_key = ?", associationKey). Find(&messages, "association_key = ?", associationKey).
Error Error
return messages, err return messages, err
} }
// CreateMessage creates a message record
func CreateMessage(message *Models.Message) error { func CreateMessage(message *Models.Message) error {
var err error var err error
@ -43,6 +48,7 @@ func CreateMessage(message *Models.Message) error {
return err return err
} }
// CreateMessages creates multiple records
func CreateMessages(messages *[]Models.Message) error { func CreateMessages(messages *[]Models.Message) error {
var err error var err error
@ -53,6 +59,7 @@ func CreateMessages(messages *[]Models.Message) error {
return err return err
} }
// DeleteMessage deletes a message
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).


+ 5
- 2
Backend/Models/Attachments.go View File

@ -3,6 +3,9 @@ package Models
// Attachment holds the attachment data // Attachment holds the attachment data
type Attachment struct { type Attachment struct {
Base Base
FilePath string `gorm:"not null" json:"-"`
Mimetype string `gorm:"not null" json:"mimetype"`
FilePath string `gorm:"not null" json:"-"`
Mimetype string `gorm:"not null" json:"mimetype"`
Extension string `gorm:"not null" json:"extension"`
Data string `gorm:"-" json:"data"`
ImageLink string `gorm:"-" json:"image_link"`
} }

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

@ -11,9 +11,11 @@ import (
// encrypted through the Message.SymmetricKey // encrypted through the Message.SymmetricKey
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"` // Stored encrypted
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
Data string ` json:"data"` // Stored encrypted
AttachmentID *uuid.UUID ` json:"attachment_id"`
Attachment Attachment ` json:"attachment"`
SenderID string `gorm:"not null" json:"sender_id"` // Stored encrypted
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
} }
// Message holds data pertaining to each users' message // Message holds data pertaining to each users' message


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

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

@ -0,0 +1,30 @@
import 'package:Envelope/components/custom_title_bar.dart';
import 'package:Envelope/models/image_message.dart';
import 'package:flutter/material.dart';
class ViewImage extends StatelessWidget {
const ViewImage({
Key? key,
required this.message,
}) : super(key: key);
final ImageMessage message;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: const CustomTitleBar(
title: Text(''),
showBack: true,
backgroundColor: Colors.black,
),
body: Center(
child: Image.file(
message.file,
),
),
);
}
}

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

@ -35,7 +35,7 @@ Future<Conversation> createConversation(String title, List<Friend> friends, bool
status: ConversationStatus.pending, status: ConversationStatus.pending,
isRead: true, isRead: true,
); );
await db.insert( await db.insert(
'conversations', 'conversations',
conversation.toMap(), conversation.toMap(),
@ -185,7 +185,7 @@ Future<Conversation?> getTwoUserConversation(String userId) async {
final List<Map<String, dynamic>> maps = await db.rawQuery( final List<Map<String, dynamic>> maps = await db.rawQuery(
''' '''
SELECT conversations.* FROM conversations
SELECT conversations.* FROM conversations
LEFT JOIN conversation_users ON conversation_users.conversation_id = conversations.id LEFT JOIN conversation_users ON conversation_users.conversation_id = conversations.id
WHERE conversation_users.user_id = ? WHERE conversation_users.user_id = ?
AND conversation_users.user_id != ? AND conversation_users.user_id != ?
@ -353,7 +353,7 @@ class Conversation {
id: maps[0]['id'], id: maps[0]['id'],
symmetricKey: maps[0]['symmetric_key'], symmetricKey: maps[0]['symmetric_key'],
userSymmetricKey: maps[0]['user_symmetric_key'], userSymmetricKey: maps[0]['user_symmetric_key'],
text: maps[0]['data'],
text: maps[0]['data'] ?? 'Image',
senderId: maps[0]['sender_id'], senderId: maps[0]['sender_id'],
senderUsername: maps[0]['sender_username'], senderUsername: maps[0]['sender_username'],
associationKey: maps[0]['association_key'], associationKey: maps[0]['association_key'],


+ 41
- 12
mobile/lib/models/image_message.dart View File

@ -1,6 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:Envelope/utils/storage/session_cookie.dart';
import 'package:Envelope/utils/storage/write_file.dart';
import 'package:http/http.dart' as http;
import 'package:mime/mime.dart';
import 'package:pointycastle/pointycastle.dart'; import 'package:pointycastle/pointycastle.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@ -11,7 +16,7 @@ import '/utils/encryption/crypto_utils.dart';
import '/utils/strings.dart'; import '/utils/strings.dart';
class ImageMessage extends Message { class ImageMessage extends Message {
String text;
File file;
ImageMessage({ ImageMessage({
id, id,
@ -22,7 +27,7 @@ class ImageMessage extends Message {
associationKey, associationKey,
createdAt, createdAt,
failedToSend, failedToSend,
required this.text,
required this.file,
}) : super( }) : super(
id: id, id: id,
symmetricKey: symmetricKey, symmetricKey: symmetricKey,
@ -34,7 +39,7 @@ class ImageMessage extends Message {
failedToSend: failedToSend, failedToSend: failedToSend,
); );
factory ImageMessage.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) {
static Future<ImageMessage> fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) async {
var userSymmetricKey = CryptoUtils.rsaDecrypt( var userSymmetricKey = CryptoUtils.rsaDecrypt(
base64.decode(json['symmetric_key']), base64.decode(json['symmetric_key']),
privKey, privKey,
@ -50,9 +55,25 @@ class ImageMessage extends Message {
base64.decode(json['message_data']['sender_id']), base64.decode(json['message_data']['sender_id']),
); );
var data = AesHelper.aesDecrypt(
var resp = await http.get(
Uri.parse(json['message_data']['attachment']['image_link']),
headers: {
'cookie': await getSessionCookie(),
}
);
if (resp.statusCode != 200) {
throw Exception('Could not get attachment file');
}
var data = AesHelper.aesDecryptBytes(
base64.decode(symmetricKey), base64.decode(symmetricKey),
base64.decode(json['message_data']['data']),
resp.bodyBytes,
);
File file = await writeImage(
'${json['id']}',
data,
); );
return ImageMessage( return ImageMessage(
@ -64,16 +85,17 @@ class ImageMessage extends Message {
associationKey: json['association_key'], associationKey: json['association_key'],
createdAt: json['created_at'], createdAt: json['created_at'],
failedToSend: false, failedToSend: false,
text: data,
file: file,
); );
} }
@override
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
'symmetric_key': symmetricKey, 'symmetric_key': symmetricKey,
'user_symmetric_key': userSymmetricKey, 'user_symmetric_key': userSymmetricKey,
'data': text,
'file': file.path,
'sender_id': senderId, 'sender_id': senderId,
'sender_username': senderUsername, 'sender_username': senderUsername,
'association_key': associationKey, 'association_key': associationKey,
@ -82,26 +104,33 @@ class ImageMessage extends Message {
}; };
} }
Future<Map<String, dynamic>> payloadJson(Conversation conversation, String messageId) async {
Future<Map<String, dynamic>> payloadJson(Conversation conversation) async {
final String messageDataId = (const Uuid()).v4(); final String messageDataId = (const Uuid()).v4();
final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); final symmetricKey = AesHelper.deriveKey(generateRandomString(32));
final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32));
List<Map<String, String>> messages = await super.payloadJsonBase( List<Map<String, String>> messages = await super.payloadJsonBase(
symmetricKey, symmetricKey,
userSymmetricKey,
conversation, conversation,
messageId,
id,
messageDataId, messageDataId,
); );
Map<String, String> messageData = {
Map<String, dynamic> messageData = {
'id': messageDataId, 'id': messageDataId,
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)),
'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), 'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)),
'symmetric_key': AesHelper.aesEncrypt( 'symmetric_key': AesHelper.aesEncrypt(
userSymmetricKey, userSymmetricKey,
Uint8List.fromList(base64.encode(symmetricKey).codeUnits), Uint8List.fromList(base64.encode(symmetricKey).codeUnits),
), ),
'attachment': {
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(file.readAsBytesSync())),
'mimetype': lookupMimeType(file.path),
'extension': getExtension(file.path),
}
}; };
return <String, dynamic>{ return <String, dynamic>{
@ -121,7 +150,7 @@ class ImageMessage extends Message {
id: $id id: $id
data: $text,
file: ${file.path},
senderId: $senderId senderId: $senderId
senderUsername: $senderUsername senderUsername: $senderUsername
associationKey: $associationKey associationKey: $associationKey


+ 38
- 11
mobile/lib/models/messages.dart View File

@ -1,15 +1,16 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:Envelope/models/conversation_users.dart';
import 'package:Envelope/models/my_profile.dart';
import 'package:Envelope/models/text_messages.dart';
import 'package:Envelope/utils/encryption/aes_helper.dart';
import 'package:Envelope/utils/encryption/crypto_utils.dart';
import 'package:Envelope/utils/strings.dart';
import 'package:pointycastle/pointycastle.dart'; import 'package:pointycastle/pointycastle.dart';
import 'package:uuid/uuid.dart';
import '/models/image_message.dart';
import '/models/conversation_users.dart';
import '/models/my_profile.dart';
import '/models/text_messages.dart';
import '/models/conversations.dart'; import '/models/conversations.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/database.dart'; import '/utils/storage/database.dart';
const messageTypeReceiver = 'receiver'; const messageTypeReceiver = 'receiver';
@ -29,6 +30,23 @@ Future<List<Message>> getMessagesForThread(Conversation conversation) async {
); );
return List.generate(maps.length, (i) { return List.generate(maps.length, (i) {
if (maps[i]['data'] == null) {
File file = File(maps[i]['file']);
return ImageMessage(
id: maps[i]['id'],
symmetricKey: maps[i]['symmetric_key'],
userSymmetricKey: maps[i]['user_symmetric_key'],
file: file,
senderId: maps[i]['sender_id'],
senderUsername: maps[i]['sender_username'],
associationKey: maps[i]['association_key'],
createdAt: maps[i]['created_at'],
failedToSend: maps[i]['failed_to_send'] == 1,
);
}
return TextMessage( return TextMessage(
id: maps[i]['id'], id: maps[i]['id'],
symmetricKey: maps[i]['symmetric_key'], symmetricKey: maps[i]['symmetric_key'],
@ -66,11 +84,11 @@ class Message {
Future<List<Map<String, String>>> payloadJsonBase( Future<List<Map<String, String>>> payloadJsonBase(
Uint8List symmetricKey, Uint8List symmetricKey,
Uint8List userSymmetricKey,
Conversation conversation, Conversation conversation,
String messageId, String messageId,
String messageDataId, String messageDataId,
) async { ) async {
MyProfile profile = await MyProfile.getProfile(); MyProfile profile = await MyProfile.getProfile();
if (profile.publicKey == null) { if (profile.publicKey == null) {
throw Exception('Could not get profile.publicKey'); throw Exception('Could not get profile.publicKey');
@ -78,8 +96,6 @@ class Message {
RSAPublicKey publicKey = profile.publicKey!; RSAPublicKey publicKey = profile.publicKey!;
final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32));
List<Map<String, String>> messages = []; List<Map<String, String>> messages = [];
List<ConversationUser> conversationUsers = await getConversationUsers(conversation); List<ConversationUser> conversationUsers = await getConversationUsers(conversation);
@ -87,8 +103,6 @@ class Message {
ConversationUser user = conversationUsers[i]; ConversationUser user = conversationUsers[i];
if (profile.id == user.userId) { if (profile.id == user.userId) {
id = user.id;
messages.add({ messages.add({
'id': messageId, 'id': messageId,
'message_data_id': messageDataId, 'message_data_id': messageDataId,
@ -121,4 +135,17 @@ class Message {
String getContent() { String getContent() {
return ''; return '';
} }
Map<String, dynamic> toMap() {
return {
'id': id,
'symmetric_key': symmetricKey,
'user_symmetric_key': userSymmetricKey,
'sender_id': senderId,
'sender_username': senderUsername,
'association_key': associationKey,
'created_at': createdAt,
'failed_to_send': failedToSend ? 1 : 0,
};
}
} }

+ 7
- 3
mobile/lib/models/text_messages.dart View File

@ -68,6 +68,7 @@ class TextMessage extends Message {
); );
} }
@override
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
@ -82,20 +83,23 @@ class TextMessage extends Message {
}; };
} }
Future<Map<String, dynamic>> payloadJson(Conversation conversation, String messageId) async {
Future<Map<String, dynamic>> payloadJson(Conversation conversation) async {
final String messageDataId = (const Uuid()).v4(); final String messageDataId = (const Uuid()).v4();
final symmetricKey = AesHelper.deriveKey(generateRandomString(32)); final symmetricKey = AesHelper.deriveKey(generateRandomString(32));
final userSymmetricKey = AesHelper.deriveKey(generateRandomString(32));
List<Map<String, String>> messages = await super.payloadJsonBase( List<Map<String, String>> messages = await super.payloadJsonBase(
symmetricKey, symmetricKey,
userSymmetricKey,
conversation, conversation,
messageId,
id,
messageDataId, messageDataId,
); );
Map<String, String> messageData = { Map<String, String> messageData = {
'id': messageDataId,
'id': id,
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)), 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(text.codeUnits)),
'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)), 'sender_id': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(senderId.codeUnits)),
'symmetric_key': AesHelper.aesEncrypt( 'symmetric_key': AesHelper.aesEncrypt(


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

@ -100,7 +100,7 @@ class AesHelper {
return base64.encode(cipherIvBytes); return base64.encode(cipherIvBytes);
} }
static String aesDecrypt(dynamic password, Uint8List ciphertext,
static Uint8List aesDecryptBytes(dynamic password, Uint8List ciphertext,
{String mode = cbcMode}) { {String mode = cbcMode}) {
Uint8List derivedKey; Uint8List derivedKey;
@ -136,7 +136,13 @@ class AesHelper {
Uint8List paddedText = _processBlocks(cipher, cipherBytes); Uint8List paddedText = _processBlocks(cipher, cipherBytes);
Uint8List textBytes = unpad(paddedText); 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) { static Uint8List _processBlocks(BlockCipher cipher, Uint8List inp) {


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

@ -63,6 +63,7 @@ Future<Database> getDatabaseConnection() async {
symmetric_key TEXT, symmetric_key TEXT,
user_symmetric_key TEXT, user_symmetric_key TEXT,
data TEXT, data TEXT,
file TEXT,
sender_id TEXT, sender_id TEXT,
sender_username TEXT, sender_username TEXT,
association_key TEXT, association_key TEXT,


+ 100
- 57
mobile/lib/utils/storage/messages.dart View File

@ -1,86 +1,122 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:Envelope/models/text_messages.dart';
import 'package:Envelope/models/messages.dart';
import 'package:Envelope/utils/storage/write_file.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '/models/image_message.dart';
import '/models/text_messages.dart';
import '/models/conversation_users.dart'; import '/models/conversation_users.dart';
import '/models/conversations.dart'; import '/models/conversations.dart';
import '/models/messages.dart';
import '/models/my_profile.dart'; import '/models/my_profile.dart';
import '/utils/storage/database.dart'; import '/utils/storage/database.dart';
import '/utils/storage/session_cookie.dart'; import '/utils/storage/session_cookie.dart';
Future<void> sendMessage(Conversation conversation, { String? data, List<File>? files }) async {
Future<void> sendMessage(Conversation conversation, {
String? data,
List<File> files = const []
}) async {
MyProfile profile = await MyProfile.getProfile(); MyProfile profile = await MyProfile.getProfile();
var uuid = const Uuid(); var uuid = const Uuid();
final String messageId = uuid.v4();
ConversationUser currentUser = await getConversationUser(conversation, profile.id); ConversationUser currentUser = await getConversationUser(conversation, profile.id);
List<Map<String, dynamic>> messagesToAdd = [];
List<Message> messages = [];
List<Map<String, dynamic>> messagesToSend = [];
final db = await getDatabaseConnection();
if (data != null) { if (data != null) {
messagesToAdd.add({ 'text': data });
}
TextMessage message = TextMessage(
id: uuid.v4(),
symmetricKey: '',
userSymmetricKey: '',
senderId: currentUser.userId,
senderUsername: profile.username,
associationKey: currentUser.associationKey,
createdAt: DateTime.now().toIso8601String(),
failedToSend: false,
text: data,
);
messages.add(message);
messagesToSend.add(await message.payloadJson(
conversation,
));
await db.insert(
'messages',
message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
if (files != null && files.isNotEmpty) {
for (File file in files) {
messagesToAdd.add({ 'file': file });
}
} }
var message = TextMessage(
id: messageId,
symmetricKey: '',
userSymmetricKey: '',
senderId: currentUser.userId,
senderUsername: profile.username,
text: data!,
associationKey: currentUser.associationKey,
createdAt: DateTime.now().toIso8601String(),
failedToSend: false,
);
for (File file in files) {
final db = await getDatabaseConnection();
String messageId = uuid.v4();
await db.insert(
'messages',
message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
File writtenFile = await writeImage(
messageId,
file.readAsBytesSync(),
);
ImageMessage message = ImageMessage(
id: messageId,
symmetricKey: '',
userSymmetricKey: '',
senderId: currentUser.userId,
senderUsername: profile.username,
associationKey: currentUser.associationKey,
createdAt: DateTime.now().toIso8601String(),
failedToSend: false,
file: writtenFile,
);
messages.add(message);
messagesToSend.add(await message.payloadJson(
conversation,
));
await db.insert(
'messages',
message.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
String sessionCookie = await getSessionCookie(); 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,
);
})
.then((resp) {
if (resp.statusCode != 200) {
throw Exception('Unable to send message');
}
})
.catchError((exception) {
message.failedToSend = true;
db.update(
'messages',
message.toMap(),
where: 'id = ?',
whereArgs: [message.id],
);
throw exception;
});
return http.post(
await MyProfile.getServerUrl('api/v1/auth/message'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie,
},
body: jsonEncode(messagesToSend),
)
.then((resp) {
if (resp.statusCode != 200) {
throw Exception('Unable to send message');
}
})
.catchError((exception) {
for (Message message in messages) {
message.failedToSend = true;
db.update(
'messages',
message.toMap(),
where: 'id = ?',
whereArgs: [message.id],
);
}
throw exception;
});
} }
Future<void> updateMessageThread(Conversation conversation, {MyProfile? profile}) async { Future<void> updateMessageThread(Conversation conversation, {MyProfile? profile}) async {
@ -103,10 +139,17 @@ Future<void> updateMessageThread(Conversation conversation, {MyProfile? profile}
final db = await getDatabaseConnection(); final db = await getDatabaseConnection();
for (var i = 0; i < messageThreadJson.length; i++) { for (var i = 0; i < messageThreadJson.length; i++) {
var message = TextMessage.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!, profile.privateKey!,
);
) :
TextMessage.fromJson(
messageJson,
profile.privateKey!,
);
ConversationUser messageUser = await getConversationUser(conversation, message.senderId); ConversationUser messageUser = await getConversationUser(conversation, message.senderId);
message.senderUsername = messageUser.username; message.senderUsername = messageUser.username;


+ 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;
}

+ 18
- 113
mobile/lib/views/main/conversation/detail.dart View File

@ -1,6 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'package:Envelope/models/image_message.dart';
import 'package:Envelope/models/text_messages.dart'; import 'package:Envelope/models/text_messages.dart';
import 'package:Envelope/views/main/conversation/message.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@ -87,38 +89,6 @@ class _ConversationDetailState extends State<ConversationDetail> {
fetchMessages(); 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() { Widget messagesView() {
if (messages.isEmpty) { if (messages.isEmpty) {
return const Center( return const Center(
@ -129,94 +99,29 @@ class _ConversationDetailState extends State<ConversationDetail> {
return ListView.builder( return ListView.builder(
itemCount: messages.length, itemCount: messages.length,
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.only(top: 10,bottom: 90),
padding: EdgeInsets.only(
top: 10,
bottom: selectedImages.isEmpty ? 90 : 160,
),
reverse: true, reverse: true,
itemBuilder: (context, index) { 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: messageContent(index),
),
const SizedBox(height: 1.5),
Row(
mainAxisAlignment: messages[index].senderUsername == profile.username ?
MainAxisAlignment.end :
MainAxisAlignment.start,
children: <Widget>[
const SizedBox(width: 10),
usernameOrFailedToSend(index),
],
),
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(),
],
)
),
return ConversationMessage(
message: messages[index],
profile: profile,
index: index,
); );
}, },
); );
} }
Widget messageContent(int index) {
return Text(
messages[index].getContent(),
style: TextStyle(
fontSize: 15,
color: messages[index].senderUsername == profile.username ?
Theme.of(context).colorScheme.onPrimary :
Theme.of(context).colorScheme.onTertiary,
)
);
}
Widget showSelectedImages() { Widget showSelectedImages() {
if (selectedImages.isEmpty) { if (selectedImages.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return SizedBox(
height: 80,
width: double.infinity,
return SizedBox(
height: 80,
width: double.infinity,
child: ListView.builder( child: ListView.builder(
itemCount: selectedImages.length, itemCount: selectedImages.length,
shrinkWrap: true, shrinkWrap: true,
@ -289,7 +194,7 @@ class _ConversationDetailState extends State<ConversationDetail> {
padding: const EdgeInsets.only(left: 10,bottom: 10,top: 10), padding: const EdgeInsets.only(left: 10,bottom: 10,top: 10),
width: double.infinity, width: double.infinity,
color: Theme.of(context).backgroundColor, color: Theme.of(context).backgroundColor,
child: Column(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -343,11 +248,11 @@ class _ConversationDetailState extends State<ConversationDetail> {
child: FittedBox( child: FittedBox(
child: FloatingActionButton( child: FloatingActionButton(
onPressed: () async { onPressed: () async {
if (msgController.text == '' || selectedImages.isEmpty) {
if (msgController.text == '' && selectedImages.isEmpty) {
return; return;
} }
await sendMessage( await sendMessage(
widget.conversation,
widget.conversation,
data: msgController.text != '' ? msgController.text : null, data: msgController.text != '' ? msgController.text : null,
files: selectedImages, files: selectedImages,
); );
@ -368,7 +273,7 @@ class _ConversationDetailState extends State<ConversationDetail> {
], ],
), ),
showFilePicker ?
showFilePicker ?
FilePicker( FilePicker(
cameraHandle: () {}, cameraHandle: () {},
galleryHandleMultiple: (List<XFile> images) async { galleryHandleMultiple: (List<XFile> images) async {
@ -380,7 +285,7 @@ class _ConversationDetailState extends State<ConversationDetail> {
}); });
}, },
fileHandle: () {}, fileHandle: () {},
) :
) :
const SizedBox.shrink(), const SizedBox.shrink(),
], ],
), ),


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

@ -0,0 +1,158 @@
import 'package:Envelope/components/view_image.dart';
import 'package:Envelope/models/image_message.dart';
import 'package:Envelope/models/my_profile.dart';
import 'package:Envelope/utils/time.dart';
import 'package:flutter/material.dart';
import '/models/messages.dart';
@immutable
class ConversationMessage extends StatelessWidget {
const ConversationMessage({
Key? key,
required this.message,
required this.profile,
required this.index,
}) : super(key: key);
final Message message;
final MyProfile profile;
final int index;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(left: 14,right: 14,top: 0,bottom: 0),
child: Align(
alignment: (
message.senderUsername == profile.username ?
Alignment.topRight :
Alignment.topLeft
),
child: Column(
crossAxisAlignment: message.senderUsername == profile.username ?
CrossAxisAlignment.end :
CrossAxisAlignment.start,
children: <Widget>[
messageContent(context),
const SizedBox(height: 1.5),
Row(
mainAxisAlignment: message.senderUsername == profile.username ?
MainAxisAlignment.end :
MainAxisAlignment.start,
children: <Widget>[
const SizedBox(width: 10),
usernameOrFailedToSend(index),
],
),
const SizedBox(height: 1.5),
Row(
mainAxisAlignment: message.senderUsername == profile.username ?
MainAxisAlignment.end :
MainAxisAlignment.start,
children: <Widget>[
const SizedBox(width: 10),
Text(
convertToAgo(message.createdAt),
textAlign: message.senderUsername == profile.username ?
TextAlign.left :
TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
index != 0 ?
const SizedBox(height: 20) :
const SizedBox.shrink(),
],
)
),
);
}
Widget messageContent(BuildContext context) {
if (message.runtimeType == ImageMessage) {
return GestureDetector(
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) {
return ViewImage(
message: (message as ImageMessage)
);
}));
},
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 350, maxWidth: 250),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.file(
(message as ImageMessage).file,
fit: BoxFit.fill,
),
),
),
);
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: (
message.senderUsername == profile.username ?
Theme.of(context).colorScheme.primary :
Theme.of(context).colorScheme.tertiary
),
),
padding: const EdgeInsets.all(12),
child: Text(
message.getContent(),
style: TextStyle(
fontSize: 15,
color: message.senderUsername == profile.username ?
Theme.of(context).colorScheme.onPrimary :
Theme.of(context).colorScheme.onTertiary,
),
),
);
}
Widget usernameOrFailedToSend(int index) {
if (message.senderUsername != profile.username) {
return Text(
message.senderUsername,
style: TextStyle(
fontSize: 12,
color: Colors.grey[300],
),
);
}
if (message.failedToSend) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: const <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();
}
}

+ 36
- 1
mobile/pubspec.lock View File

@ -233,6 +233,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.7.0" version: "1.7.0"
mime:
dependency: "direct main"
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
@ -240,6 +247,27 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.1" 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -247,6 +275,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.6" 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: path_provider_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -478,4 +513,4 @@ packages:
version: "0.2.0+1" version: "0.2.0+1"
sdks: sdks:
dart: ">=2.17.0 <3.0.0" dart: ">=2.17.0 <3.0.0"
flutter: ">=2.8.0"
flutter: ">=2.8.1"

+ 2
- 0
mobile/pubspec.yaml View File

@ -26,6 +26,8 @@ dependencies:
qr_code_scanner: ^1.0.1 qr_code_scanner: ^1.0.1
sliding_up_panel: ^2.0.0+1 sliding_up_panel: ^2.0.0+1
image_picker: ^0.8.5+3 image_picker: ^0.8.5+3
path_provider: ^2.0.11
mime: ^1.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:


Loading…
Cancel
Save