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

2233 lines
84 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:another_telephony/telephony.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
import '../models/chat_model.dart';
import '../utils/app_theme.dart';
import '../utils/contact_helper.dart';
import '../utils/database_helper.dart';
import '../utils/message_processor.dart';
import '../utils/protocol_helper.dart';
import '../utils/secure_messaging_service.dart';
import '../widgets/message_bubble.dart';
class MeshBackgroundPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// Optimization for Raspberry Pi 3: Use RadialGradient instead of MaskFilter blur
// which is often hardware-accelerated and much cheaper than multi-pass blur.
final Paint paint1 = Paint()
..shader = RadialGradient(
colors: [
const Color(0xFF7000FF).withValues(alpha: 0.12),
const Color(0xFF7000FF).withValues(alpha: 0.0),
],
).createShader(Rect.fromCircle(
center: Offset(size.width * 0.2, size.height * 0.15), radius: 300));
canvas.drawCircle(
Offset(size.width * 0.2, size.height * 0.15), 300, paint1);
final Paint paint2 = Paint()
..shader = RadialGradient(
colors: [
const Color(0xFF00D2FF).withValues(alpha: 0.1),
const Color(0xFF00D2FF).withValues(alpha: 0.0),
],
).createShader(Rect.fromCircle(
center: Offset(size.width * 0.8, size.height * 0.8), radius: 400));
canvas.drawCircle(Offset(size.width * 0.8, size.height * 0.8), 400, paint2);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _MessageCandidate {
final ChatModel message;
final bool preferCandidate;
const _MessageCandidate(
this.message, {
this.preferCandidate = false,
});
}
class ChatScreen extends StatefulWidget {
final String address;
const ChatScreen({super.key, required this.address});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final Telephony telephony = Telephony.instance;
static const platform = MethodChannel('com.example.saba/sim_cards');
static const nativeChannel = MethodChannel('com.example.saba/sms_native');
static const asymmetricModeLabel = "رمزنگاری غیر متقارن (طولانی‌تر و امن‌تر)";
final TextEditingController _msgController = TextEditingController();
Color get primaryColor => Theme.of(context).primaryColor;
Color get backgroundColor => const Color(0xFFF5F7FA);
final ScrollController _scrollController = ScrollController();
final ValueNotifier<List<ChatModel>> _messageNotifier = ValueNotifier([]);
List<ChatModel> get messages => _messageNotifier.value;
set messages(List<ChatModel> val) => _messageNotifier.value = val;
List<Map<String, dynamic>> _simCards = [];
Map<String, dynamic>? _selectedSim;
bool _loadingSims = true;
String displayName = "";
String _selectedSecurityLevel = 'normal';
Map<String, dynamic>? contactInfo;
StreamSubscription? _messageSubscription;
String? _dismissedBannerText;
bool _isLoading = false;
Timer? _debounceTimer;
int _offset = 0;
static const int _limit = 25;
static const int _liveMessageGraceMs = 15000;
bool _hasMore = true;
int _compareMessages(ChatModel a, ChatModel b) {
final dateComp = b.date.compareTo(a.date);
if (dateComp != 0) return dateComp;
final idComp = (b.id ?? -1).compareTo(a.id ?? -1);
if (idComp != 0) return idComp;
final packetComp = (_packetKey(b.packetId, b.packetMode) ?? '')
.compareTo(_packetKey(a.packetId, a.packetMode) ?? '');
if (packetComp != 0) return packetComp;
final isMeComp = (b.isMe ? 1 : 0).compareTo(a.isMe ? 1 : 0);
if (isMeComp != 0) return isMeComp;
final bodyComp = (b.rawBody ?? b.body).compareTo(a.rawBody ?? a.body);
if (bodyComp != 0) return bodyComp;
return b.status.index.compareTo(a.status.index);
}
int _messageQuality(ChatModel message) {
var score = 0;
if (message.id != null) score += 40;
if (_packetKey(message.packetId, message.packetMode) != null) score += 8;
if ((message.rawBody?.isNotEmpty ?? false)) score += 3;
if ((message.rawViewBody?.isNotEmpty ?? false)) score += 2;
if ((message.encryptedPayload?.isNotEmpty ?? false)) score += 4;
if (message.isSecure) score += 3;
if (!message.isPendingMultipart) score += 6;
if (message.status == MessageStatus.sent ||
message.status == MessageStatus.received) {
score += 2;
}
if (message.status == MessageStatus.sending) score -= 2;
if (message.status == MessageStatus.failed) score -= 1;
if (_isSecurePlaceholderBody(message.body)) score -= 1;
return score;
}
void _publishMessages(List<ChatModel> items) {
final sorted = List<ChatModel>.from(items)..sort(_compareMessages);
_messageNotifier.value = sorted;
}
String? _packetKey(String? packetId, [String? packetMode]) {
if (packetId == null || packetId.isEmpty) return null;
final mode =
(packetMode == null || packetMode.isEmpty) ? 'UNK' : packetMode;
return '$mode::$packetId';
}
ChatModel _cloneMessage(ChatModel source) {
return ChatModel(
id: source.id,
localId: source.localId,
body: source.body,
rawBody: source.rawBody,
rawViewBody: source.rawViewBody,
encryptedPayload: source.encryptedPayload,
statusLabel: source.statusLabel,
packetId: source.packetId,
packetMode: source.packetMode,
rawPacketId: source.rawPacketId,
date: source.date,
isMe: source.isMe,
status: source.status,
isSecure: source.isSecure,
canRetryDecryption: source.canRetryDecryption,
isPendingMultipart: source.isPendingMultipart,
isRead: source.isRead,
);
}
String _fallbackMessageKey(ChatModel message) {
return 'fallback::${message.isMe ? 1 : 0}::${message.date}::${message.body.hashCode}';
}
String? _normalizedPayloadSignature(String? payload) {
final normalized = (payload ?? '').replaceAll(RegExp(r'\s+'), '').trim();
return normalized.isEmpty ? null : normalized;
}
String? _contentSignature(ChatModel message) {
final rawSignature = (message.rawBody ?? '').trim();
final bodySignature = message.body.trim();
final contentSignature =
rawSignature.isNotEmpty ? rawSignature : bodySignature;
if (contentSignature.isEmpty ||
_isSecurePlaceholderBody(contentSignature) ||
_isProgressBody(contentSignature)) {
return null;
}
return contentSignature;
}
int _timeBucket(int date, [int bucketMs = 5000]) {
if (date <= 0) return 0;
return date ~/ bucketMs;
}
List<String> _messageAliases(ChatModel message) {
final aliases = <String>{};
if (message.localId != null && message.localId!.isNotEmpty) {
aliases.add('local::${message.localId}');
}
if (message.id != null) {
aliases.add('db::${message.id}');
}
final packetKey = _packetKey(message.packetId, message.packetMode);
if (packetKey != null) {
aliases.add('packet::$packetKey');
}
final payload = _normalizedPayloadSignature(message.encryptedPayload);
if (payload != null) {
final mode = (message.packetMode ?? 'SEC').trim();
aliases.add('payload::$mode::${message.isMe ? 1 : 0}::$payload');
aliases.add('payload::$mode::$payload');
}
final rawBody = (message.rawBody ?? '').trim();
if (rawBody.isNotEmpty) {
aliases.add('raw::${message.isMe ? 1 : 0}::$rawBody');
final parsed = ProtocolHelper.parseMessage(rawBody);
final rawPayload =
_normalizedPayloadSignature(parsed['payload'] as String?);
final rawType = parsed['type'] as String? ?? 'plain';
if (rawPayload != null) {
final mode = (message.packetMode ??
(rawType == 'asym' || rawType == 'afrag' ? 'AE' : 'SYM'))
.trim();
aliases.add('payload::$mode::${message.isMe ? 1 : 0}::$rawPayload');
aliases.add('payload::$mode::$rawPayload');
}
}
final content = _contentSignature(message);
if (content != null) {
aliases.add(
'body::${message.isMe ? 1 : 0}::${_timeBucket(message.date)}::$content');
}
return aliases.toList(growable: false);
}
bool _hasAliasOverlap(Set<String> a, Set<String> b) {
for (final key in a) {
if (b.contains(key)) return true;
}
return false;
}
bool _isProgressBody(String body) {
return body.contains('در حال دریافت قطعات') ||
body.contains('در حال ارسال قطعات') ||
body.contains('باقی قطعات پیام نرسیده‌اند');
}
String _pickPreferredBody(String primaryBody, String secondaryBody) {
final primary = primaryBody.trim();
final secondary = secondaryBody.trim();
if (primary.isEmpty) return secondaryBody;
if (secondary.isEmpty) return primaryBody;
final primaryProgress = _isProgressBody(primaryBody);
final secondaryProgress = _isProgressBody(secondaryBody);
if (primaryProgress != secondaryProgress) {
return primaryProgress ? secondaryBody : primaryBody;
}
final primaryPlaceholder = _isSecurePlaceholderBody(primaryBody);
final secondaryPlaceholder = _isSecurePlaceholderBody(secondaryBody);
if (primaryPlaceholder != secondaryPlaceholder) {
return primaryPlaceholder ? secondaryBody : primaryBody;
}
return primary.length >= secondary.length ? primaryBody : secondaryBody;
}
String? _pickPreferredString(String? primary, String? secondary) {
final a = (primary ?? '').trim();
final b = (secondary ?? '').trim();
if (a.isEmpty) return b.isEmpty ? null : secondary;
if (b.isEmpty) return primary;
return a.length >= b.length ? primary : secondary;
}
MessageStatus _pickPreferredStatus(ChatModel primary, ChatModel secondary) {
final primaryFinal = primary.status == MessageStatus.sent ||
primary.status == MessageStatus.received;
final secondaryFinal = secondary.status == MessageStatus.sent ||
secondary.status == MessageStatus.received;
if (primaryFinal != secondaryFinal) {
return primaryFinal ? primary.status : secondary.status;
}
if (primary.status == MessageStatus.failed &&
secondary.status != MessageStatus.failed) {
return secondary.status;
}
if (secondary.status == MessageStatus.failed &&
primary.status != MessageStatus.failed) {
return primary.status;
}
if (primary.status == MessageStatus.sending &&
secondary.status != MessageStatus.sending) {
return secondary.status;
}
return primary.status;
}
ChatModel _mergeModels(
ChatModel existing,
ChatModel candidate, {
bool preferCandidate = false,
}) {
final candidateQuality = _messageQuality(candidate);
final existingQuality = _messageQuality(existing);
final useCandidateAsPrimary = preferCandidate ||
candidateQuality > existingQuality ||
(candidateQuality == existingQuality &&
_compareMessages(candidate, existing) < 0);
final primary = useCandidateAsPrimary ? candidate : existing;
final secondary = useCandidateAsPrimary ? existing : candidate;
final merged = ChatModel(
id: primary.id ?? secondary.id,
localId: primary.localId ?? secondary.localId,
body: _pickPreferredBody(primary.body, secondary.body),
rawBody: _pickPreferredString(primary.rawBody, secondary.rawBody),
rawViewBody:
_pickPreferredString(primary.rawViewBody, secondary.rawViewBody),
encryptedPayload: _pickPreferredString(
primary.encryptedPayload, secondary.encryptedPayload),
statusLabel:
_pickPreferredString(primary.statusLabel, secondary.statusLabel),
packetId: _pickPreferredString(primary.packetId, secondary.packetId),
packetMode:
_pickPreferredString(primary.packetMode, secondary.packetMode),
rawPacketId:
_pickPreferredString(primary.rawPacketId, secondary.rawPacketId),
date: primary.date == 0
? secondary.date
: (secondary.date == 0
? primary.date
: min(primary.date, secondary.date)),
isMe: primary.isMe,
status: _pickPreferredStatus(primary, secondary),
isSecure: primary.isSecure ||
secondary.isSecure ||
(primary.packetMode ?? secondary.packetMode) != null ||
(primary.encryptedPayload ?? secondary.encryptedPayload) != null,
canRetryDecryption:
primary.canRetryDecryption || secondary.canRetryDecryption,
isPendingMultipart:
primary.isPendingMultipart || secondary.isPendingMultipart,
isRead: primary.isRead && secondary.isRead,
);
if (!_isProgressBody(merged.body)) {
merged.isPendingMultipart = false;
if (merged.statusLabel != null &&
(merged.status == MessageStatus.sent ||
merged.status == MessageStatus.received) &&
!_isSecurePlaceholderBody(merged.body)) {
merged.statusLabel = null;
}
}
if (merged.isSecure ||
merged.packetMode != null ||
(merged.rawBody?.isNotEmpty ?? false)) {
_enrichSecureModel(merged);
} else {
merged.rawViewBody ??= _buildRawViewBody(
body: merged.body,
rawBody: merged.rawBody,
encryptedPayload: merged.encryptedPayload,
packetMode: merged.packetMode,
isSecure: merged.isSecure,
);
}
return merged;
}
List<ChatModel> _reconcileCandidates(List<_MessageCandidate> candidates) {
final mergedMessages = <ChatModel>[];
final aliasSets = <Set<String>>[];
for (final entry in candidates) {
final prepared = _cloneMessage(entry.message);
if (prepared.isSecure ||
prepared.packetMode != null ||
(prepared.rawBody?.isNotEmpty ?? false)) {
_enrichSecureModel(prepared);
} else {
prepared.rawViewBody ??= _buildRawViewBody(
body: prepared.body,
rawBody: prepared.rawBody,
encryptedPayload: prepared.encryptedPayload,
packetMode: prepared.packetMode,
isSecure: prepared.isSecure,
);
}
final workingAliases = _messageAliases(prepared).toSet();
final matches = <int>[];
for (var i = 0; i < aliasSets.length; i++) {
if (_hasAliasOverlap(aliasSets[i], workingAliases)) {
matches.add(i);
}
}
if (matches.isEmpty) {
mergedMessages.add(prepared);
aliasSets.add(
workingAliases.isEmpty
? {_fallbackMessageKey(prepared)}
: workingAliases,
);
continue;
}
var combined = prepared;
for (final index in matches.reversed) {
combined = _mergeModels(
mergedMessages[index],
combined,
preferCandidate: entry.preferCandidate,
);
mergedMessages.removeAt(index);
aliasSets.removeAt(index);
}
final combinedAliases = _messageAliases(combined).toSet()
..addAll(workingAliases);
mergedMessages.add(combined);
aliasSets.add(
combinedAliases.isEmpty
? {_fallbackMessageKey(combined)}
: combinedAliases,
);
}
mergedMessages.sort(_compareMessages);
return mergedMessages;
}
void _replaceMessagesWithCandidates(List<_MessageCandidate> candidates) {
_publishMessages(_reconcileCandidates(candidates));
}
String? _findOutgoingDraftLocalId(ChatModel candidate) {
if (!candidate.isMe) return null;
ChatModel? best;
var bestDelta = 1 << 30;
for (final message in messages) {
if (message.localId == null ||
!message.isMe ||
message.id != null ||
(message.status != MessageStatus.sending &&
message.status != MessageStatus.failed &&
!message.isPendingMultipart)) {
continue;
}
if (candidate.packetMode != null &&
message.packetMode != null &&
candidate.packetMode != message.packetMode) {
continue;
}
final delta = (candidate.date - message.date).abs();
if (delta > 30000 || delta >= bestDelta) continue;
best = message;
bestDelta = delta;
}
return best?.localId;
}
bool _shouldKeepTransientMessageDuringReload(ChatModel message) {
if (message.id != null) return false;
final ageMs = DateTime.now().millisecondsSinceEpoch - message.date;
if (ageMs <= _liveMessageGraceMs) return true;
if (message.status == MessageStatus.failed ||
message.status == MessageStatus.sending ||
message.isPendingMultipart) {
return true;
}
return message.isMe;
}
bool _shouldPreferInMemoryMessage(ChatModel message) {
return message.id == null &&
((DateTime.now().millisecondsSinceEpoch - message.date) <=
_liveMessageGraceMs ||
message.status == MessageStatus.failed ||
message.status == MessageStatus.sending ||
message.isPendingMultipart ||
message.isMe);
}
String? _buildRawViewBody({
required String body,
String? rawBody,
String? encryptedPayload,
String? packetMode,
bool isSecure = false,
}) {
// If it's a secure message, we want to show the underlying protocol string
if (encryptedPayload != null && encryptedPayload.isNotEmpty) {
switch (packetMode) {
case 'AE':
return '@S:AE|$encryptedPayload';
case 'SYM':
default:
return '@S:SYM|$encryptedPayload';
}
}
// Fallback to the original raw SMS body if available
if (rawBody != null && rawBody.isNotEmpty && rawBody != body) {
return rawBody;
}
return null;
}
void _debouncedLoadMessages() {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () {
if (mounted) loadMessages();
});
}
@override
void initState() {
super.initState();
displayName = ContactHelper.getName(widget.address);
SecureMessagingService.instance.currentChatPhone =
ContactHelper.normalizePhone(widget.address);
_scrollController.addListener(_onScroll);
_fetchSimCards();
_initMessageStreamListener();
_syncAndLoad();
DatabaseHelper.instance.markAsRead(widget.address);
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final pos = _scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 150 && !_isLoading && _hasMore) {
loadMessages(isLoadMore: true);
}
}
@override
void dispose() {
if (SecureMessagingService.instance.currentChatPhone ==
ContactHelper.normalizePhone(widget.address)) {
SecureMessagingService.instance.currentChatPhone = null;
}
_scrollController.dispose();
_messageNotifier.dispose();
_messageSubscription?.cancel();
_msgController.dispose();
_debounceTimer?.cancel();
super.dispose();
}
void _initMessageStreamListener() {
_messageSubscription =
SecureMessagingService.instance.messageStream.listen((data) {
final normalizedTarget = ContactHelper.normalizePhone(widget.address);
final normalizedIncoming =
ContactHelper.normalizePhone(data['phone'] as String);
if (normalizedIncoming == normalizedTarget) {
// Immediate UI feedback for security state
final result = data['result'] as Map<String, dynamic>?;
if (result != null) {
final isSecure = result['isSecure'] == true;
final body = data['body'] as String?;
if (isSecure && body != null) {
// Trigger a load immediately to reflect state changes
}
}
final originalBody = data['originalBody'] as String? ?? '';
final body = data['body'] as String? ?? '';
final packetId = data['packetId'] as String?;
final packetMode = data['packetMode'] as String?;
final eventDate =
data['date'] as int? ?? DateTime.now().millisecondsSinceEpoch;
final isMultipartPending = data['isMultipartPending'] as bool? ?? false;
final isMultipartComplete =
data['isMultipartComplete'] as bool? ?? false;
final receivedParts = data['receivedParts'] as int? ?? 0;
final totalPartsFromEvent = data['totalParts'] as int? ?? 0;
final isMeEvent = data['isMe'] as bool? ?? false;
if (body == 'REFRESH') {
_debouncedLoadMessages();
return;
}
if (ProtocolHelper.parseMessage(originalBody)['type'] == 'plain' &&
_looksLikeProtocolFragment(originalBody)) {
_debouncedLoadMessages();
return;
}
final bool isIntermediateFragment =
body.contains('در حال دریافت قطعات') ||
body.contains('در حال ارسال قطعات') ||
body.contains('باقی قطعات پیام نرسیده‌اند');
if (isIntermediateFragment) {
if (!mounted) return;
if (isMultipartPending && packetId != null) {
final liveBody = body;
final liveStatus = totalPartsFromEvent > 0 && receivedParts > 0
? '${isMeEvent ? 'ارسال' : 'دریافت'} زنده: $receivedParts از $totalPartsFromEvent قطعه'
: '${isMeEvent ? 'ارسال' : 'دریافت'} قطعات در حال انجام است';
final pendingMessage = ChatModel(
localId: isMeEvent
? _findOutgoingDraftLocalId(ChatModel(
body: liveBody,
date: eventDate,
isMe: true,
status: MessageStatus.sending,
))
: null,
body: liveBody,
rawBody: originalBody,
rawViewBody: _buildRawViewBody(
body: liveBody,
rawBody: originalBody,
packetMode: packetMode,
isSecure: true,
),
packetId: packetId,
packetMode: packetMode,
statusLabel: liveStatus,
date: eventDate,
isMe: isMeEvent,
status:
isMeEvent ? MessageStatus.sending : MessageStatus.received,
isSecure: true,
isPendingMultipart: true,
);
_replaceMessagesWithCandidates([
...messages.map((m) => _MessageCandidate(m)),
_MessageCandidate(pendingMessage, preferCandidate: true),
]);
}
return;
}
if (!mounted) return;
final payloadMarker = _extractPayloadFromProcessedBody(body);
final cleanBody = payloadMarker != null
? body.substring(0, body.indexOf(' ::PAYLOAD::'))
: body;
final incomingMessage = ChatModel(
body: cleanBody,
rawBody: originalBody,
rawViewBody: _buildRawViewBody(
body: cleanBody,
rawBody: originalBody,
encryptedPayload: payloadMarker,
packetMode: packetMode,
isSecure: isMultipartComplete ||
ProtocolHelper.parseMessage(originalBody)['type'] != 'plain',
),
encryptedPayload: payloadMarker,
packetId: packetId,
packetMode: packetMode,
date: eventDate,
isMe: isMeEvent,
status: isMeEvent ? MessageStatus.sent : MessageStatus.received,
isSecure: isMultipartComplete ||
ProtocolHelper.parseMessage(originalBody)['type'] != 'plain',
canRetryDecryption: payloadMarker != null,
isPendingMultipart: false,
statusLabel: payloadMarker != null
? 'پیام کامل شد؛ برای بازگشایی کلید را وارد کنید'
: null,
);
if (incomingMessage.isSecure || packetMode != null) {
_enrichSecureModel(incomingMessage);
}
incomingMessage.localId ??= _findOutgoingDraftLocalId(incomingMessage);
_replaceMessagesWithCandidates([
...messages.map((m) => _MessageCandidate(m)),
_MessageCandidate(incomingMessage, preferCandidate: true),
]);
}
});
}
Future<void> _fetchSimCards() async {
if (!await Permission.phone.request().isGranted) {
if (mounted) setState(() => _loadingSims = false);
return;
}
try {
final List<dynamic> result = await platform.invokeMethod('getSimCards');
final cleanList = result.map((e) {
final rawMap = e as Map<Object?, Object?>;
return rawMap.map((key, value) => MapEntry(key.toString(), value));
}).toList();
if (!mounted) return;
setState(() {
_simCards = cleanList;
if (_simCards.isNotEmpty) _selectedSim = _simCards.first;
_loadingSims = false;
});
} catch (_) {
if (mounted) setState(() => _loadingSims = false);
}
}
bool _isSecurePlaceholderBody(String body) {
if (body.contains(' ::PAYLOAD::')) return true;
return body.startsWith('پیام امن') ||
body.startsWith('بازگشایی') ||
body.startsWith('پیام رمزنگاری غیر متقارن');
}
bool _looksLikeProtocolFragment(String body) {
final normalized = body.trimLeft().toUpperCase();
return normalized.startsWith('@S:AF|') ||
normalized.startsWith('@S:SFRA|') ||
normalized.startsWith('@G:AF|') ||
normalized.startsWith('@G:SFRA|');
}
void _enrichSecureModel(ChatModel msg) {
final originalBody = msg.body;
final payloadFromProcessed = _extractPayloadFromProcessedBody(originalBody);
if (payloadFromProcessed != null) {
msg.body =
originalBody.substring(0, originalBody.indexOf(' ::PAYLOAD::'));
}
final protocolParsed =
ProtocolHelper.parseMessage(msg.rawBody ?? originalBody);
final protocolPayload = protocolParsed['payload'] as String?;
final protocolPacketId = protocolParsed['packetId'] as String?;
final protocolType = protocolParsed['type'] as String?;
msg.encryptedPayload = payloadFromProcessed ??
msg.encryptedPayload ??
(protocolPayload?.trim().isNotEmpty == true
? protocolPayload!.trim()
: null);
if ((msg.packetId == null || msg.packetId!.isEmpty) &&
protocolPacketId != null &&
protocolPacketId.isNotEmpty) {
msg.packetId = protocolPacketId;
}
if ((msg.packetMode == null || msg.packetMode!.isEmpty) &&
protocolType != null) {
if (protocolType == 'sym' || protocolType == 'sfra') {
msg.packetMode = 'SYM';
} else if (protocolType == 'asym' || protocolType == 'afrag') {
msg.packetMode = 'AE';
}
}
msg.rawViewBody = _buildRawViewBody(
body: msg.body,
rawBody: msg.rawBody,
encryptedPayload: msg.encryptedPayload,
packetMode: msg.packetMode,
isSecure: true,
);
msg.canRetryDecryption = !msg.isMe && _isSecurePlaceholderBody(msg.body);
}
String? _extractPayloadFromProcessedBody(String body) {
const separator = ' ::PAYLOAD::';
final splitIndex = body.lastIndexOf(separator);
if (splitIndex == -1) return null;
final payload = body.substring(splitIndex + separator.length).trim();
return payload.isEmpty ? null : payload;
}
String _securityLabel(String level) {
switch (level) {
case 'symmetric':
return 'رمزنگاری متقارن';
case 'asymmetric':
return asymmetricModeLabel;
default:
return 'ارسال عادی';
}
}
String _securityDescription(String level) {
switch (level) {
case 'symmetric':
return 'سریع‌تر برای گفتگوهای مشترک با کلید ثابت';
case 'asymmetric':
return 'امن‌تر برای تبادل کلید و پیام‌های حساس';
default:
return 'پیامک معمولی بدون لایه امنیتی اضافه';
}
}
IconData _securityIcon(String level) {
switch (level) {
case 'symmetric':
return Icons.key_rounded;
case 'asymmetric':
return Icons.verified_user_rounded;
default:
return Icons.chat_bubble_outline_rounded;
}
}
Color _securityAccent(String level) {
switch (level) {
case 'symmetric':
return const Color(0xFF2E7D32);
case 'asymmetric':
return const Color(0xFF1565C0);
default:
return const Color(0xFF607D8B);
}
}
({String text, Color color}) _securityModeBadge(String level) {
final secureState = contactInfo?['secure_state'] as String? ?? 'none';
final verification =
contactInfo?['verification_state'] as String? ?? 'unverified';
final hasPeerKey =
(contactInfo?['peer_public_key'] as String?)?.isNotEmpty == true;
final hasSharedKey =
(contactInfo?['ecc_shared_key'] as String?)?.isNotEmpty == true;
final hasSymmetricKey =
(contactInfo?['symmetric_key'] as String?)?.isNotEmpty == true;
switch (level) {
case 'symmetric':
return hasSymmetricKey
? (text: 'آماده', color: const Color(0xFF2E7D32))
: (text: 'نیاز به کلید', color: const Color(0xFFEF6C00));
case 'asymmetric':
if (secureState == 'handshake_failed' || verification == 'changed') {
return (text: 'نیاز به بازیابی', color: const Color(0xFFC62828));
}
if (secureState == 'handshake_pending') {
return (text: 'در حال تبادل', color: const Color(0xFF1565C0));
}
if (hasPeerKey &&
hasSharedKey &&
verification == 'verified' &&
secureState == 'ready') {
return (text: 'آماده', color: const Color(0xFF2E7D32));
}
if (hasPeerKey && hasSharedKey) {
return (text: 'نیاز به تایید', color: const Color(0xFFEF6C00));
}
return (text: 'نیاز به ایجاد', color: const Color(0xFFEF6C00));
default:
return (text: 'همیشه آماده', color: const Color(0xFF607D8B));
}
}
Future<void> _showSecurityModeSheet() async {
final selected = await showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
final levels = ['normal', 'symmetric', 'asymmetric'];
return Container(
decoration: BoxDecoration(
color: AppTheme.darkCard,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1), width: 0.5),
),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 42,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 16),
const Text(
'روش رمزنگاری',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white),
),
const SizedBox(height: 6),
const Text(
'برای این پیام، حالت مناسب را انتخاب کنید.',
style: TextStyle(color: Colors.white60, height: 1.4),
),
const SizedBox(height: 16),
...levels.map((level) {
final isSelected = _selectedSecurityLevel == level;
final accent = _securityAccent(level);
final badge = _securityModeBadge(level);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () => Navigator.of(context).pop(level),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isSelected
? accent.withValues(alpha: 0.15)
: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: isSelected
? accent.withValues(alpha: 0.6)
: Colors.white.withValues(alpha: 0.1),
width: isSelected ? 1.6 : 1,
),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(_securityIcon(level), color: accent),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_securityLabel(level),
style: const TextStyle(
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color:
badge.color.withValues(alpha: 0.10),
borderRadius:
BorderRadius.circular(999),
),
child: Text(
badge.text,
style: TextStyle(
color: badge.color,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 4),
Text(
_securityDescription(level),
style: const TextStyle(
color: Colors.white38, fontSize: 12),
),
],
),
),
const SizedBox(width: 8),
Icon(
isSelected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
color:
isSelected ? accent : const Color(0xFF97A6B5),
),
],
),
),
),
);
}),
],
),
);
},
);
if (selected != null && mounted) {
setState(() => _selectedSecurityLevel = selected);
}
}
String _securityStatusText() {
final state = contactInfo?['secure_state'] as String? ?? 'none';
final verification =
contactInfo?['verification_state'] as String? ?? 'unverified';
final mode = contactInfo?['mode'] as String? ?? 'normal';
if (state == 'handshake_pending') {
return 'در حال تبادل کلید عمومی...';
}
if (state == 'handshake_failed') {
return 'تبادل کلید ناموفق بود؛ دوباره تلاش کنید';
}
if (mode == 'asymmetric' &&
(contactInfo?['peer_public_key'] as String?)?.isNotEmpty == true) {
if (verification == 'changed') {
return 'کلید مخاطب تغییر کرده است؛ اثر انگشت را دوباره تطبیق دهید';
}
return verification == 'verified' && state == 'ready'
? 'ارتباط امن شد و کانال ECC آماده است'
: 'کلید عمومی دریافت شده؛ برای فعال شدن ECC اثر انگشت را تایید کنید';
}
if (mode == 'symmetric' &&
(contactInfo?['symmetric_key'] as String?)?.isNotEmpty == true) {
return 'رمزنگاری متقارن آماده است';
}
return 'حالت عادی';
}
bool get _isAsymmetricReadyNow {
final mode = contactInfo?['mode'] as String? ?? 'normal';
final secureState = contactInfo?['secure_state'] as String? ?? 'none';
final verification =
contactInfo?['verification_state'] as String? ?? 'unverified';
final hasPeerKey =
(contactInfo?['peer_public_key'] as String?)?.isNotEmpty == true;
final hasSharedKey =
(contactInfo?['ecc_shared_key'] as String?)?.isNotEmpty == true;
return mode == 'asymmetric' &&
hasPeerKey &&
hasSharedKey &&
secureState == 'ready' &&
verification == 'verified';
}
Future<void> _startAsymmetricHandshake({bool recovery = false}) async {
final result = await SecureMessagingService.instance
.startAsymmetricHandshake(widget.address);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
recovery
? 'بازیابی ارتباط امن شروع شد. ${result['notice']}'
: (result['notice'] as String),
),
));
await loadMessages();
}
Future<void> _endAsymmetricSecurity() async {
await SecureMessagingService.instance
.resetAsymmetricSecurity(widget.address);
if (!mounted) return;
setState(() {
_dismissedBannerText = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('ارتباط امن برای این مخاطب پایان یافت.'),
),
);
await loadMessages();
}
Widget _buildAsymmetricActions() {
if (_selectedSecurityLevel != 'asymmetric') {
return const SizedBox.shrink();
}
final secureState = contactInfo?['secure_state'] as String? ?? 'none';
final verification =
contactInfo?['verification_state'] as String? ?? 'unverified';
final hasPeerKey =
(contactInfo?['peer_public_key'] as String?)?.isNotEmpty == true;
final hasSharedKey =
(contactInfo?['ecc_shared_key'] as String?)?.isNotEmpty == true;
final needsVerification = hasPeerKey &&
hasSharedKey &&
verification != 'verified' &&
secureState != 'handshake_pending';
final needsCreate = !needsVerification &&
!_isAsymmetricReadyNow &&
secureState != 'handshake_pending' &&
verification != 'changed';
final canRecover = _isAsymmetricReadyNow ||
secureState == 'handshake_failed' ||
secureState == 'pending_verification' ||
verification == 'changed';
final canEnd = secureState == 'handshake_pending' ||
secureState == 'handshake_failed' ||
secureState == 'pending_verification' ||
(contactInfo?['mode'] as String? ?? 'normal') == 'asymmetric' ||
hasPeerKey;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: AppTheme.glassWrapper(
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isAsymmetricReadyNow
? 'ارتباط امن تایید شده است. حالا می‌توانید پیام‌های ECC بفرستید.'
: secureState == 'handshake_pending'
? 'در حال تبادل کلیدهای عمومی هستیم. بعد از دریافت پاسخ، اثر انگشت مخاطب را تایید کنید.'
: verification == 'changed'
? 'کلید مخاطب تغییر کرده است. تا زمان تایید اثر انگشت جدید، ارسال ECC غیرفعال می‌ماند.'
: needsVerification
? 'کلید عمومی مخاطب دریافت شده است. تا زمان تایید اثر انگشت، ارسال ECC غیرفعال می‌ماند.'
: 'برای شروع ارتباط نامتقارن، ابتدا تبادل کلید را انجام دهید.',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (needsCreate)
ElevatedButton.icon(
onPressed: () => _startAsymmetricHandshake(),
style: ElevatedButton.styleFrom(
backgroundColor:
Colors.blueAccent.withValues(alpha: 0.8),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
icon: const Icon(Icons.security, size: 18),
label: const Text('ایجاد ارتباط امن'),
),
if (secureState == 'handshake_pending')
OutlinedButton.icon(
onPressed: null,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white60,
side: const BorderSide(color: Colors.white24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
icon: const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white60),
),
label: const Text('در حال تبادل کلید'),
),
if (hasPeerKey)
OutlinedButton.icon(
onPressed: _showFingerprintSheet,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white30),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
icon: const Icon(Icons.verified_user_outlined, size: 18),
label: Text(verification == 'verified'
? 'اثر انگشت'
: 'تایید اثر انگشت'),
),
if (canRecover)
OutlinedButton.icon(
onPressed: () =>
_startAsymmetricHandshake(recovery: true),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white30),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('بازیابی ارتباط امن'),
),
if (canEnd)
TextButton.icon(
onPressed: _endAsymmetricSecurity,
style: TextButton.styleFrom(
foregroundColor:
Colors.redAccent.withValues(alpha: 0.7)),
icon: const Icon(Icons.cancel_outlined, size: 18),
label: const Text('پایان ارتباط'),
),
],
),
],
),
),
),
);
}
Future<void> _syncAndLoad() async {
if (_isLoading) return;
_isLoading = true;
try {
final inbox = await telephony.getInboxSms(columns: [
SmsColumn.ID,
SmsColumn.ADDRESS,
SmsColumn.BODY,
SmsColumn.DATE,
SmsColumn.TYPE
], filter: SmsFilter.where(SmsColumn.ADDRESS).equals(widget.address));
final sent = await telephony.getSentSms(columns: [
SmsColumn.ID,
SmsColumn.ADDRESS,
SmsColumn.BODY,
SmsColumn.DATE,
SmsColumn.TYPE
], filter: SmsFilter.where(SmsColumn.ADDRESS).equals(widget.address));
final List<Map<String, dynamic>> toCache = [];
for (final sms in [...inbox, ...sent]) {
toCache.add({
'sms_id': sms.id,
'address': ContactHelper.normalizePhone(sms.address ?? ''),
'body': sms.body ?? '',
'date': sms.date ?? 0,
'type': sms.type?.index ?? 0,
'is_me': sms.type != SmsType.MESSAGE_TYPE_INBOX ? 1 : 0,
});
}
if (toCache.isNotEmpty) {
await DatabaseHelper.instance.batchInsertSms(toCache);
}
} catch (e) {
debugPrint('Sync error: $e');
} finally {
_isLoading = false;
loadMessages();
}
}
bool _needsReload = false;
Future<void> loadMessages({bool isLoadMore = false}) async {
if (_isLoading) {
if (!isLoadMore) _needsReload = true;
return;
}
if (isLoadMore && !_hasMore) return;
_isLoading = true;
_needsReload = false;
if (!isLoadMore) {
_hasMore = true;
}
try {
final cInfo = await DatabaseHelper.instance.getContact(widget.address);
final totalSmsCount = await DatabaseHelper.instance.getSmsCount(
widget.address,
);
if (mounted) setState(() => contactInfo = cInfo);
final previousOffset = _offset;
final fetchOffset = isLoadMore ? previousOffset : 0;
final rawSms = await DatabaseHelper.instance
.getPaginatedSms(widget.address, _limit, fetchOffset);
// 1. Process Database SMS
final List<ChatModel> processed = await compute(
(data) => MessageProcessor.processSmsList(
data['raw'] as List<Map<String, dynamic>>,
data['address'] as String,
),
{'raw': rawSms, 'address': widget.address},
);
// 2. Perform Unified Reconciliation
final currentMessages = List<ChatModel>.from(messages);
final baseMessages = isLoadMore
? currentMessages
: currentMessages
.where(_shouldKeepTransientMessageDuringReload)
.toList(growable: false);
final candidates = <_MessageCandidate>[
...baseMessages.map(
(m) => _MessageCandidate(
m,
preferCandidate: _shouldPreferInMemoryMessage(m),
),
),
];
// Add Database Messages
for (final msg in processed) {
if (msg.isSecure) {
final pBody =
await SecureMessagingService.instance.processIncomingSms(
widget.address,
msg.body,
broadcast: false,
isMe: msg.isMe,
isScan: false,
);
if (pBody != null && pBody != msg.body) {
msg.body = pBody;
}
_enrichSecureModel(msg);
}
candidates.add(_MessageCandidate(msg));
}
// Merge Pending Fragments from Cache
final pendingPacketRows = await SecureMessagingService.instance
.getPendingPacketsForPhone(widget.address);
for (final row in pendingPacketRows) {
final pId = row['msg_id'] as String;
final pMode = row['packet_mode'] as String? ?? 'SYM';
candidates.add(
_MessageCandidate(
ChatModel(
body: 'در حال دریافت قطعات...',
statusLabel:
'(${row['received_parts']}/${row['total_parts']}) دریافت قطعات',
packetId: pId,
packetMode: pMode,
date: row['first_seen'] as int? ?? 0,
isMe: (row['isMe'] as int? ?? 0) == 1,
status: MessageStatus.received,
isSecure: true,
isPendingMultipart: true,
),
),
);
}
// 3. Finalize List
final List<ChatModel> updatedList = _reconcileCandidates(candidates);
final nextOffset = isLoadMore
? previousOffset + rawSms.length
: max(previousOffset, rawSms.length);
_offset = min(totalSmsCount, nextOffset);
_hasMore = _offset < totalSmsCount;
_publishMessages(updatedList);
} finally {
_isLoading = false;
if (_needsReload) loadMessages();
}
}
Future<void> _openContactInfo() async {
if (!await FlutterContacts.requestPermission(readonly: true) || !mounted) {
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
final contacts = await FlutterContacts.getContacts(
withProperties: true, withPhoto: false);
final currentPhoneNormalized =
ContactHelper.normalizePhone(widget.address);
String? contactId;
for (final contact in contacts) {
for (final phone in contact.phones) {
if (ContactHelper.normalizePhone(phone.number) ==
currentPhoneNormalized) {
contactId = contact.id;
break;
}
}
if (contactId != null) break;
}
if (mounted) Navigator.pop(context);
if (contactId != null) {
await FlutterContacts.openExternalView(contactId);
} else {
await FlutterContacts.openExternalInsert(
Contact(phones: [Phone(widget.address)]));
}
if (mounted) {
setState(() {
displayName = ContactHelper.getName(widget.address);
});
}
} catch (_) {
if (mounted && Navigator.canPop(context)) Navigator.pop(context);
}
}
Future<void> _showFingerprintSheet() async {
final securityContext = await SecureMessagingService.instance
.getSecurityContext(widget.address);
if (!mounted || securityContext == null) return;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) {
final peerFingerprint = securityContext['peerFingerprint'] as String?;
final verificationState =
securityContext['verificationState'] as String? ?? 'unverified';
final stateText = verificationState == 'changed'
? 'وضعیت: کلید مخاطب تغییر کرده است'
: verificationState == 'verified'
? 'وضعیت: تایید شده'
: 'وضعیت: هنوز تایید نشده';
final stateColor = verificationState == 'changed'
? Colors.red.shade700
: verificationState == 'verified'
? Colors.green.shade700
: Colors.orange.shade400;
return Container(
decoration: BoxDecoration(
color: AppTheme.darkCard,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1), width: 0.5),
),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('تایید هویت و اثر انگشت کلید',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white)),
const SizedBox(height: 12),
Text('کلید شما:\n${securityContext['localFingerprint']}',
style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 12),
Text(
peerFingerprint == null
? 'هنوز کلید عمومی مخاطب دریافت نشده است.'
: 'کلید مخاطب:\n$peerFingerprint',
style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 12),
const Text(
'برای جلوگیری از MITM، این اثر انگشت را با تماس تلفنی یا یک کانال امن دیگر با مخاطب تطبیق دهید.',
style: TextStyle(height: 1.5, color: Colors.white60),
),
const SizedBox(height: 12),
Text(
stateText,
style: TextStyle(
color: stateColor,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: peerFingerprint == null
? null
: () async {
await SecureMessagingService.instance
.markFingerprintVerified(widget.address);
if (!mounted) return;
Navigator.of(this.context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(
content: Text(
'اثر انگشت کلید برای این مخاطب تایید شد.')),
);
loadMessages();
},
child: Text(verificationState == 'changed'
? 'تایید اثر انگشت جدید'
: 'تایید اثر انگشت'),
),
],
),
);
},
);
}
void _deleteSmsNative(int messageId) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
title: const Text('حذف پیام'),
content: const Text('آیا از حذف این پیام مطمئن هستید؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx), child: const Text('لغو')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, foregroundColor: Colors.white),
onPressed: () async {
Navigator.pop(ctx);
try {
await nativeChannel
.invokeMethod('deleteSms', {'id': messageId.toString()});
if (!mounted) return;
messages.removeWhere((m) => m.id == messageId);
_publishMessages(messages);
Future.delayed(const Duration(seconds: 1), loadMessages);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('درخواست حذف ارسال شد')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('خطا: $e')));
}
},
child: const Text('حذف'),
),
],
),
);
}
Future<void> sendMessage() async {
final text = _msgController.text.trim();
if (text.isEmpty) return;
if (_selectedSecurityLevel == 'symmetric') {
_showKeyEntryAndSend(text);
return;
}
if (_selectedSecurityLevel == 'asymmetric' && !_isAsymmetricReadyNow) {
final secureState = contactInfo?['secure_state'] as String? ?? 'none';
final verification =
contactInfo?['verification_state'] as String? ?? 'unverified';
final hasPeerKey =
(contactInfo?['peer_public_key'] as String?)?.isNotEmpty == true;
final hasSharedKey =
(contactInfo?['ecc_shared_key'] as String?)?.isNotEmpty == true;
final notice = verification == 'changed'
? 'کلید مخاطب تغییر کرده است؛ ابتدا اثر انگشت جدید را تایید کنید.'
: (hasPeerKey && hasSharedKey)
? 'کلید مخاطب دریافت شده اما هنوز تایید نشده است؛ ابتدا اثر انگشت را تایید کنید.'
: secureState == 'handshake_pending'
? 'تبادل کلید هنوز کامل نشده است.'
: 'ابتدا روی «ایجاد ارتباط امن» بزنید تا تبادل کلید انجام شود.';
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(notice)));
return;
}
await _executeSendMessage(text, securityLevel: _selectedSecurityLevel);
}
void _showKeyEntryAndSend(String text) {
final keyController =
TextEditingController(text: contactInfo?['symmetric_key'] ?? '');
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
title: const Text('رمزنگاری متقارن'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('کلید متقارن این پیام را وارد یا تایید کنید:'),
const SizedBox(height: 10),
TextField(
controller: keyController,
decoration: const InputDecoration(
hintText: 'کلید متقارن',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx), child: const Text('لغو')),
ElevatedButton(
onPressed: () {
if (keyController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('لطفاً کلید را وارد کنید')));
return;
}
Navigator.pop(ctx);
_executeSendMessage(text,
securityLevel: 'symmetric',
symmetricKey: keyController.text.trim());
},
child: const Text('ارسال'),
),
],
),
);
}
Future<void> _executeSendMessage(String text,
{required String securityLevel, String? symmetricKey}) async {
final earlyPacketId =
Random().nextInt(0xFFFFFFFF).toRadixString(16).padLeft(10, '0');
final tempTimestamp = DateTime.now().millisecondsSinceEpoch;
final draftLocalId =
'draft::$tempTimestamp::${Random().nextInt(1 << 32).toRadixString(16)}';
final tempMsg = ChatModel(
localId: draftLocalId,
body: text,
date: tempTimestamp,
isMe: true,
status: MessageStatus.sending,
isSecure: securityLevel != 'normal',
packetId: securityLevel == 'normal' ? null : earlyPacketId,
);
messages.insert(0, tempMsg);
_publishMessages(messages);
_msgController.clear();
try {
if (symmetricKey != null) {
tempMsg.rawBody = await SecureMessagingService.instance
.getEncryptedPreview(text, symmetricKey);
}
final result = await SecureMessagingService.instance.sendMessage(
widget.address,
text,
securityLevel: securityLevel,
symmetricKey: symmetricKey,
forcedPacketId: securityLevel == 'normal' ? null : earlyPacketId,
localTimestamp: tempTimestamp,
);
if (!mounted) return;
if (result['sentText'] == true) {
tempMsg.packetId = result['packetId'] as String?;
tempMsg.packetMode = result['packetMode'] as String?;
tempMsg.rawBody ??= result['protocolBody'] as String?;
tempMsg.rawViewBody ??= tempMsg.rawBody;
tempMsg.encryptedPayload ??= result['encryptedPayload'] as String?;
tempMsg.isPendingMultipart = result['notice'] == 'sent_fragmented';
tempMsg.status = MessageStatus.sent;
if (result['notice'] == 'sent') {
tempMsg.statusLabel = null;
} else {
tempMsg.statusLabel = 'ارسال کامل شد';
}
_replaceMessagesWithCandidates([
...messages.map((m) => _MessageCandidate(m)),
_MessageCandidate(tempMsg, preferCandidate: true),
]);
Future.delayed(const Duration(milliseconds: 800), loadMessages);
} else {
messages.remove(tempMsg);
_msgController.text = text;
_msgController.selection = TextSelection.fromPosition(
TextPosition(offset: _msgController.text.length));
_publishMessages(messages);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(result['notice'] as String)));
loadMessages();
}
} catch (e) {
if (!mounted) return;
setState(() {
tempMsg.status = MessageStatus.failed;
tempMsg.statusLabel = 'خطا در ارسال';
});
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('خطا در ارسال: $e')));
}
}
Future<void> _handleRetryDecryption(ChatModel msg) async {
final source = msg.rawBody ?? msg.body;
final payload =
await SecureMessagingService.instance.resolvePayloadForRetry(
source,
phone: widget.address,
hintedPayload: msg.body.contains(' ::PAYLOAD::')
? msg.body.split(' ::PAYLOAD::')[1]
: msg.encryptedPayload,
);
if (!mounted) return;
if (payload == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('payload پیام پیدا نشد.')));
return;
}
final keyController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
title: const Text('بازگشایی دستی پیام'),
content: TextField(
controller: keyController,
decoration: const InputDecoration(
hintText: 'کلید متقارن',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx), child: const Text('لغو')),
ElevatedButton(
onPressed: () async {
if (keyController.text.trim().isEmpty) return;
Navigator.pop(ctx);
final messenger = ScaffoldMessenger.of(context);
final decrypted =
await SecureMessagingService.instance.decryptWithKey(
source,
keyController.text.trim(),
phone: widget.address,
hintedPayload: payload,
);
if (!mounted) return;
if (decrypted != null) {
final isGroup = msg.rawBody?.contains('@G:') == true ||
msg.body.contains('@G:') == true ||
(msg.rawBody != null &&
ProtocolHelper.parseMessage(msg.rawBody!)['isGroup'] ==
true);
if (isGroup) {
final groups = await DatabaseHelper.instance
.getGroupsContainingPhone(widget.address);
for (final g in groups) {
await DatabaseHelper.instance.updateGroupKey(
g['id'] as int, keyController.text.trim());
}
} else {
await DatabaseHelper.instance.updateContactSecurity(
widget.address,
symmetricKey: keyController.text.trim(),
mode: 'symmetric',
state: 'ready',
);
}
setState(() {
msg.body = decrypted;
msg.encryptedPayload = payload;
msg.rawViewBody = _buildRawViewBody(
body: decrypted,
rawBody: msg.rawBody,
encryptedPayload: payload,
packetMode: msg.packetMode ?? 'SYM',
isSecure: true,
);
msg.canRetryDecryption = false;
msg.statusLabel = null;
msg.isSecure = true;
msg.isPendingMultipart = false;
});
messenger.showSnackBar(
const SnackBar(content: Text('بازگشایی موفقیت‌آمیز بود.')));
loadMessages();
} else {
messenger.showSnackBar(const SnackBar(
content: Text(
'بازگشایی ناموفق بود. کلید اشتباه است یا دیتا مخدوش شده است.')));
}
},
child: const Text('بازگشایی'),
),
],
),
);
}
Future<void> _makePhoneCall() async {
try {
await nativeChannel.invokeMethod('makeCall', {'phone': widget.address});
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('خطا در برقراری تماس')));
}
}
Widget _buildSecurityBanner() {
final status = _securityStatusText();
if (status.isEmpty || status == _dismissedBannerText) {
return const SizedBox.shrink();
}
final mode = contactInfo?['mode'] as String? ?? 'normal';
final secureState = contactInfo?['secure_state'] as String? ?? 'none';
final verification =
contactInfo?['verification_state'] as String? ?? 'unverified';
final isAsym =
mode == 'asymmetric' || _selectedSecurityLevel == 'asymmetric';
final hasSecurityAlert =
secureState == 'handshake_failed' || verification == 'changed';
final hasSecureConnection = isAsym && _isAsymmetricReadyNow;
final bg = hasSecurityAlert
? Colors.red.withValues(alpha: 0.8)
: (hasSecureConnection
? Colors.green.withValues(alpha: 0.8)
: Colors.blue.withValues(alpha: 0.8));
final border = Colors.white.withValues(alpha: 0.2);
const iconColor = Colors.white;
const textColor = Colors.white;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (Widget child, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.5),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: Container(
key: ValueKey(status),
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: bg.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: border, width: 0.5),
),
child: Row(
children: [
Icon(
hasSecurityAlert
? Icons.warning_amber_rounded
: (hasSecureConnection
? Icons.verified_user_rounded
: Icons.info_outline_rounded),
color: iconColor,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
status,
style: const TextStyle(
color: textColor,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: () =>
setState(() => _dismissedBannerText = status),
icon: const Icon(Icons.close,
color: Colors.white60, size: 18),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.darkBg,
extendBodyBehindAppBar: true,
resizeToAvoidBottomInset: true,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight + 8),
child: AppTheme.glassWrapper(
radius: 0,
sigma: 18,
child: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
title: InkWell(
onTap: _openContactInfo,
overlayColor: WidgetStateProperty.all(Colors.transparent),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Hero(
tag: 'avatar_${displayName}_ind',
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor,
Theme.of(context)
.primaryColor
.withValues(alpha: 0.7),
],
),
),
child: Center(
child: Text(
displayName.isNotEmpty ? displayName[0] : '?',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold),
),
),
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white),
),
if (displayName != widget.address)
Text(
widget.address,
style: const TextStyle(
fontSize: 11, color: Colors.white70),
),
],
),
],
),
),
actions: [
IconButton(
icon: const Icon(Icons.verified_user_outlined,
color: Colors.white, size: 22),
onPressed: _showFingerprintSheet,
),
IconButton(
icon: const Icon(Icons.call_outlined,
color: Colors.white, size: 22),
onPressed: _makePhoneCall,
),
],
),
),
),
body: Stack(
children: [
Positioned.fill(child: CustomPaint(painter: MeshBackgroundPainter())),
Column(
children: [
SizedBox(
height:
MediaQuery.of(context).padding.top + kToolbarHeight + 8),
_buildSecurityBanner(),
Expanded(
child: ValueListenableBuilder<List<ChatModel>>(
valueListenable: _messageNotifier,
builder: (context, messages, child) {
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mark_chat_unread_outlined,
size: 60,
color: Colors.white.withValues(alpha: 0.1)),
const SizedBox(height: 10),
const Text('آغاز گفتگو...',
style: TextStyle(
color: Colors.white38, fontSize: 13)),
],
),
);
}
return ListView.builder(
key: const PageStorageKey('chat_list_view'),
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 20),
itemCount: messages.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == messages.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(12.0),
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final msg = messages[index];
return GestureDetector(
onLongPress: () {
if (msg.id != null) {
_deleteSmsNative(msg.id!);
}
},
child: MessageBubble(
key: ValueKey(
msg.localId ??
_packetKey(msg.packetId, msg.packetMode) ??
msg.id?.toString() ??
'temp_${msg.date}',
),
body: msg.body,
rawBody: msg.rawViewBody ?? msg.rawBody,
statusLabel: msg.statusLabel,
date: msg.date,
isMe: msg.isMe,
status: msg.status,
isSecure: msg.isSecure,
canRetryDecryption: msg.canRetryDecryption,
onRetryDecryption: () =>
_handleRetryDecryption(msg),
packetMode: msg.packetMode,
isPendingMultipart: msg.isPendingMultipart,
),
);
},
);
},
),
),
AppTheme.glassWrapper(
radius: 0,
sigma: 15,
child: Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
border: Border(
top: BorderSide(
color: Colors.white.withValues(alpha: 0.08),
width: 0.5),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!_loadingSims && _simCards.length > 1)
SizedBox(
height: 35,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _simCards.length,
itemBuilder: (context, index) {
final sim = _simCards[index];
final isSelected = _selectedSim == sim;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(sim['displayName']?.toString() ??
'SIM ${index + 1}'),
selected: isSelected,
onSelected: (_) =>
setState(() => _selectedSim = sim),
),
);
},
),
),
const SizedBox(height: 8),
Row(
children: [
InkWell(
borderRadius: BorderRadius.circular(18),
onTap: _showSecurityModeSheet,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: _securityAccent(
_selectedSecurityLevel)
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_securityIcon(_selectedSecurityLevel),
size: 16,
color: _securityAccent(
_selectedSecurityLevel),
),
),
const SizedBox(width: 10),
Text(
_securityLabel(_selectedSecurityLevel),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.white),
),
const SizedBox(width: 8),
const Icon(Icons.expand_more_rounded,
size: 18, color: Colors.white70),
],
),
),
),
],
),
_buildAsymmetricActions(),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextField(
controller: _msgController,
minLines: 1,
maxLines: 4,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'پیام خود را بنویسید...',
hintStyle:
const TextStyle(color: Colors.white30),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 10),
FloatingActionButton(
heroTag: 'send_${widget.address}',
mini: true,
elevation: 4,
backgroundColor: Theme.of(context).primaryColor,
onPressed: _selectedSecurityLevel == 'asymmetric' &&
!_isAsymmetricReadyNow
? null
: sendMessage,
child: const Icon(Icons.send_rounded,
color: Colors.white, size: 20),
),
],
),
],
),
),
),
],
),
],
),
);
}
}