1199 lines
41 KiB
Dart
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,
|
|
);
|
|
}
|