238 lines
6.6 KiB
Dart
238 lines
6.6 KiB
Dart
// Models for Secure E2EE Chat
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
/// Encrypted conversation metadata
|
|
class SecureConversation {
|
|
final String id;
|
|
final String participantA;
|
|
final String participantB;
|
|
final DateTime createdAt;
|
|
final DateTime lastMessageAt;
|
|
|
|
// Resolved participant info (loaded separately)
|
|
final String? otherUserHandle;
|
|
final String? otherUserDisplayName;
|
|
final String? otherUserAvatarUrl;
|
|
final int? unreadCount;
|
|
|
|
SecureConversation({
|
|
required this.id,
|
|
required this.participantA,
|
|
required this.participantB,
|
|
required this.createdAt,
|
|
required this.lastMessageAt,
|
|
this.otherUserHandle,
|
|
this.otherUserDisplayName,
|
|
this.otherUserAvatarUrl,
|
|
this.unreadCount,
|
|
});
|
|
|
|
factory SecureConversation.fromJson(
|
|
Map<String, dynamic> json, String currentUserId) {
|
|
final participantA = json['participant_a'] as String;
|
|
final participantB = json['participant_b'] as String;
|
|
final isParticipantA = currentUserId == participantA;
|
|
|
|
// Get the other participant's info if included
|
|
final otherProfile = isParticipantA
|
|
? json['participant_b_profile']
|
|
: json['participant_a_profile'];
|
|
|
|
return SecureConversation(
|
|
id: json['id'] as String,
|
|
participantA: participantA,
|
|
participantB: participantB,
|
|
createdAt: DateTime.parse(json['created_at'] as String),
|
|
lastMessageAt: DateTime.parse(json['last_message_at'] as String),
|
|
otherUserHandle: otherProfile?['handle'] as String?,
|
|
otherUserDisplayName: otherProfile?['display_name'] as String?,
|
|
otherUserAvatarUrl: otherProfile?['avatar_url'] as String?,
|
|
unreadCount: json['unread_count'] as int?,
|
|
);
|
|
}
|
|
|
|
String getOtherId(String currentUserId) {
|
|
return currentUserId == participantA ? participantB : participantA;
|
|
}
|
|
}
|
|
|
|
/// Encrypted message (what the server stores and returns)
|
|
class EncryptedMessage {
|
|
final String id;
|
|
final String conversationId;
|
|
final String senderId;
|
|
final String ciphertext;
|
|
final String iv;
|
|
final Object messageHeader;
|
|
final int messageType;
|
|
final DateTime createdAt;
|
|
final DateTime? deliveredAt;
|
|
final DateTime? readAt;
|
|
final DateTime? expiresAt;
|
|
|
|
// Decrypted content (populated client-side)
|
|
String? decryptedContent;
|
|
|
|
EncryptedMessage({
|
|
required this.id,
|
|
required this.conversationId,
|
|
required this.senderId,
|
|
required this.ciphertext,
|
|
required this.iv,
|
|
required this.messageHeader,
|
|
required this.messageType,
|
|
required this.createdAt,
|
|
this.deliveredAt,
|
|
this.readAt,
|
|
this.expiresAt,
|
|
this.decryptedContent,
|
|
});
|
|
|
|
factory EncryptedMessage.fromJson(Map<String, dynamic> json) {
|
|
final cipher = json['ciphertext'];
|
|
final ciphertext = cipher is String
|
|
? cipher
|
|
: cipher is List
|
|
? base64Encode(Uint8List.fromList(cipher.cast<int>()))
|
|
: '';
|
|
final iv = json['iv'] as String? ?? '';
|
|
final rawType = json['message_type'];
|
|
final parsedType = rawType is int
|
|
? rawType
|
|
: rawType is num
|
|
? rawType.toInt()
|
|
: int.tryParse(rawType?.toString() ?? '');
|
|
final header = json['message_header'];
|
|
final messageHeader = header is Map<String, dynamic>
|
|
? header
|
|
: header is String
|
|
? header
|
|
: '';
|
|
|
|
return EncryptedMessage(
|
|
id: json['id'] as String,
|
|
conversationId: json['conversation_id'] as String,
|
|
senderId: json['sender_id'] as String,
|
|
ciphertext: ciphertext,
|
|
iv: iv,
|
|
messageHeader: messageHeader,
|
|
messageType: parsedType ?? MessageType.standardMessage,
|
|
createdAt: DateTime.parse(json['created_at'] as String),
|
|
deliveredAt: json['delivered_at'] != null
|
|
? DateTime.parse(json['delivered_at'] as String)
|
|
: null,
|
|
readAt: json['read_at'] != null
|
|
? DateTime.parse(json['read_at'] as String)
|
|
: null,
|
|
expiresAt: json['expires_at'] != null
|
|
? DateTime.parse(json['expires_at'] as String)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
bool get isRead => readAt != null;
|
|
bool get isDelivered => deliveredAt != null;
|
|
bool get isExpired =>
|
|
expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
|
}
|
|
|
|
/// Message type constants
|
|
class MessageType {
|
|
static const int standardMessage = 1; // Normal user message
|
|
static const int commandMessage = 2; // System command (delete, etc.)
|
|
}
|
|
|
|
/// Command types for E2EE system commands
|
|
class CommandType {
|
|
static const String deleteMessage = 'command_delete_message';
|
|
static const String deleteConversation = 'command_delete_conversation';
|
|
static const String resyncRequest = 'command_resync_request';
|
|
static const String resyncPayload = 'command_resync_payload';
|
|
}
|
|
|
|
/// System command payload for E2EE commands
|
|
class E2EECommand {
|
|
final String type;
|
|
final Map<String, dynamic> payload;
|
|
|
|
E2EECommand({
|
|
required this.type,
|
|
required this.payload,
|
|
});
|
|
|
|
factory E2EECommand.deleteMessage(String targetMessageId) {
|
|
return E2EECommand(
|
|
type: CommandType.deleteMessage,
|
|
payload: {'target_message_id': targetMessageId},
|
|
);
|
|
}
|
|
|
|
factory E2EECommand.deleteConversation(String targetConversationId) {
|
|
return E2EECommand(
|
|
type: CommandType.deleteConversation,
|
|
payload: {'target_conversation_id': targetConversationId},
|
|
);
|
|
}
|
|
|
|
factory E2EECommand.resyncRequest(String conversationId, {int? limit}) {
|
|
return E2EECommand(
|
|
type: CommandType.resyncRequest,
|
|
payload: {
|
|
'conversation_id': conversationId,
|
|
if (limit != null) 'limit': limit,
|
|
},
|
|
);
|
|
}
|
|
|
|
factory E2EECommand.resyncPayload(
|
|
String conversationId,
|
|
List<Map<String, String>> messages,
|
|
) {
|
|
return E2EECommand(
|
|
type: CommandType.resyncPayload,
|
|
payload: {
|
|
'conversation_id': conversationId,
|
|
'messages': messages,
|
|
},
|
|
);
|
|
}
|
|
|
|
factory E2EECommand.fromJson(Map<String, dynamic> json) {
|
|
return E2EECommand(
|
|
type: json['type'] as String,
|
|
payload: Map<String, dynamic>.from(json['payload'] as Map),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'type': type,
|
|
'payload': payload,
|
|
};
|
|
|
|
String toJsonString() => jsonEncode(toJson());
|
|
|
|
static E2EECommand? tryParse(String content) {
|
|
try {
|
|
final json = jsonDecode(content) as Map<String, dynamic>;
|
|
if (json.containsKey('type') && json.containsKey('payload')) {
|
|
return E2EECommand.fromJson(json);
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Result of a delete operation
|
|
class DeleteResult {
|
|
final bool success;
|
|
final String? error;
|
|
final bool remoteWipeFailed;
|
|
|
|
DeleteResult({
|
|
required this.success,
|
|
this.error,
|
|
this.remoteWipeFailed = false,
|
|
});
|
|
}
|