import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:Capsule/models/messages.dart';
|
|
import 'package:Capsule/models/text_messages.dart';
|
|
import 'package:mime/mime.dart';
|
|
import 'package:pointycastle/export.dart';
|
|
import 'package:sqflite/sqflite.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../utils/storage/write_file.dart';
|
|
import '/models/conversation_users.dart';
|
|
import '/models/friends.dart';
|
|
import '/models/my_profile.dart';
|
|
import '/utils/encryption/aes_helper.dart';
|
|
import '/utils/encryption/crypto_utils.dart';
|
|
import '/utils/storage/database.dart';
|
|
import '/utils/strings.dart';
|
|
|
|
Future<Conversation> createConversation(String title, List<Friend> friends, bool twoUser) async {
|
|
final db = await getDatabaseConnection();
|
|
|
|
MyProfile profile = await MyProfile.getProfile();
|
|
|
|
var uuid = const Uuid();
|
|
final String conversationId = uuid.v4();
|
|
|
|
Uint8List symmetricKey = AesHelper.deriveKey(generateRandomString(32));
|
|
|
|
Conversation conversation = Conversation(
|
|
id: conversationId,
|
|
userId: profile.id,
|
|
symmetricKey: base64.encode(symmetricKey),
|
|
admin: true,
|
|
name: title,
|
|
twoUser: twoUser,
|
|
status: ConversationStatus.pending,
|
|
isRead: true,
|
|
messageExpiryDefault: 'no_expiry'
|
|
);
|
|
|
|
await db.insert(
|
|
'conversations',
|
|
conversation.toMap(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
|
|
await db.insert(
|
|
'conversation_users',
|
|
ConversationUser(
|
|
id: uuid.v4(),
|
|
userId: profile.id,
|
|
conversationId: conversationId,
|
|
username: profile.username,
|
|
associationKey: uuid.v4(),
|
|
publicKey: profile.publicKey!,
|
|
admin: true,
|
|
).toMap(),
|
|
conflictAlgorithm: ConflictAlgorithm.fail,
|
|
);
|
|
|
|
for (Friend friend in friends) {
|
|
await db.insert(
|
|
'conversation_users',
|
|
ConversationUser(
|
|
id: uuid.v4(),
|
|
userId: friend.friendId,
|
|
conversationId: conversationId,
|
|
username: friend.username,
|
|
associationKey: uuid.v4(),
|
|
publicKey: friend.publicKey,
|
|
admin: twoUser ? true : false,
|
|
).toMap(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
if (twoUser) {
|
|
List<Map<String, dynamic>> maps = await db.query(
|
|
'conversation_users',
|
|
where: 'conversation_id = ? AND user_id != ?',
|
|
whereArgs: [ conversation.id, profile.id ],
|
|
);
|
|
|
|
if (maps.length != 1) {
|
|
throw ArgumentError('Invalid user id');
|
|
}
|
|
|
|
conversation.name = maps[0]['username'];
|
|
|
|
await db.insert(
|
|
'conversations',
|
|
conversation.toMap(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
return conversation;
|
|
}
|
|
|
|
|
|
Future<Conversation> addUsersToConversation(Conversation conversation, List<Friend> friends) async {
|
|
final db = await getDatabaseConnection();
|
|
|
|
var uuid = const Uuid();
|
|
|
|
for (Friend friend in friends) {
|
|
await db.insert(
|
|
'conversation_users',
|
|
ConversationUser(
|
|
id: uuid.v4(),
|
|
userId: friend.friendId,
|
|
conversationId: conversation.id,
|
|
username: friend.username,
|
|
associationKey: uuid.v4(),
|
|
publicKey: friend.publicKey,
|
|
admin: false,
|
|
).toMap(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
return conversation;
|
|
}
|
|
|
|
Conversation findConversationByDetailId(List<Conversation> conversations, String id) {
|
|
for (var conversation in conversations) {
|
|
if (conversation.id == id) {
|
|
return conversation;
|
|
}
|
|
}
|
|
// Or return `null`.
|
|
throw ArgumentError.value(id, 'id', 'No element with that id');
|
|
}
|
|
|
|
Future<Conversation> getConversationById(String id) async {
|
|
final db = await getDatabaseConnection();
|
|
|
|
final List<Map<String, dynamic>> maps = await db.query(
|
|
'conversations',
|
|
where: 'id = ?',
|
|
whereArgs: [id],
|
|
);
|
|
|
|
if (maps.length != 1) {
|
|
throw ArgumentError('Invalid user id');
|
|
}
|
|
|
|
File? file;
|
|
if (maps[0]['file'] != null && maps[0]['file'] != '') {
|
|
file = File(maps[0]['file']);
|
|
}
|
|
|
|
return Conversation(
|
|
id: maps[0]['id'],
|
|
userId: maps[0]['user_id'],
|
|
symmetricKey: maps[0]['symmetric_key'],
|
|
admin: maps[0]['admin'] == 1,
|
|
name: maps[0]['name'],
|
|
twoUser: maps[0]['two_user'] == 1,
|
|
status: ConversationStatus.values[maps[0]['status']],
|
|
isRead: maps[0]['is_read'] == 1,
|
|
icon: file,
|
|
messageExpiryDefault: maps[0]['message_expiry'],
|
|
);
|
|
}
|
|
|
|
// A method that retrieves all the dogs from the dogs table.
|
|
Future<List<Conversation>> getConversations() async {
|
|
final db = await getDatabaseConnection();
|
|
|
|
final List<Map<String, dynamic>> maps = await db.query(
|
|
'conversations',
|
|
orderBy: 'name',
|
|
);
|
|
|
|
return List.generate(maps.length, (i) {
|
|
|
|
File? file;
|
|
if (maps[i]['file'] != null && maps[i]['file'] != '') {
|
|
file = File(maps[i]['file']);
|
|
}
|
|
|
|
return Conversation(
|
|
id: maps[i]['id'],
|
|
userId: maps[i]['user_id'],
|
|
symmetricKey: maps[i]['symmetric_key'],
|
|
admin: maps[i]['admin'] == 1,
|
|
name: maps[i]['name'],
|
|
twoUser: maps[i]['two_user'] == 1,
|
|
status: ConversationStatus.values[maps[i]['status']],
|
|
isRead: maps[i]['is_read'] == 1,
|
|
icon: file,
|
|
messageExpiryDefault: maps[i]['message_expiry'] ?? 'no_expiry',
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<Conversation?> getTwoUserConversation(String userId) async {
|
|
final db = await getDatabaseConnection();
|
|
|
|
MyProfile profile = await MyProfile.getProfile();
|
|
|
|
final List<Map<String, dynamic>> maps = await db.rawQuery(
|
|
'''
|
|
SELECT conversations.* FROM conversations
|
|
LEFT JOIN conversation_users ON conversation_users.conversation_id = conversations.id
|
|
WHERE conversation_users.user_id = ?
|
|
AND conversation_users.user_id != ?
|
|
AND conversations.two_user = 1
|
|
''',
|
|
[ userId, profile.id ],
|
|
);
|
|
|
|
if (maps.length != 1) {
|
|
return null;
|
|
}
|
|
|
|
return Conversation(
|
|
id: maps[0]['id'],
|
|
userId: maps[0]['user_id'],
|
|
symmetricKey: maps[0]['symmetric_key'],
|
|
admin: maps[0]['admin'] == 1,
|
|
name: maps[0]['name'],
|
|
twoUser: maps[0]['two_user'] == 1,
|
|
status: ConversationStatus.values[maps[0]['status']],
|
|
isRead: maps[0]['is_read'] == 1,
|
|
messageExpiryDefault: maps[0]['message_expiry'],
|
|
);
|
|
|
|
}
|
|
|
|
class Conversation {
|
|
String id;
|
|
String userId;
|
|
String symmetricKey;
|
|
bool admin;
|
|
String name;
|
|
bool twoUser;
|
|
ConversationStatus status;
|
|
bool isRead;
|
|
String messageExpiryDefault = 'no_expiry';
|
|
File? icon;
|
|
|
|
Conversation({
|
|
required this.id,
|
|
required this.userId,
|
|
required this.symmetricKey,
|
|
required this.admin,
|
|
required this.name,
|
|
required this.twoUser,
|
|
required this.status,
|
|
required this.isRead,
|
|
required this.messageExpiryDefault,
|
|
this.icon,
|
|
});
|
|
|
|
|
|
factory Conversation.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) {
|
|
var symmetricKeyDecrypted = CryptoUtils.rsaDecrypt(
|
|
base64.decode(json['symmetric_key']),
|
|
privKey,
|
|
);
|
|
|
|
var id = AesHelper.aesDecrypt(
|
|
symmetricKeyDecrypted,
|
|
base64.decode(json['conversation_detail_id']),
|
|
);
|
|
|
|
var admin = AesHelper.aesDecrypt(
|
|
symmetricKeyDecrypted,
|
|
base64.decode(json['admin']),
|
|
);
|
|
|
|
return Conversation(
|
|
id: id,
|
|
userId: json['user_id'],
|
|
symmetricKey: base64.encode(symmetricKeyDecrypted),
|
|
admin: admin == 'true',
|
|
name: 'Unknown',
|
|
twoUser: false,
|
|
status: ConversationStatus.complete,
|
|
isRead: true,
|
|
messageExpiryDefault: 'no_expiry',
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> payloadJson({ bool includeUsers = true }) async {
|
|
MyProfile profile = await MyProfile.getProfile();
|
|
|
|
var symKey = base64.decode(symmetricKey);
|
|
|
|
if (!includeUsers) {
|
|
return {
|
|
'id': id,
|
|
'name': AesHelper.aesEncrypt(symKey, Uint8List.fromList(name.codeUnits)),
|
|
'users': await getEncryptedConversationUsers(this, symKey),
|
|
};
|
|
}
|
|
|
|
List<ConversationUser> users = await getConversationUsers(this);
|
|
|
|
List<Object> userConversations = [];
|
|
|
|
for (ConversationUser user in users) {
|
|
|
|
RSAPublicKey pubKey = profile.publicKey!;
|
|
String newId = id;
|
|
|
|
if (profile.id != user.userId) {
|
|
Friend friend = await getFriendByFriendId(user.userId);
|
|
pubKey = friend.publicKey;
|
|
newId = (const Uuid()).v4();
|
|
}
|
|
|
|
userConversations.add({
|
|
'id': newId,
|
|
'user_id': user.userId,
|
|
'conversation_detail_id': AesHelper.aesEncrypt(symKey, Uint8List.fromList(id.codeUnits)),
|
|
'admin': AesHelper.aesEncrypt(symKey, Uint8List.fromList((user.admin ? 'true' : 'false').codeUnits)),
|
|
'symmetric_key': base64.encode(CryptoUtils.rsaEncrypt(symKey, pubKey)),
|
|
});
|
|
}
|
|
|
|
Map<String, dynamic> returnData = {
|
|
'id': id,
|
|
'name': AesHelper.aesEncrypt(symKey, Uint8List.fromList(name.codeUnits)),
|
|
'users': await getEncryptedConversationUsers(this, symKey),
|
|
'two_user': AesHelper.aesEncrypt(symKey, Uint8List.fromList((twoUser ? 'true' : 'false').codeUnits)),
|
|
'message_expiry': messageExpiryDefault,
|
|
'user_conversations': userConversations,
|
|
};
|
|
|
|
if (icon != null) {
|
|
returnData['attachment'] = {
|
|
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())),
|
|
'mimetype': lookupMimeType(icon!.path),
|
|
'extension': getExtension(icon!.path),
|
|
};
|
|
}
|
|
|
|
return returnData;
|
|
}
|
|
|
|
Map<String, dynamic> payloadImageJson() {
|
|
if (icon == null) {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())),
|
|
'mimetype': lookupMimeType(icon!.path),
|
|
'extension': getExtension(icon!.path),
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> toMap() {
|
|
return {
|
|
'id': id,
|
|
'user_id': userId,
|
|
'symmetric_key': symmetricKey,
|
|
'admin': admin ? 1 : 0,
|
|
'name': name,
|
|
'two_user': twoUser ? 1 : 0,
|
|
'status': status.index,
|
|
'is_read': isRead ? 1 : 0,
|
|
'file': icon != null ? icon!.path : null,
|
|
'message_expiry': messageExpiryDefault,
|
|
};
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return '''
|
|
|
|
|
|
id: $id
|
|
userId: $userId
|
|
name: $name
|
|
admin: $admin''';
|
|
}
|
|
|
|
Future<Message?> getRecentMessage() async {
|
|
final db = await getDatabaseConnection();
|
|
|
|
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
|
|
LIMIT 1;
|
|
''',
|
|
[ id ],
|
|
);
|
|
|
|
if (maps.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
return TextMessage(
|
|
id: maps[0]['id'],
|
|
symmetricKey: maps[0]['symmetric_key'],
|
|
userSymmetricKey: maps[0]['user_symmetric_key'],
|
|
text: maps[0]['data'] ?? 'Image',
|
|
senderId: maps[0]['sender_id'],
|
|
senderUsername: maps[0]['sender_username'],
|
|
associationKey: maps[0]['association_key'],
|
|
createdAt: maps[0]['created_at'],
|
|
failedToSend: maps[0]['failed_to_send'] == 1,
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
enum ConversationStatus {
|
|
complete,
|
|
pending,
|
|
error,
|
|
}
|