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>.broadcast(); Stream> get messageStream => _messageStreamController.stream; final Set _activeOutgoingPacketIds = {}; String? currentChatPhone; bool _isInitialized = false; bool _listenerRegistered = false; SecureMessagingService._init(); Future init() async { if (_isInitialized) return; await NotificationHelper.instance.init(); await _ensureIdentity(); await _ensureInstallDate(); if (!_listenerRegistered) { _setupGlobalListener(); _listenerRegistered = true; } _isInitialized = true; } Future resetForFreshInstall() async { currentChatPhone = null; _isInitialized = false; await _db.resetAppData(); } Future getInstallDate() async { final val = await _db.getSetting('install_date'); if (val == null) return null; return int.tryParse(val); } Future _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 _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 _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 _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 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?> 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 resetAsymmetricSecurity(String phone) async { final normalizedPhone = ContactHelper.normalizePhone(phone); await _db.resetAsymmetricSecurity(normalizedPhone); _messageStreamController.add({'phone': normalizedPhone, 'body': 'REFRESH'}); } Future>> getPendingPacketsForPhone(String phone) { return _db.getPendingPacketsForPhone(phone); } Future cleanupFragmentBuffer(String phone) async { final retentionCutoff = DateTime.now().millisecondsSinceEpoch - fragmentRetentionMs; await _db.clearCompletedPackets(phone); await _db.clearStalePendingPackets( phone, olderThanMs: retentionCutoff, ); } Future 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> 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> 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 sendSecureMessage(String phone, String text, {String? symmetricKey}) async { await sendMessage( phone, text, securityLevel: symmetricKey != null && symmetricKey.trim().isNotEmpty ? 'symmetric' : 'normal', symmetricKey: symmetricKey, ); } Future getEncryptedPreview(String text, String symmetricKey) async { final encrypted = await _crypto.encryptSymmetric(text, symmetricKey); return ProtocolHelper.buildSymmetricMsg(encrypted); } Future _sendFrames(String phone, List 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 _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 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>.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 = []; 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 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 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 _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 _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 _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> fragments) { if (fragments.isEmpty) return false; final lastSeen = fragments .map((f) => (f['updated_at'] ?? f['date'] ?? 0) as int) .fold(0, max); return DateTime.now().millisecondsSinceEpoch - lastSeen > fragmentTimeoutMs; } Future _handleLegacyFrag( String phone, Map 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>.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 _handleAsymmetricFrag( String phone, Map 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>.from(fragments) ..sort((a, b) => (a['frag_index'] as int).compareTo(b['frag_index'] as int)); final payloadBytes = []; 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 _handleGroupMsg( String phone, String body, String type, Map 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 sendGroupSecureMessage( int groupId, List 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, ); }