Saba-dart/lib/utils/secure_messaging_service.dart
2026-04-13 23:41:27 +03:30

1199 lines
41 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:another_telephony/telephony.dart';
import 'package:flutter/foundation.dart';
import 'contact_helper.dart';
import 'database_helper.dart';
import 'notification_helper.dart';
import 'protocol_helper.dart';
import 'secure_crypto_helper.dart';
class SecureMessagingService {
static final SecureMessagingService instance = SecureMessagingService._init();
final _crypto = SecureCryptoHelper();
final _db = DatabaseHelper.instance;
final _telephony = Telephony.instance;
static const int maxTextLimit = 5000;
static const int fragmentTimeoutMs = 45 * 60 * 1000;
static const int fragmentRetentionMs = 7 * 24 * 60 * 60 * 1000;
final _messageStreamController =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get messageStream =>
_messageStreamController.stream;
final Set<String> _activeOutgoingPacketIds = {};
String? currentChatPhone;
bool _isInitialized = false;
bool _listenerRegistered = false;
SecureMessagingService._init();
Future<void> init() async {
if (_isInitialized) return;
await NotificationHelper.instance.init();
await _ensureIdentity();
await _ensureInstallDate();
if (!_listenerRegistered) {
_setupGlobalListener();
_listenerRegistered = true;
}
_isInitialized = true;
}
Future<void> resetForFreshInstall() async {
currentChatPhone = null;
_isInitialized = false;
await _db.resetAppData();
}
Future<int?> getInstallDate() async {
final val = await _db.getSetting('install_date');
if (val == null) return null;
return int.tryParse(val);
}
Future<void> _ensureInstallDate() async {
final existing = await _db.getSetting('install_date');
if (existing == null) {
final now = DateTime.now().millisecondsSinceEpoch.toString();
await _db.setSetting('install_date', now);
}
}
Future<void> _ensureIdentity() async {
final existing = await _db.getIdentity();
if (existing != null) {
try {
final privateBytes = _crypto.b64uDecode(existing['privateKey']!);
if (privateBytes.length != 32) {
throw StateError(
'Local P-256 private key must contain 32 bytes.',
);
}
final publicBytes = _crypto.decodePublicKey(existing['publicKey']!);
if (publicBytes.length != 64) {
throw StateError(
'Local P-256 public key must contain 64 bytes.',
);
}
return;
} catch (e) {
debugPrint('[ECC] Local identity is invalid; regenerating. $e');
}
}
final identity = await _crypto.generateIdentity();
await _db.saveIdentity(
privateKey: identity.privateKey,
publicKey: identity.publicKey,
fingerprint: identity.fingerprint,
);
}
void _setupGlobalListener() {
_telephony.listenIncomingSms(
onNewMessage: _handleOnNewMessage,
onBackgroundMessage: backgroundMessageHandler,
listenInBackground: true,
);
}
static void _handleOnNewMessage(SmsMessage message) {
instance._processAndBroadcast(message);
}
String? _packetModeForType(String type) {
switch (type) {
case 'sym':
case 'sfra':
return 'SYM';
case 'asym':
case 'afrag':
return 'AE';
default:
return null;
}
}
bool _isControlProtocolType(String type) {
return type == 'key_init' || type == 'key_reply' || type == 'norm';
}
Map<String, dynamic> _buildStreamEvent(
String phone,
String originalBody,
String? processedBody, {
bool isMe = false,
required int date,
}) {
final parsed = ProtocolHelper.parseMessage(originalBody);
final type = parsed['type'] as String? ?? 'plain';
final packetId = parsed['packetId'] as String?;
final packetMode = _packetModeForType(type);
final effectiveBody = processedBody ?? originalBody;
final isMultipartPending = packetId != null &&
packetMode != null &&
effectiveBody.contains('در حال');
final isMultipartComplete = packetId != null &&
packetMode != null &&
!isMultipartPending &&
effectiveBody != originalBody &&
effectiveBody != 'REFRESH';
return {
'phone': phone,
'body': effectiveBody,
'originalBody': originalBody,
'packetId': packetId,
'packetMode': packetMode,
'date': date,
'isMultipartPending': isMultipartPending,
'isMultipartComplete': isMultipartComplete,
'isMe': isMe,
};
}
Future<void> _processAndBroadcast(SmsMessage message) async {
final phone = ContactHelper.normalizePhone(message.address ?? "");
final body = message.body ?? "";
final parsed = ProtocolHelper.parseMessage(body);
final type = parsed['type'] as String? ?? 'plain';
final packetId = parsed['packetId'] as String?;
final packetMode = _packetModeForType(type);
final isControlProtocol = _isControlProtocolType(type);
// Persist to local cache immediately so it survives screen re-entry (loadMessages)
final savedId = await _db.saveSingleSmsToCache({
'sms_id': message.id,
'address': phone,
'body': body,
'date': message.date ?? DateTime.now().millisecondsSinceEpoch,
'type': 1, // Received
'is_me': 0,
'is_read': ((currentChatPhone == phone) || isControlProtocol)
? 1
: 0, // Control protocol messages never count as unread
'is_secure': type != 'plain' ? 1 : 0,
'packet_id': packetId,
'packet_mode': packetMode,
});
// Always attempt to process (handle fragments, decryption, etc.)
final processedBody = await processIncomingSms(phone, body);
if (processedBody != null &&
processedBody != body &&
processedBody != 'REFRESH' &&
savedId > 0) {
// Update the cache with the decrypted/processed version and mark as secure
await _db.updateSmsBody(savedId, processedBody,
isSecure: 1, packetId: packetId, packetMode: packetMode);
}
if (isControlProtocol) {
_messageStreamController.add({'phone': phone, 'body': 'REFRESH'});
return;
}
// If the message is an echo and we couldn't process it further than the raw string,
// skip broadcasting to avoid UI flickering (e.g. replacing plain text with raw protocol strings).
final isMeEcho = (message.type == SmsType.MESSAGE_TYPE_SENT) ||
(packetId != null && _activeOutgoingPacketIds.contains(packetId));
if (processedBody == null && isMeEcho) {
return;
}
final event = _buildStreamEvent(
phone,
body,
processedBody,
isMe: isMeEcho,
date: message.date ?? DateTime.now().millisecondsSinceEpoch,
);
// Broadcast to the stream so the UI can react immediately
_messageStreamController.add(event);
// Handle notifications if the chat is not currently open for this contact
if (currentChatPhone != phone) {
final name = ContactHelper.getName(phone);
final notificationBody =
(processedBody != null) ? (event['body'] as String) : body;
await NotificationHelper.instance.showNotification(
id: phone.hashCode,
title: name,
body: notificationBody,
payload: phone,
);
}
}
Future<bool> isAsymmetricReady(String phone) async {
final contact = await _db.getContact(phone);
final secureState = contact?['secure_state'] as String? ?? 'none';
final verificationState =
contact?['verification_state'] as String? ?? 'unverified';
return (contact?['ecc_shared_key'] as String?)?.isNotEmpty == true &&
(contact?['peer_public_key'] as String?)?.isNotEmpty == true &&
secureState == 'ready' &&
verificationState == 'verified';
}
Future<Map<String, dynamic>?> getSecurityContext(String phone) async {
await _ensureIdentity();
final identity = await _db.getIdentity();
final contact = await _db.getContact(phone);
if (identity == null) return null;
return {
'localFingerprint': identity['fingerprint'],
'peerFingerprint': contact?['peer_fingerprint'],
'secureState': contact?['secure_state'] ?? 'none',
'verificationState': contact?['verification_state'] ?? 'unverified',
'mode': contact?['mode'] ?? 'normal',
'hasPeerKey':
(contact?['peer_public_key'] as String?)?.isNotEmpty == true,
};
}
Future<void> resetAsymmetricSecurity(String phone) async {
final normalizedPhone = ContactHelper.normalizePhone(phone);
await _db.resetAsymmetricSecurity(normalizedPhone);
_messageStreamController.add({'phone': normalizedPhone, 'body': 'REFRESH'});
}
Future<List<Map<String, dynamic>>> getPendingPacketsForPhone(String phone) {
return _db.getPendingPacketsForPhone(phone);
}
Future<void> cleanupFragmentBuffer(String phone) async {
final retentionCutoff =
DateTime.now().millisecondsSinceEpoch - fragmentRetentionMs;
await _db.clearCompletedPackets(phone);
await _db.clearStalePendingPackets(
phone,
olderThanMs: retentionCutoff,
);
}
Future<void> markFingerprintVerified(String phone) async {
final normalizedPhone = ContactHelper.normalizePhone(phone);
final contact = await _db.getContact(normalizedPhone);
final hasAsymmetricMaterial =
(contact?['ecc_shared_key'] as String?)?.isNotEmpty == true &&
(contact?['peer_public_key'] as String?)?.isNotEmpty == true;
await _db.updateContactSecurity(
normalizedPhone,
verificationState: 'verified',
verifiedAt: DateTime.now().millisecondsSinceEpoch,
state: hasAsymmetricMaterial
? 'ready'
: (contact?['secure_state'] as String?),
mode: hasAsymmetricMaterial ? 'asymmetric' : null,
);
_messageStreamController.add({'phone': normalizedPhone, 'body': 'REFRESH'});
}
Future<Map<String, dynamic>> startAsymmetricHandshake(String phone) async {
await _ensureIdentity();
final normalizedPhone = ContactHelper.normalizePhone(phone);
final identity = await _db.getIdentity();
if (identity == null) {
throw StateError('Identity is not available.');
}
await _db.updateContactSecurity(
normalizedPhone,
state: 'handshake_pending',
mode: 'asymmetric',
);
final publicKeyTransport =
_crypto.publicKeyTransport(identity['publicKey']!);
await _telephony.sendSms(
to: normalizedPhone,
message: ProtocolHelper.buildKeyInit(publicKeyTransport),
);
_messageStreamController.add({'phone': normalizedPhone, 'body': 'REFRESH'});
return {
'sentText': false,
'notice': 'در حال تبادل کلید عمومی...',
};
}
Future<Map<String, dynamic>> sendMessage(
String phone,
String text, {
required String securityLevel,
String? symmetricKey,
String? forcedPacketId,
int? localTimestamp,
}) async {
final normalizedPhone = ContactHelper.normalizePhone(phone);
final messageTimestamp =
localTimestamp ?? DateTime.now().millisecondsSinceEpoch;
if (text.length > maxTextLimit) {
throw "طول پیام نباید بیشتر از $maxTextLimit کاراکتر باشد.";
}
if (securityLevel == 'normal') {
await _telephony.sendSms(to: normalizedPhone, message: text);
await _db.saveSingleSmsToCache({
'address': normalizedPhone,
'body': text,
'date': messageTimestamp,
'type': 2,
'is_me': 1,
});
return {'sentText': true, 'notice': 'sent'};
}
if (securityLevel == 'symmetric') {
final effectiveKey = (symmetricKey ?? '').trim();
if (effectiveKey.isEmpty) {
return {
'sentText': false,
'notice': 'کلید متقارن برای این مخاطب تنظیم نشده است.'
};
}
await _db.updateContactSecurity(
normalizedPhone,
symmetricKey: effectiveKey,
mode: 'symmetric',
state: 'ready',
);
final encrypted = await _crypto.encryptSymmetric(text, effectiveKey);
final cleanEncrypted = encrypted.replaceAll(RegExp(r'\s+'), '');
await _db.saveDecrypted(
cleanEncrypted, text); // Cache for instant recovery
final protocolMsg = ProtocolHelper.buildSymmetricMsg(encrypted);
if (protocolMsg.length <= ProtocolHelper.maxSmsChars) {
await _telephony.sendSms(to: normalizedPhone, message: protocolMsg);
// Cache immediately to prevent disappearing bubble during UI refreshes
await _db.saveSingleSmsToCache({
'address': normalizedPhone,
'body': protocolMsg,
'date': messageTimestamp,
'type': 2, // Sent
'is_me': 1,
'is_secure': 1,
'packet_id': forcedPacketId,
'packet_mode': 'SYM',
});
return {
'sentText': true,
'notice': 'sent',
'packetId': forcedPacketId,
'packetMode': 'SYM',
};
}
final packetId = await _sendLegacyFragmented(normalizedPhone, encrypted,
forcedPacketId: forcedPacketId);
return {
'sentText': true,
'notice': 'sent_fragmented',
'packetId': packetId,
'packetMode': 'SYM',
};
}
final contact = await _db.getContact(normalizedPhone);
final secureState = contact?['secure_state'] as String? ?? 'none';
final verificationState =
contact?['verification_state'] as String? ?? 'unverified';
final hasPeerKey =
(contact?['peer_public_key'] as String?)?.isNotEmpty == true;
final sharedKeyEncoded = contact?['ecc_shared_key'] as String?;
final hasSharedKey = (sharedKeyEncoded?.isNotEmpty ?? false);
if (!await isAsymmetricReady(normalizedPhone)) {
if (verificationState == 'changed') {
return {
'sentText': false,
'notice':
'کلید مخاطب تغییر کرده است؛ ابتدا اثر انگشت جدید را دوباره تایید کنید.',
};
}
if (hasPeerKey && hasSharedKey) {
return {
'sentText': false,
'notice':
'کلید مخاطب دریافت شده اما هنوز تایید نشده است؛ ابتدا اثر انگشت را تطبیق و تایید کنید.',
};
}
if (secureState == 'handshake_pending') {
return {
'sentText': false,
'notice': 'تبادل کلید هنوز کامل نشده است.',
};
}
return startAsymmetricHandshake(normalizedPhone);
}
if (verificationState == 'changed') {
return {
'sentText': false,
'notice':
'کلید مخاطب تغییر کرده است؛ ابتدا اثر انگشت جدید را دوباره تایید کنید.',
};
}
if (sharedKeyEncoded == null || sharedKeyEncoded.isEmpty) {
return startAsymmetricHandshake(normalizedPhone);
}
final sharedKey = _crypto.b64uDecode(sharedKeyEncoded);
final encrypted = await _crypto.encryptWithSharedKey(text, sharedKey);
final cleanEncrypted = encrypted.replaceAll(RegExp(r'\s+'), '');
await _db.saveDecrypted(cleanEncrypted, text); // Cache for instant recovery
final frames =
ProtocolHelper.buildAsymmetricFrames(_crypto.b64uDecode(encrypted));
final fullProtocolBody = ProtocolHelper.buildAsymmetricMsg(encrypted);
if (frames.length == 1) {
await _db.saveSingleSmsToCache({
'address': normalizedPhone,
'body': fullProtocolBody,
'date': messageTimestamp,
'type': 2,
'is_me': 1,
'is_secure': 1,
'packet_id': forcedPacketId,
'packet_mode': 'AE',
});
}
final packetId = await _sendFrames(normalizedPhone, frames,
securePrefix: 'ECC', forcedPacketId: forcedPacketId);
await _db.updateContactSecurity(normalizedPhone,
mode: 'asymmetric', state: 'ready');
return {
'sentText': true,
'notice': frames.length > 1 ? 'sent_fragmented' : 'sent',
'packetId': packetId,
'packetMode': 'AE',
'protocolBody': fullProtocolBody,
'encryptedPayload': cleanEncrypted,
};
}
Future<void> sendSecureMessage(String phone, String text,
{String? symmetricKey}) async {
await sendMessage(
phone,
text,
securityLevel: symmetricKey != null && symmetricKey.trim().isNotEmpty
? 'symmetric'
: 'normal',
symmetricKey: symmetricKey,
);
}
Future<String?> getEncryptedPreview(String text, String symmetricKey) async {
final encrypted = await _crypto.encryptSymmetric(text, symmetricKey);
return ProtocolHelper.buildSymmetricMsg(encrypted);
}
Future<String?> _sendFrames(String phone, List<String> frames,
{required String securePrefix, String? forcedPacketId}) async {
final packetId = forcedPacketId ??
Random().nextInt(0xFFFFFFFF).toRadixString(16).padLeft(10, '0');
if (frames.length > 1) {
_activeOutgoingPacketIds.add(packetId);
}
for (var i = 0; i < frames.length; i++) {
await _telephony.sendSms(to: phone, message: frames[i]);
if (frames.length > 1) {
final parsed = ProtocolHelper.parseMessage(frames[i]);
await _db.saveFragment(
phone,
parsed['packetId'] ?? packetId,
parsed['partNo'] ?? (i + 1),
parsed['totalParts'] ?? frames.length,
parsed['chunk'] ?? frames[i],
packetMode: 'AE',
isMe: true,
);
_messageStreamController.add({
'phone': phone,
'body':
'$securePrefix در حال ارسال بخش ${i + 1} از ${frames.length}...',
'originalBody': 'SFRA_PART',
'packetId': parsed['packetId'] ?? packetId,
'packetMode': 'AE',
'isMultipartPending': true,
'isMultipartComplete': false,
'receivedParts': i + 1,
'totalParts': frames.length,
'isMe': true,
});
await Future.delayed(const Duration(milliseconds: 2200));
}
}
if (frames.length > 1) {
// Keep it for a bit longer to catch late echoes, then remove
Future.delayed(const Duration(seconds: 10), () {
_activeOutgoingPacketIds.remove(packetId);
});
}
_messageStreamController.add({'phone': phone, 'body': 'REFRESH'});
return frames.length > 1 ? packetId : null;
}
Future<String?> _sendLegacyFragmented(String phone, String encryptedPayload,
{bool isGroup = false, String? forcedPacketId}) async {
final packetId = forcedPacketId ??
Random().nextInt(0xFFFFFFFF).toRadixString(16).padLeft(10, '0');
const chunkSize = 90;
final total = (encryptedPayload.length / chunkSize).ceil();
_activeOutgoingPacketIds.add(packetId);
try {
for (var i = 0; i < total; i++) {
final start = i * chunkSize;
final end = min(start + chunkSize, encryptedPayload.length);
final chunk = encryptedPayload.substring(start, end);
final frame = isGroup
? ('${ProtocolHelper.gPrefix}${ProtocolHelper.typeSfra}|$packetId|${i + 1}|$total|$chunk')
: ProtocolHelper.buildSymmetricFrag(packetId, i + 1, total, chunk);
await _telephony.sendSms(to: phone, message: frame);
await _db.saveFragment(phone, packetId, i + 1, total, chunk,
packetMode: 'SYM', isMe: true);
_messageStreamController.add({
'phone': phone,
'body': 'در حال ارسال بخش ${i + 1} از $total...',
'originalBody': 'SFRA_PART',
'packetId': packetId,
'packetMode': 'SYM',
'isMultipartPending': true,
'isMultipartComplete': false,
'receivedParts': i + 1,
'totalParts': total,
'isMe': true,
});
// Reduce delay from 2200ms to 1200ms for better UX
await Future.delayed(const Duration(milliseconds: 1200));
}
} finally {
Future.delayed(const Duration(seconds: 10), () {
_activeOutgoingPacketIds.remove(packetId);
});
}
_messageStreamController.add({'phone': phone, 'body': 'REFRESH'});
return total > 1 ? packetId : null;
}
Future<String?> resolvePayloadForRetry(
String rawMessage, {
String? phone,
String? hintedPayload,
}) async {
if (hintedPayload != null && hintedPayload.trim().isNotEmpty) {
return hintedPayload.trim();
}
final parsed = ProtocolHelper.parseMessage(rawMessage);
final directPayload = parsed['payload'] as String?;
if (directPayload != null && directPayload.trim().isNotEmpty) {
return directPayload.trim();
}
final type = parsed['type'] as String? ?? 'plain';
final packetId = parsed['packetId'] as String?;
if ((type != 'sfra' && type != 'afrag') ||
packetId == null ||
phone == null) {
return rawMessage.trim().isEmpty ? null : rawMessage.trim();
}
final fragments = await _db.getFragments(phone, packetId);
if (fragments.isEmpty) {
final chunk = parsed['chunk'] as String?;
return chunk?.trim().isEmpty == false ? chunk!.trim() : null;
}
final sorted = List<Map<String, dynamic>>.from(fragments)
..sort(
(a, b) => (a['frag_index'] as int).compareTo(b['frag_index'] as int));
if (type == 'sfra') {
return sorted
.map((fragment) => (fragment['body'] as String?) ?? '')
.join()
.trim();
}
final payloadBytes = <int>[];
for (final fragment in sorted) {
final chunk = fragment['body'] as String? ?? '';
if (chunk.isEmpty) continue;
payloadBytes.addAll(_crypto.b64uDecode(chunk));
}
return payloadBytes.isEmpty ? null : _crypto.b64uEncode(payloadBytes);
}
Future<String?> decryptWithKey(
String rawMessage,
String key, {
String? phone,
String? hintedPayload,
}) async {
final actualPayload = await resolvePayloadForRetry(
rawMessage,
phone: phone,
hintedPayload: hintedPayload,
);
if (actualPayload == null || actualPayload.isEmpty) {
return null;
}
final decrypted = await _crypto.decryptSymmetric(actualPayload, key);
if (decrypted != null) {
await _db.saveDecrypted(actualPayload, decrypted);
}
return decrypted;
}
Future<String?> processIncomingSms(
String phone,
String body, {
bool broadcast = true,
bool isMe = false,
bool isScan = false,
}) async {
await _ensureIdentity();
final parsed = ProtocolHelper.parseMessage(body);
final type = parsed['type'];
final packetId = parsed['packetId'] as String?;
// If we are currently sending this packet, this "incoming" SMS is likely an echo of our own sent message
bool effectiveIsMe = isMe;
if (!effectiveIsMe &&
packetId != null &&
_activeOutgoingPacketIds.contains(packetId)) {
effectiveIsMe = true;
}
debugPrint(
'[CRYPTO] Incoming SMS from $phone: type=$type, isMe=$effectiveIsMe, packetId=$packetId');
if (type == 'plain') return body;
if (parsed['isGroup'] == true) {
if (type == 'sfra') {
return _handleLegacyFrag(phone, parsed,
broadcast: broadcast, isMe: effectiveIsMe, isScan: isScan);
}
return _handleGroupMsg(phone, body, type, parsed, isMe: effectiveIsMe);
}
switch (type) {
case 'norm':
return 'بازگشت به حالت عادی برای این گفتگو دریافت شد.';
case 'sym':
return _handleSymmetricMsg(phone, parsed['payload'],
isMe: effectiveIsMe);
case 'sfra':
return _handleLegacyFrag(phone, parsed,
broadcast: broadcast, isMe: effectiveIsMe, isScan: isScan);
case 'key_init':
await _handleKeyExchange(phone, parsed['payload'], shouldReply: true);
return null;
case 'key_reply':
await _handleKeyExchange(phone, parsed['payload'], shouldReply: false);
return null;
case 'asym':
return _handleAsymmetricMsg(phone, parsed['payload'],
isMe: effectiveIsMe);
case 'afrag':
return _handleAsymmetricFrag(phone, parsed,
broadcast: broadcast, isMe: effectiveIsMe, isScan: isScan);
default:
return body;
}
}
Future<void> _handleKeyExchange(String phone, String peerPublicKey,
{required bool shouldReply}) async {
try {
final identity = await _db.getIdentity();
if (identity == null) return;
final contact = await _db.getContact(phone);
final secureState = contact?['secure_state'] as String? ?? 'none';
if (!shouldReply && secureState != 'handshake_pending') {
debugPrint('[ECC] Ignoring unexpected key reply for $phone.');
return;
}
final existingPublicKey = contact?['peer_public_key'] as String?;
String verificationState =
contact?['verification_state'] as String? ?? 'unverified';
if (existingPublicKey != null &&
existingPublicKey.isNotEmpty &&
existingPublicKey != peerPublicKey) {
verificationState = 'changed';
} else if (verificationState != 'verified') {
verificationState = 'unverified';
}
final sharedKey = await _crypto.deriveSharedKey(
privateKey: identity['privateKey']!,
localPublicKey: identity['publicKey']!,
publicKey: peerPublicKey,
);
final peerPublicBytes = _crypto.decodePublicKey(peerPublicKey);
final nextSecureState =
verificationState == 'verified' ? 'ready' : 'pending_verification';
await _db.updateContactSecurity(
phone,
publicKey: peerPublicKey,
peerFingerprint: _crypto.fingerprintFromPublicBytes(peerPublicBytes),
eccSharedKey: _crypto.b64uEncode(sharedKey),
state: nextSecureState,
mode: 'asymmetric',
verificationState: verificationState,
);
if (shouldReply) {
final publicKeyTransport =
_crypto.publicKeyTransport(identity['publicKey']!);
await _telephony.sendSms(
to: phone,
message: ProtocolHelper.buildKeyReply(publicKeyTransport));
}
_messageStreamController.add({'phone': phone, 'body': 'REFRESH'});
} catch (e) {
debugPrint('Key exchange failed for $phone: $e');
await _db.updateContactSecurity(
phone,
state: 'handshake_failed',
mode: 'asymmetric',
);
_messageStreamController.add({'phone': phone, 'body': 'REFRESH'});
}
}
Future<String?> _handleSymmetricMsg(String phone, String payload,
{String? symmetricKey, bool isMe = false}) async {
final cleanPayload = payload.replaceAll(RegExp(r'\s+'), '');
final cached = await _db.getDecrypted(cleanPayload);
if (cached != null) return cached;
final contact = await _db.getContact(phone);
final savedKey = symmetricKey ?? contact?['symmetric_key'] as String?;
if (savedKey == null || savedKey.isEmpty) {
if (isMe) return null; // Don't show "Locked" for our own echoed message
final label = isMe ? 'ارسال شده' : 'دریافت شد';
return 'پیام امن متقارن $label. برای بازگشایی، کلید متقارن را وارد کنید. ::PAYLOAD::$payload';
}
final decrypted = await _crypto.decryptSymmetric(payload, savedKey);
if (decrypted == null) {
if (isMe) return null; // Skip placeholder for our own failed echo
final label = isMe ? 'ارسال شده' : 'دریافت شد';
return 'پیام امن متقارن $label. برای بازگشایی، کلید متقارن را وارد کنید. ::PAYLOAD::$payload';
}
await _db.saveDecrypted(cleanPayload, decrypted);
return decrypted;
}
Future<String?> _handleAsymmetricMsg(String phone, String payload,
{bool isMe = false}) async {
final cleanPayload = payload.replaceAll(RegExp(r'\s+'), '');
final cached = await _db.getDecrypted(cleanPayload);
if (cached != null) return cached;
final contact = await _db.getContact(phone);
final sharedKeyEncoded = contact?['ecc_shared_key'] as String?;
if (sharedKeyEncoded == null || sharedKeyEncoded.isEmpty) {
if (isMe) return null;
final label = isMe ? 'ارسال شده' : 'دریافت شد';
return 'پیام ${'رمزنگاری غیر متقارن (طولانی‌تر و امن‌تر)'} $label اما تبادل کلید هنوز کامل نشده است. ::PAYLOAD::$payload';
}
final decrypted = await _crypto.decryptWithSharedKey(
cleanPayload, _crypto.b64uDecode(sharedKeyEncoded));
if (decrypted == null) {
if (isMe) return null;
return 'بازگشایی پیام ECC ناموفق بود. اثر انگشت یا تبادل کلید را بررسی کنید. ::PAYLOAD::$cleanPayload';
}
await _db.saveDecrypted(cleanPayload, decrypted);
return decrypted;
}
bool _isPacketExpired(List<Map<String, dynamic>> fragments) {
if (fragments.isEmpty) return false;
final lastSeen = fragments
.map((f) => (f['updated_at'] ?? f['date'] ?? 0) as int)
.fold<int>(0, max);
return DateTime.now().millisecondsSinceEpoch - lastSeen > fragmentTimeoutMs;
}
Future<String?> _handleLegacyFrag(
String phone,
Map<String, dynamic> frag, {
bool broadcast = true,
bool isMe = false,
bool isScan = false,
}) async {
debugPrint(
'[CRYPTO] Handling legacy fragment: id=${frag['packetId']}, part=${frag['partNo']}/${frag['totalParts']}, len=${frag['chunk']?.length}');
await _db.saveFragment(phone, frag['packetId'], frag['partNo'],
frag['totalParts'], frag['chunk'],
packetMode: 'SYM');
final fragments = await _db.getFragments(phone, frag['packetId']);
debugPrint(
'[CRYPTO] Fragment Status for ${frag['packetId']}: owned=${fragments.length}, total=${frag['totalParts']}');
if (fragments.length == frag['totalParts']) {
final sorted = List<Map<String, dynamic>>.from(fragments)
..sort((a, b) =>
(a['frag_index'] as int).compareTo(b['frag_index'] as int));
final payload = sorted.map((f) => f['body'] as String).join().trim();
debugPrint(
'[CRYPTO] Packet ${frag['packetId']} reassembled. Total payload length: ${payload.length}');
if (!isScan) {
await _db.clearFragments(phone, frag['packetId']);
}
if (frag['isGroup'] == true) {
final fullMsg =
'${ProtocolHelper.gPrefix}${ProtocolHelper.typeSym}|$payload';
final result = await _handleGroupMsg(phone, fullMsg, "gsym", frag);
// Delete individual fragments from group_messages
final groups = await _db.getGroupsContainingPhone(phone);
for (var g in groups) {
await _db.deleteGroupFragments(g['id'] as int, frag['packetId']);
}
if (broadcast) {
_messageStreamController.add({
'phone': phone,
'body': result ?? 'REFRESH',
'originalBody': payload,
'packetId': frag['packetId'],
'packetMode': 'SYM',
'isMultipartComplete': true,
'isMe': isMe,
});
}
return result;
}
final result = await _handleSymmetricMsg(phone, payload, isMe: isMe);
final eventBody = result ?? ProtocolHelper.buildSymmetricMsg(payload);
final eventDate = DateTime.now().millisecondsSinceEpoch;
await _db.consolidatePacketMessage(
phone,
packetId: frag['packetId'] as String,
packetMode: 'SYM',
body: eventBody,
date: eventDate,
type: isMe ? 2 : 1,
isMe: isMe ? 1 : 0,
isRead: isMe ? 1 : 1,
isSecure: 1,
);
if (broadcast) {
_messageStreamController.add({
'phone': phone,
'body': eventBody,
'originalBody': ProtocolHelper.buildSymmetricMsg(payload),
'packetId': frag['packetId'],
'packetMode': 'SYM',
'date': eventDate,
'isMultipartPending': false,
'isMultipartComplete': true,
'isMe': isMe,
});
}
return result;
}
final action = isMe ? 'ارسال' : 'دریافت';
final expired = _isPacketExpired(fragments);
final status = expired
? 'باقی قطعات پیام نرسیده‌اند (زمان تمام شد)'
: 'در حال $action قطعات... (${fragments.length}/${frag['totalParts']})';
if (broadcast) {
_messageStreamController.add({
'phone': phone,
'body': status,
'originalBody': 'SFRA_PART',
'isMe': isMe,
'packetId': frag['packetId'],
'packetMode': 'SYM',
'isMultipartPending': true,
'isMultipartComplete': false,
'receivedParts': fragments.length,
'totalParts': frag['totalParts'],
});
}
return status;
}
Future<String?> _handleAsymmetricFrag(
String phone,
Map<String, dynamic> frag, {
bool broadcast = true,
bool isMe = false,
bool isScan = false,
}) async {
await _db.saveFragment(phone, frag['packetId'], frag['partNo'],
frag['totalParts'], frag['chunk'],
packetMode: 'AE');
final fragments = await _db.getFragments(phone, frag['packetId']);
if (fragments.length == frag['totalParts']) {
final sorted = List<Map<String, dynamic>>.from(fragments)
..sort((a, b) =>
(a['frag_index'] as int).compareTo(b['frag_index'] as int));
final payloadBytes = <int>[];
for (final fragment in sorted) {
payloadBytes.addAll(_crypto.b64uDecode(fragment['body'] as String));
}
if (!isScan) {
await _db.clearFragments(phone, frag['packetId']);
}
final encodedPayload = _crypto.b64uEncode(payloadBytes);
final result =
await _handleAsymmetricMsg(phone, encodedPayload, isMe: isMe);
final eventBody =
result ?? ProtocolHelper.buildAsymmetricMsg(encodedPayload);
final eventDate = DateTime.now().millisecondsSinceEpoch;
await _db.consolidatePacketMessage(
phone,
packetId: frag['packetId'] as String,
packetMode: 'AE',
body: eventBody,
date: eventDate,
type: isMe ? 2 : 1,
isMe: isMe ? 1 : 0,
isRead: isMe ? 1 : 1,
isSecure: 1,
);
if (broadcast) {
_messageStreamController.add({
'phone': phone,
'body': eventBody,
'originalBody': ProtocolHelper.buildAsymmetricMsg(encodedPayload),
'packetId': frag['packetId'],
'packetMode': 'AE',
'date': eventDate,
'isMultipartPending': false,
'isMultipartComplete': true,
'isMe': isMe,
});
}
return result;
}
final action = isMe ? 'ارسال' : 'دریافت';
final expired = _isPacketExpired(fragments);
final status = expired
? 'باقی قطعات پیام ECC نرسیده‌اند (زمان تمام شد)'
: 'در حال $action قطعات... (${fragments.length}/${frag['totalParts']})';
if (broadcast) {
_messageStreamController.add({
'phone': phone,
'body': status,
'originalBody': 'SFRA_PART',
'isMe': isMe,
'packetId': frag['packetId'],
'packetMode': 'AE',
'isMultipartPending': true,
'isMultipartComplete': false,
'receivedParts': fragments.length,
'totalParts': frag['totalParts'],
});
}
return status;
}
Future<String?> _handleGroupMsg(
String phone, String body, String type, Map<String, dynamic> parsed,
{bool isMe = false}) async {
final groups = await _db.getGroupsContainingPhone(phone);
if (groups.isEmpty) return body;
final effectiveParsed =
parsed['payload'] != null ? parsed : ProtocolHelper.parseMessage(body);
final effectiveType = effectiveParsed['type'] as String? ?? type;
String? firstSuccess;
for (final group in groups) {
final groupId = group['id'] as int;
String currentStoredBody = body;
final groupKey = (group['group_key'] as String? ?? '').trim();
if ((effectiveType == 'sym' || effectiveType == 'gsym') &&
groupKey.isNotEmpty) {
final payload = effectiveParsed['payload'] as String?;
if (payload != null) {
final result = await _crypto.decryptSymmetric(payload, groupKey);
if (result != null) {
currentStoredBody = result;
firstSuccess ??= result;
} else {
currentStoredBody =
'پیام امن گروهی دریافت شد اما کلید این گروه (ID: $groupId) معتبر نیست.';
}
}
} else if (effectiveType == 'sym' || effectiveType == 'gsym') {
final label = isMe ? 'ارسال شده' : 'دریافت شد';
return 'پیام امن گروهی $label. برای مشاهده، کلید گروه را وارد کنید. ::PAYLOAD::${effectiveParsed['payload']}';
}
await _db.saveGroupMessage(
groupId, currentStoredBody, DateTime.now().millisecondsSinceEpoch,
senderPhone: phone,
isSecure: firstSuccess != null ? 1 : 0,
packetId: parsed['packetId'],
packetMode: 'SYM');
if (firstSuccess != null) {
debugPrint('[CRYPTO] Group decryption SUCCESS for group $groupId.');
break;
}
}
if (firstSuccess != null) {
_messageStreamController.add({'phone': phone, 'body': 'REFRESH'});
return firstSuccess;
}
return "گروه: ${groups[0]['name']}";
}
Future<void> sendGroupSecureMessage(
int groupId, List<String> phones, String text, String key) async {
if (text.length > maxTextLimit) {
throw "طول پیام نباید بیشتر از $maxTextLimit کاراکتر باشد.";
}
final encrypted = await _crypto.encryptSymmetric(text, key);
final protocolMsg =
'${ProtocolHelper.gPrefix}${ProtocolHelper.typeSym}|$encrypted';
await _db.saveGroupMessage(
groupId, text, DateTime.now().millisecondsSinceEpoch,
isSecure: 1, packetId: null, packetMode: 'SYM');
for (final phone in phones) {
final normalized = ContactHelper.normalizePhone(phone);
if (protocolMsg.length <= ProtocolHelper.maxSmsChars) {
await _telephony.sendSms(to: normalized, message: protocolMsg);
await Future.delayed(const Duration(milliseconds: 1500));
} else {
await _sendLegacyFragmented(normalized, encrypted, isGroup: true);
}
}
_messageStreamController
.add({'phone': 'group_$groupId', 'body': 'REFRESH'});
}
}
@pragma('vm:entry-point')
void backgroundMessageHandler(SmsMessage message) async {
await ContactHelper.loadFromLocalCache();
final phone = ContactHelper.normalizePhone(message.address ?? "");
final name = ContactHelper.getName(phone);
final body = message.body ?? "";
final parsed = ProtocolHelper.parseMessage(body);
final type = parsed['type'];
final isSecure = type != 'plain';
final isControlProtocol =
type == 'key_init' || type == 'key_reply' || type == 'norm';
final db = DatabaseHelper.instance;
// CRITICAL: Always persist to sms_cache so it survives app restarts
await db.saveSingleSmsToCache({
'sms_id': message.id,
'address': phone,
'body': body,
'date': message.date ?? DateTime.now().millisecondsSinceEpoch,
'type': 1, // Inbox
'is_me': 0,
'is_read': isControlProtocol ? 1 : 0,
'is_secure': isSecure ? 1 : 0,
'packet_id': parsed['packetId'],
'packet_mode': type == 'afrag'
? 'AE'
: (type == 'sfra' || type == 'sym'
? 'SYM'
: (type == 'asym' ? 'AE' : null)),
});
if (isControlProtocol) {
return;
}
if (type == 'sfra' || type == 'afrag') {
await db.saveFragment(
phone,
parsed['packetId'],
parsed['partNo'],
parsed['totalParts'],
parsed['chunk'],
packetMode: type == 'afrag' ? 'AE' : 'SYM',
);
final fragments = await db.getFragments(phone, parsed['packetId']);
if (fragments.length == parsed['totalParts']) {
await NotificationHelper.instance.showNotification(
id: phone.hashCode,
title: "پیام امن جدید ($name)",
body: "پیام چندبخشی کامل شد. برای بازگشایی وارد برنامه شوید.",
payload: phone,
);
} else {
await NotificationHelper.instance.showNotification(
id: phone.hashCode,
title: "پیام امن جدید ($name)",
body:
"در حال دریافت قطعات... (${fragments.length}/${parsed['totalParts']})",
payload: phone,
);
}
return;
}
if (parsed['isGroup'] == true) {
final groups = await db.getGroupsContainingPhone(phone);
if (groups.isNotEmpty) {
for (final group in groups) {
await db.saveGroupMessage(
group['id'] as int, body, DateTime.now().millisecondsSinceEpoch,
senderPhone: phone);
}
await NotificationHelper.instance.showNotification(
id: groups[0]['id'] as int,
title: "پیام جدید در گروه ${groups[0]['name']}",
body: isSecure ? "پیام امن گروهی" : body,
payload: "group_${groups[0]['id']}",
);
return;
}
}
await NotificationHelper.instance.showNotification(
id: phone.hashCode,
title: "پیام جدید از $name",
body: isSecure ? "پیام امن دریافت شد" : body,
payload: phone,
);
}