2233 lines
84 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|