sojorn/sojorn_app/lib/models/secure_chat.dart
2026-02-15 00:33:24 -06:00

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