Время учитывает часовой пояс, опитимизирован запрос контакров

This commit is contained in:
Artur 2026-05-01 23:48:44 +05:00
parent 1a36cbccd3
commit 15af40fc64
15 changed files with 364 additions and 99 deletions

View File

@ -9,6 +9,7 @@ class Contact {
final bool isOnline; final bool isOnline;
final int unreadCount; final int unreadCount;
final String? publicKey; final String? publicKey;
final bool isLastMsgDecrypted;
Contact({ Contact({
required this.id, required this.id,
@ -21,6 +22,7 @@ class Contact {
this.isOnline = false, this.isOnline = false,
this.unreadCount = 0, this.unreadCount = 0,
this.publicKey, this.publicKey,
this.isLastMsgDecrypted = false,
}); });
Contact copyWith({ Contact copyWith({
@ -34,6 +36,7 @@ class Contact {
bool? isOnline, bool? isOnline,
int? unreadCount, int? unreadCount,
String? publicKey, String? publicKey,
bool? isLastMsgDecrypted,
}) { }) {
return Contact( return Contact(
id: id ?? this.id, id: id ?? this.id,
@ -46,6 +49,7 @@ class Contact {
isOnline: isOnline ?? this.isOnline, isOnline: isOnline ?? this.isOnline,
unreadCount: unreadCount ?? this.unreadCount, unreadCount: unreadCount ?? this.unreadCount,
publicKey: publicKey ?? this.publicKey, publicKey: publicKey ?? this.publicKey,
isLastMsgDecrypted: isLastMsgDecrypted ?? this.isLastMsgDecrypted,
); );
} }
@ -68,6 +72,7 @@ class Contact {
isOnline: (json['is_online'] ?? json['isOnline']) == true, isOnline: (json['is_online'] ?? json['isOnline']) == true,
unreadCount: int.tryParse((json['unread_count'] ?? json['unreadCount'] ?? 0).toString()) ?? 0, unreadCount: int.tryParse((json['unread_count'] ?? json['unreadCount'] ?? 0).toString()) ?? 0,
publicKey: json['public_key'], publicKey: json['public_key'],
isLastMsgDecrypted: json['is_last_msg_decrypted'] ?? false,
); );
} }
} }

View File

@ -10,6 +10,12 @@ class ContactRepository {
Future<List<Contact>> fetchChatContacts() async { Future<List<Contact>> fetchChatContacts() async {
final token = await _apiService.getAccessToken(); final token = await _apiService.getAccessToken();
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
if (token == null) { if (token == null) {
throw Exception('No access token'); throw Exception('No access token');
} }
@ -24,7 +30,14 @@ class ContactRepository {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes)); final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((json) => Contact.fromJson(json)).toList(); List<Contact> contacts = data.map((json) => Contact.fromJson(json)).toList();
for (var item in contacts) {
if (item.lastMessageTime != null) {
DateTime serverTime = item.lastMessageTime!;
item = item.copyWith(lastMessageTime: serverTime.add(offset));
}
}
return contacts;
} else { } else {
throw Exception('Failed to load contacts'); throw Exception('Failed to load contacts');
} }
@ -32,6 +45,10 @@ class ContactRepository {
Future<List<Contact>> fetchAllUsers() async { Future<List<Contact>> fetchAllUsers() async {
final token = await _apiService.getAccessToken(); final token = await _apiService.getAccessToken();
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
if (token == null) { if (token == null) {
throw Exception('No access token'); throw Exception('No access token');
} }
@ -46,7 +63,14 @@ class ContactRepository {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes)); final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((json) => Contact.fromJson(json)).toList(); List<Contact> contacts = data.map((json) => Contact.fromJson(json)).toList();
for (var contact in contacts) {
if (contact.lastMessageTime != null) {
DateTime serverTime = contact.lastMessageTime!;
contact = contact.copyWith(lastMessageTime: serverTime.add(offset));
}
}
return contacts;
} else { } else {
throw Exception('Failed to load contacts'); throw Exception('Failed to load contacts');
} }

View File

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:chepuhagram/data/models/contact_model.dart';
class CryptoService { class CryptoService {
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
@ -123,6 +124,117 @@ class CryptoService {
return base64Encode(nonce + encrypted.mac.bytes + encrypted.cipherText); return base64Encode(nonce + encrypted.mac.bytes + encrypted.cipherText);
} }
static Future<String> decryptInIsolate(
String base64Data,
SecretKey sharedKey,
) async {
final data = base64Decode(base64Data);
final aesGcm = AesGcm.with256bits();
final nonce = data.sublist(0, 12);
final mac = data.sublist(12, 28);
final cipherText = data.sublist(28);
final decrypted = await aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: sharedKey,
);
return utf8.decode(decrypted);
}
static Future<List<Contact>> bulkDecryptContacts(
Map<String, dynamic> data,
) async {
final List<Contact> contacts = data['contacts'];
final String privKey = data['privKey'];
final Map<int, SecretKey> cache = data['cache'];
final x25519 = X25519();
final aesGcm = AesGcm.with256bits();
final List<Contact> result = [];
// Вычисляем свою пару один раз
final myKeyPair = await x25519.newKeyPairFromSeed(base64Decode(privKey));
for (var contact in contacts) {
if (contact.lastMessage == null || contact.publicKey == null) {
result.add(contact);
continue;
}
try {
SecretKey sharedKey;
if (cache.containsKey(contact.id)) {
sharedKey = cache[contact.id]!;
} else {
final theirPubKey = SimplePublicKey(
base64Decode(contact.publicKey!),
type: KeyPairType.x25519,
);
sharedKey = await x25519.sharedSecretKey(
keyPair: myKeyPair,
remotePublicKey: theirPubKey,
);
}
// Дешифровка AES-GCM
final msgData = base64Decode(contact.lastMessage!);
final decrypted = await aesGcm.decrypt(
SecretBox(
msgData.sublist(28),
nonce: msgData.sublist(0, 12),
mac: Mac(msgData.sublist(12, 28)),
),
secretKey: sharedKey,
);
result.add(
contact.copyWith(
lastMessage: utf8.decode(decrypted),
isLastMsgDecrypted: true,
),
);
} catch (_) {
result.add(contact);
}
}
return result;
}
static Future<Map<int, List<int>>> computeSharedKeysTask(
Map<String, dynamic> params,
) async {
final Map<int, String> isolateKeysMap = params['keysMap'];
final String isolatePrivKey = params['privKey'];
final x25519 = X25519();
final Map<int, List<int>> result = {};
final myKeyPair = await x25519.newKeyPairFromSeed(
base64Decode(isolatePrivKey),
);
for (var entry in isolateKeysMap.entries) {
try {
final theirPubKey = SimplePublicKey(
base64Decode(entry.value),
type: KeyPairType.x25519,
);
final sharedKey = await x25519.sharedSecretKey(
keyPair: myKeyPair,
remotePublicKey: theirPubKey,
);
result[entry.key] = await sharedKey.extractBytes();
} catch (_) {
continue;
}
}
return result;
}
Future<(List<int>, String)?> encryptImage( Future<(List<int>, String)?> encryptImage(
List<int> fileBytes, List<int> fileBytes,
SecretKey sharedKey, SecretKey sharedKey,

View File

@ -3,11 +3,18 @@ import '/data/models/contact_model.dart';
import '/data/repositories/contact_repository.dart'; import '/data/repositories/contact_repository.dart';
import '/data/datasources/local_db_service.dart'; import '/data/datasources/local_db_service.dart';
import '/domain/services/crypto_service.dart'; import '/domain/services/crypto_service.dart';
import 'dart:isolate';
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart';
class ContactProvider extends ChangeNotifier { class ContactProvider extends ChangeNotifier {
final ContactRepository _repository = ContactRepository(); final ContactRepository _repository = ContactRepository();
final LocalDbService _localDbService = LocalDbService(); final LocalDbService _localDbService = LocalDbService();
final CryptoService _cryptoService = CryptoService(); final CryptoService _cryptoService;
ContactProvider(this._cryptoService);
final Map<int, SecretKey> _sharedKeysCache = {};
List<Contact> _contacts = []; List<Contact> _contacts = [];
List<Contact> _allContacts = []; List<Contact> _allContacts = [];
bool _isLoading = false; bool _isLoading = false;
@ -19,6 +26,11 @@ class ContactProvider extends ChangeNotifier {
List<Contact> get allContacts => _allContacts; List<Contact> get allContacts => _allContacts;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
String? get error => _error; String? get error => _error;
Map<int, SecretKey> get sharedKeysCache => _sharedKeysCache;
void setSharedKey(int contactId, SecretKey key) {
_sharedKeysCache[contactId] = key;
}
void setCurrentUserId(int? id) { void setCurrentUserId(int? id) {
_currentUserId = id; _currentUserId = id;
@ -29,7 +41,7 @@ class ContactProvider extends ChangeNotifier {
return _currentUserId; return _currentUserId;
} }
Future<void> loadContacts() async { Future<void> loadContacts({bool enrichContacts = true}) async {
if (_isFirstLoad) { if (_isFirstLoad) {
_isFirstLoad = false; _isFirstLoad = false;
_isLoading = true; _isLoading = true;
@ -39,21 +51,23 @@ class ContactProvider extends ChangeNotifier {
try { try {
final allContacts = await _repository.fetchChatContacts(); final allContacts = await _repository.fetchChatContacts();
// Фильтруем: исключаем себя (для основного списка - только чаты) final userIdCopy = _currentUserId;
_contacts = allContacts _contacts = await Isolate.run(() {
.where((contact) => contact.id != _currentUserId) return allContacts
.toList(); .where((contact) => contact.id != userIdCopy)
.toList();
});
_allContacts = _contacts; _allContacts = _contacts;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
// Обогащаем превью последним сообщением из локальной БД, не блокируя UI. if (enrichContacts) {
_enrichContactsWithLastMessages(); await _enrichContactsWithLastMessages();
}
} catch (e) { } catch (e) {
_error = e.toString(); _error = e.toString();
print("❌ ОШИБКА ПРИ ЗАГРУЗКЕ КОНТАКТОВ: $_error");
} finally { } finally {
// Если ошибка выходим из состояния загрузки тут.
// Если всё ок `_isLoading` уже сброшен выше, чтобы показать список быстрее.
if (_error != null) { if (_error != null) {
_isLoading = false; _isLoading = false;
} }
@ -61,7 +75,6 @@ class ContactProvider extends ChangeNotifier {
} }
} }
// Метод для получения всех контактов (исключая себя) для нового чата
Future<void> loadAllContactsForNewChat() async { Future<void> loadAllContactsForNewChat() async {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
@ -69,7 +82,6 @@ class ContactProvider extends ChangeNotifier {
try { try {
final allContacts = await _repository.fetchAllUsers(); final allContacts = await _repository.fetchAllUsers();
// Фильтруем только исключение самого себя
_allContacts = allContacts _allContacts = allContacts
.where((contact) => contact.id != _currentUserId) .where((contact) => contact.id != _currentUserId)
.toList(); .toList();
@ -82,42 +94,30 @@ class ContactProvider extends ChangeNotifier {
} }
Future<void> _enrichContactsWithLastMessages() async { Future<void> _enrichContactsWithLastMessages() async {
print("Начинаем обогащать контакты последними сообщениями из локальной БД... Для текущего пользователя ID: $_currentUserId"); final myPrivKeyBase64 = await _cryptoService.getPrivateKey();
final myId = _currentUserId; if (myPrivKeyBase64 == null) return;
if (myId == null) return;
print("Текущий пользователь ID: $myId");
final myPrivKey = await _cryptoService.getPrivateKey(); // Создаем локальные копии для передачи
final contactsToProcess = List<Contact>.from(_contacts);
final cacheCopy = Map<int, SecretKey>.from(_sharedKeysCache);
final List<Contact> updated = List<Contact>.from(_contacts); print('Avialable cache for contacts: ${cacheCopy.length}');
for (int i = 0; i < updated.length; i++) { try {
final contact = updated[i]; final updatedContacts = await compute(
CryptoService.bulkDecryptContacts,
{
'contacts': contactsToProcess,
'privKey': myPrivKeyBase64,
'cache': cacheCopy,
},
);
// 1) Если сервер уже прислал lastMessage попробуем расшифровать превью. _contacts = updatedContacts;
print(contact.lastMessage);
if (contact.lastMessage != null &&
contact.lastMessage!.isNotEmpty &&
myPrivKey != null &&
contact.publicKey != null) {
try {
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
contact.publicKey!,
);
final decrypted = await _cryptoService.decryptMessage(
contact.lastMessage!,
sharedSecret,
);
updated[i] = contact.copyWith(lastMessage: decrypted);
} catch (_) {
// Если расшифровать не удалось оставляем как есть, дальше попробуем локальную БД.
}
}
}
_contacts = updated;
_allContacts = updated;
notifyListeners(); notifyListeners();
} catch (e) {
print("Ошибка дешифровки: $e");
} }
} }
}

View File

@ -22,15 +22,12 @@ final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>(); final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
// Глобальная переменная для отслеживания текущего активного контакта в чате
int? currentActiveChatContactId; int? currentActiveChatContactId;
// Глобальная переменная для хранения начального сообщения (при запуске из уведомления)
RemoteMessage? initialMessage; RemoteMessage? initialMessage;
// Ключ для SharedPreferences // Ключ для SharedPreferences
const String _notificationLaunchKey = 'notification_launch_data'; const String _notificationLaunchKey = 'notification_launch_data';
// Защита от повторной обработки одного и того же payload при следующих запусках по иконке
const String _lastHandledNotificationLaunchPayloadKey = const String _lastHandledNotificationLaunchPayloadKey =
'notification_last_handled_payload'; 'notification_last_handled_payload';
@ -49,9 +46,6 @@ Future<void> _onSelectNotification(
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final canonicalPayload = jsonEncode(data); final canonicalPayload = jsonEncode(data);
// Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат.
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
// будет снова автопереходить в чат.
if (context == null) { if (context == null) {
final lastHandled = prefs.getString( final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey, _lastHandledNotificationLaunchPayloadKey,
@ -70,7 +64,6 @@ Future<void> _onSelectNotification(
await prefs.remove(_notificationLaunchKey); await prefs.remove(_notificationLaunchKey);
} }
// Navigate to chat with this contact (if context is ready)
_navigateToChat(senderId); _navigateToChat(senderId);
} else { } else {
print( print(
@ -135,8 +128,6 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(); await Firebase.initializeApp();
// Проверяем, было ли приложение запущено из уведомления
// Добавляем небольшую задержку, чтобы Firebase полностью инициализировался
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
initialMessage = await FirebaseMessaging.instance.getInitialMessage(); initialMessage = await FirebaseMessaging.instance.getInitialMessage();
print('Initial message from main() after delay: $initialMessage'); print('Initial message from main() after delay: $initialMessage');
@ -226,10 +217,17 @@ void main() async {
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
Provider(create: (_) => CryptoService()),
Provider(create: (_) => SocketService()),
ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()), ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (_) => ContactProvider()),
Provider(create: (_) => SocketService()), Provider(create: (_) => SocketService()),
ChangeNotifierProvider(
create: (context) => ContactProvider(
context.read<CryptoService>(),
),
),
], ],
child: const MyApp(), child: const MyApp(),
), ),

View File

@ -494,6 +494,10 @@ class _ChatScreenState extends State<ChatScreen> {
} }
_replyTo = null; _replyTo = null;
}); });
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: targetContact,)),
);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -754,11 +758,16 @@ class _ChatScreenState extends State<ChatScreen> {
} }
void _handleIncomingMessage(Map<String, dynamic> data) async { void _handleIncomingMessage(Map<String, dynamic> data) async {
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
// ACK от сервера: сообщение сохранено и получило server_id // ACK от сервера: сообщение сохранено и получило server_id
if (data['type'] == 'message_sent') { if (data['type'] == 'message_sent') {
final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
final serverId = int.tryParse(data['server_id']?.toString() ?? ''); final serverId = int.tryParse(data['server_id']?.toString() ?? '');
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); var ts = DateTime.tryParse(
data['timestamp']?.toString() ?? '',
)?.add(offset);
if (tempId == null) return; if (tempId == null) return;
@ -779,7 +788,9 @@ class _ChatScreenState extends State<ChatScreen> {
if (data['type'] == 'message_delivered' && data.containsKey('temp_id')) { if (data['type'] == 'message_delivered' && data.containsKey('temp_id')) {
final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
final serverId = int.tryParse(data['server_id']?.toString() ?? ''); final serverId = int.tryParse(data['server_id']?.toString() ?? '');
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); var ts = DateTime.tryParse(
data['timestamp']?.toString() ?? '',
)?.add(offset);
if (tempId == null) return; if (tempId == null) return;
@ -799,7 +810,9 @@ class _ChatScreenState extends State<ChatScreen> {
// Доставка онлайн (получатель был в сети) // Доставка онлайн (получатель был в сети)
if (data['type'] == 'message_delivered') { if (data['type'] == 'message_delivered') {
final messageId = int.tryParse(data['message_id']?.toString() ?? ''); final messageId = int.tryParse(data['message_id']?.toString() ?? '');
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); var ts = DateTime.tryParse(
data['timestamp']?.toString() ?? '',
)?.add(offset);
if (messageId == null) return; if (messageId == null) return;
if (!mounted) return; if (!mounted) return;
@ -821,7 +834,9 @@ class _ChatScreenState extends State<ChatScreen> {
if (data['type'] == 'message_edited') { if (data['type'] == 'message_edited') {
final messageId = int.tryParse(data['message_id']?.toString() ?? ''); final messageId = int.tryParse(data['message_id']?.toString() ?? '');
final ts = DateTime.tryParse(data['edited_at']?.toString() ?? ''); var ts = DateTime.tryParse(
data['edited_at']?.toString() ?? '',
)?.add(offset);
if (messageId == null) return; if (messageId == null) return;
final myPrivKey = await _cryptoService.getPrivateKey(); final myPrivKey = await _cryptoService.getPrivateKey();
@ -871,7 +886,9 @@ class _ChatScreenState extends State<ChatScreen> {
if (data['type'] == 'message_read') { if (data['type'] == 'message_read') {
final messageId = int.tryParse(data['message_id'].toString()); final messageId = int.tryParse(data['message_id'].toString());
if (messageId == null) return; if (messageId == null) return;
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); var ts = DateTime.tryParse(
data['timestamp']?.toString() ?? '',
)?.add(offset);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -943,15 +960,14 @@ class _ChatScreenState extends State<ChatScreen> {
isMe: false, isMe: false,
senderId: senderId, senderId: senderId,
receiverId: myId, receiverId: myId,
createdAt: DateTime.parse(data['timestamp']), createdAt: DateTime.parse(data['timestamp']).add(offset),
status: MessageStatus.delivered, status: MessageStatus.delivered,
replyToId: data['reply_to_id'] == null replyToId: data['reply_to_id'] == null
? null ? null
: int.tryParse(data['reply_to_id'].toString()), : int.tryParse(data['reply_to_id'].toString()),
replyToText: replyToText: data['reply_to_text'] != null
data['reply_to_text'] != null ? data['reply_to_text'].toString()
? data['reply_to_text'].toString() : null,
: null,
), ),
); );
}); });
@ -968,6 +984,9 @@ class _ChatScreenState extends State<ChatScreen> {
} }
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey); await prefs.remove(_notificationLaunchKey);
@ -992,10 +1011,10 @@ class _ChatScreenState extends State<ChatScreen> {
final deliveredAt = msg['delivered_at'] == null final deliveredAt = msg['delivered_at'] == null
? null ? null
: DateTime.tryParse(msg['delivered_at'].toString()); : DateTime.tryParse(msg['delivered_at'].toString())?.add(offset);
final readAt = msg['read_at'] == null final readAt = msg['read_at'] == null
? null ? null
: DateTime.tryParse(msg['read_at'].toString()); : DateTime.tryParse(msg['read_at'].toString())?.add(offset);
MessageStatus status = (msg['sender_id'] == myId) MessageStatus status = (msg['sender_id'] == myId)
? MessageStatus.sent ? MessageStatus.sent
@ -1015,7 +1034,7 @@ class _ChatScreenState extends State<ChatScreen> {
isMe: msg['sender_id'] == myId, isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'], senderId: msg['sender_id'],
receiverId: msg['receiver_id'], receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']), createdAt: DateTime.parse(msg['timestamp']).add(offset),
status: status, status: status,
replyToId: msg['reply_to_id'] == null replyToId: msg['reply_to_id'] == null
? null ? null
@ -1024,7 +1043,7 @@ class _ChatScreenState extends State<ChatScreen> {
? msg['reply_to_text'].toString() ? msg['reply_to_text'].toString()
: null, : null,
editedAt: msg['edited_at'] != null editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString()) ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null, : null,
), ),
); );
@ -1059,10 +1078,10 @@ class _ChatScreenState extends State<ChatScreen> {
final deliveredAt = msg['delivered_at'] == null final deliveredAt = msg['delivered_at'] == null
? null ? null
: DateTime.tryParse(msg['delivered_at'].toString()); : DateTime.tryParse(msg['delivered_at'].toString())?.add(offset);
final readAt = msg['read_at'] == null final readAt = msg['read_at'] == null
? null ? null
: DateTime.tryParse(msg['read_at'].toString()); : DateTime.tryParse(msg['read_at'].toString())?.add(offset);
MessageStatus status = (msg['sender_id'] == myId) MessageStatus status = (msg['sender_id'] == myId)
? MessageStatus.sent ? MessageStatus.sent
@ -1083,7 +1102,7 @@ class _ChatScreenState extends State<ChatScreen> {
isMe: msg['sender_id'] == myId, isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'], senderId: msg['sender_id'],
receiverId: msg['receiver_id'], receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']), createdAt: DateTime.parse(msg['timestamp']).add(offset),
status: status, status: status,
replyToId: msg['reply_to_id'] == null replyToId: msg['reply_to_id'] == null
? null ? null
@ -1092,12 +1111,13 @@ class _ChatScreenState extends State<ChatScreen> {
? msg['reply_to_text'].toString() ? msg['reply_to_text'].toString()
: null, : null,
editedAt: msg['edited_at'] != null editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString()) ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null, : null,
), ),
); );
} }
try { try {
await _localDbService.deleteChatHistory(widget.contact.id, myId);
await _localDbService.saveMessages(history); await _localDbService.saveMessages(history);
} catch (e) { } catch (e) {
print("Ошибка сохранения истории в локальную базу: $e"); print("Ошибка сохранения истории в локальную базу: $e");
@ -1119,7 +1139,6 @@ class _ChatScreenState extends State<ChatScreen> {
Provider.of<SocketService>(context, listen: false).sendReadReceipt(id); Provider.of<SocketService>(context, listen: false).sendReadReceipt(id);
_sentReadReceipts.add(id); _sentReadReceipts.add(id);
} }
await _localDbService.deleteChatHistory(widget.contact.id, myId);
} catch (e) { } catch (e) {
print("Ошибка загрузки истории: $e"); print("Ошибка загрузки истории: $e");
if (!mounted) return; if (!mounted) return;

View File

@ -55,21 +55,28 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
'Setting current user ID in ContactProvider: ${authProvider.currentUserId}', 'Setting current user ID in ContactProvider: ${authProvider.currentUserId}',
); );
contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadContacts().then((_) { _initContacts();
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
// После загрузки контактов проверить, нужно ли перейти к чату
if (widget.targetChatId != null) {
_navigateToTargetChat();
} else {
_checkSavedNotificationTarget();
}
});
}); });
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAppUpdate(); _checkAppUpdate();
}); });
} }
Future<void> _initContacts() async {
final contactProvider = context.read<ContactProvider>();
// Ждем завершения загрузки контактов
await contactProvider.loadContacts();
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
// Дальнейшая логика выполнится только после того, как loadContacts завершится
if (widget.targetChatId != null) {
_navigateToTargetChat();
} else {
_checkSavedNotificationTarget();
}
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -15,6 +16,13 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:chepuhagram/main.dart'; import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:cryptography/cryptography.dart';
import 'package:chepuhagram/data/repositories/contact_repository.dart ';
import 'package:chepuhagram/data/models/contact_model.dart';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:convert/convert.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -29,6 +37,8 @@ class _SplashScreenState extends State<SplashScreen> {
// Ключ для SharedPreferences // Ключ для SharedPreferences
static const String _notificationLaunchKey = 'notification_launch_data'; static const String _notificationLaunchKey = 'notification_launch_data';
static const String _contactPublicKey = 'contact_public_key_';
static const String _contactSharedKey = 'contact_shared_key_';
@override @override
void initState() { void initState() {
@ -117,12 +127,59 @@ class _SplashScreenState extends State<SplashScreen> {
// Проверяем, было ли приложение запущено из уведомления // Проверяем, было ли приложение запущено из уведомления
int? targetChatId = int? targetChatId =
_targetChatId; // Сначала проверяем из onMessageOpenedApp _targetChatId; // Сначала проверяем из onMessageOpenedApp
// Если не установлено, проверяем SharedPreferences
if (targetChatId == null) { if (targetChatId == null) {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final savedData = prefs.getString(_notificationLaunchKey); final savedData = prefs.getString(_notificationLaunchKey);
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts(enrichContacts: false);
final myPrivKeyBase64 = await context
.read<CryptoService>()
.getPrivateKey();
if (myPrivKeyBase64 != null) {
final Map<int, String> keysToCompute = {};
for (var c in contactProvider.contacts) {
final savedKeyHex = prefs.getString(
'$_contactSharedKey${c.id}',
);
final savedPubKey = prefs.getString(
'$_contactPublicKey${c.id}',
);
if (savedKeyHex != null && savedPubKey == c.publicKey) {
final bytes = base64Decode(
savedKeyHex,
);
contactProvider.setSharedKey(c.id, SecretKey(bytes));
} else if (c.publicKey != null) {
keysToCompute[c.id] = c.publicKey!;
}
}
print(
'Contacts with keys for isolate: ${keysToCompute.keys.toList()}',
);
final String privKey = myPrivKeyBase64;
final computedKeys = await compute(
CryptoService.computeSharedKeysTask,
{'keysMap': keysToCompute, 'privKey': privKey},
);
computedKeys.forEach((id, bytes) {
contactProvider.setSharedKey(id, SecretKey(bytes));
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
});
}
} catch (e) {
print("Ошибка при загрузке контактов или вычислении ключей: $e");
}
// Если не установлено, проверяем SharedPreferences
if (savedData != null) { if (savedData != null) {
try { try {
final data = jsonDecode(savedData) as Map<String, dynamic>; final data = jsonDecode(savedData) as Map<String, dynamic>;
@ -178,7 +235,7 @@ class _SplashScreenState extends State<SplashScreen> {
try { try {
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts(); await contactProvider.loadContacts(enrichContacts: false);
final contact = contactProvider.contacts.firstWhere( final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId, (c) => c.id == targetChatId,

View File

@ -46,7 +46,7 @@ class ContactTile extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
subtitle: Text( subtitle: Text(
contact.lastMessage ?? "Нет сообщений", contact.isLastMsgDecrypted ? contact.lastMessage ?? "Нет сообщений" : (contact.lastMessage != null ? "Ожидание дешифровки..." : "Нет сообщений"),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey), style: const TextStyle(color: Colors.grey),

View File

@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: "direct main"
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:

View File

@ -55,6 +55,7 @@ dependencies:
dio: ^5.9.2 dio: ^5.9.2
package_info_plus: ^9.0.1 package_info_plus: ^9.0.1
open_filex: ^4.3.2 open_filex: ^4.3.2
convert: ^3.1.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -6,7 +6,7 @@ from app.core.security import get_current_user
from app.api import schemas from app.api import schemas
from sqlalchemy import or_, and_, exists from sqlalchemy import or_, and_, exists
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.websocket import connection_manager
# бд # бд
def get_db(): def get_db():
@ -146,7 +146,8 @@ async def update_privacy_settings(
user_to_update.show_about = 1 if data.show_about else 0 user_to_update.show_about = 1 if data.show_about else 0
if data.show_username is not None: if data.show_username is not None:
user_to_update.show_username = 1 if data.show_username else 0 user_to_update.show_username = 1 if data.show_username else 0
if data.show_last_online is not None:
user_to_update.show_last_online = 1 if data.show_last_online else 0
try: try:
db.commit() db.commit()
except Exception: except Exception:
@ -169,6 +170,7 @@ async def get_privacy_settings(current_user: models.User = Depends(get_current_u
"show_avatar": bool(current_user.show_avatar), "show_avatar": bool(current_user.show_avatar),
"show_about": bool(current_user.show_about), "show_about": bool(current_user.show_about),
"show_username": bool(current_user.show_username), "show_username": bool(current_user.show_username),
"show_last_online": bool(current_user.show_last_online),
} }
@ -288,5 +290,12 @@ def get_user_by_id(
if user.show_email: if user.show_email:
profile_data["email"] = user.email profile_data["email"] = user.email
if user.id in connection_manager.active_connections:
profile_data["online"] = True
else:
profile_data["online"] = False
if user.show_last_online:
profile_data["last_online"] = user.last_online.isoformat() if user.last_online else None
return profile_data return profile_data

View File

@ -32,6 +32,8 @@ class User(Base):
show_avatar = Column(Integer, nullable=False, server_default="1") show_avatar = Column(Integer, nullable=False, server_default="1")
show_about = Column(Integer, nullable=False, server_default="1") show_about = Column(Integer, nullable=False, server_default="1")
show_username = Column(Integer, nullable=False, server_default="1") show_username = Column(Integer, nullable=False, server_default="1")
show_last_online = Column(Integer, nullable=False, server_default="1")
last_online = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class Message(Base): class Message(Base):
__tablename__ = "messages" __tablename__ = "messages"
@ -93,6 +95,11 @@ def _ensure_sqlite_user_columns():
conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1")) conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1"))
if "show_username" not in existing: if "show_username" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1")) conn.execute(text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1"))
if "show_last_online" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_last_online INTEGER DEFAULT 1"))
if "last_online" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN last_online DATETIME"))
conn.execute(text("UPDATE users SET last_online = datetime('now')"))
conn.commit() conn.commit()

View File

@ -1,14 +1,15 @@
from fastapi import HTTPException, status, APIRouter, WebSocket, WebSocketDisconnect, Query, Depends from fastapi import HTTPException, status, APIRouter, WebSocket, WebSocketDisconnect, Query, Depends
from app.core.security import test_token from app.core.security import test_token
from typing import Dict from typing import Dict
from datetime import datetime from datetime import datetime, timezone
import json import json
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import models from app.db import models
from firebase_admin import messaging, credentials, exceptions from firebase_admin import messaging, credentials, exceptions
import firebase_admin import firebase_admin
cred = credentials.Certificate("chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json") cred = credentials.Certificate(
"chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
firebase_admin.initialize_app(cred) firebase_admin.initialize_app(cred)
# бд # бд
@ -40,16 +41,25 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
print("ПОДКЛЮЧЕНИЕ") print("ПОДКЛЮЧЕНИЕ")
await manager.connect(user_id, websocket) await manager.connect(user_id, websocket)
print("ПОДКЛЮЧЕНО") print("ПОДКЛЮЧЕНО")
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
synchronize_session="fetch")
db.commit()
try: try:
while True: while True:
print("ОЖИДАНИЕ СООБЩЕНИЙ") print("ОЖИДАНИЕ СООБЩЕНИЙ")
data = await websocket.receive_text() data = await websocket.receive_text()
message_data = json.loads(data) message_data = json.loads(data)
print(f"DEBUG: Получены данные: {message_data}") print(f"DEBUG: Получены данные: {message_data}")
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
synchronize_session="fetch")
db.commit()
if message_data.get("type") == "private_message": if message_data.get("type") == "private_message":
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(
models.User.id == user_id).first()
receiver_id = message_data.get("receiver_id") receiver_id = message_data.get("receiver_id")
temp_id = message_data.get("temp_id") temp_id = message_data.get("temp_id")
content = message_data.get("content") content = message_data.get("content")
@ -150,7 +160,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"detail": "message_id must be int", "detail": "message_id must be int",
}) })
continue continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first() msg = db.query(models.Message).filter(
models.Message.id == message_id).first()
if msg is None or msg.sender_id != user_id: if msg is None or msg.sender_id != user_id:
continue continue
try: try:
@ -186,7 +197,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"detail": "message_id must be int", "detail": "message_id must be int",
}) })
continue continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first() msg = db.query(models.Message).filter(
models.Message.id == message_id).first()
if msg is None or msg.sender_id != user_id: if msg is None or msg.sender_id != user_id:
continue continue
receiver_id = msg.receiver_id receiver_id = msg.receiver_id
@ -210,7 +222,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first() msg = db.query(models.Message).filter(
models.Message.id == message_id).first()
if msg is None: if msg is None:
continue continue
@ -237,10 +250,15 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
pass pass
finally: finally:
manager.disconnect(user_id) manager.disconnect(user_id)
db.query(models.User).filter(models.User.id == user_id).update(
{"last_online": datetime.now()})
db.commit()
print("ОТКЛЮЧЕНИЕ")
def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp): def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp):
print(f"DEBUG: Отправляем FCM уведомление пользователю {user_id} с токеном {token}") print(
f"DEBUG: Отправляем FCM уведомление пользователю {user_id} с токеном {token}")
message = messaging.Message( message = messaging.Message(
data={ data={
"type": "enc_message", "type": "enc_message",

View File

@ -10,7 +10,7 @@ app = FastAPI()
app.include_router(auth.authRouter) app.include_router(auth.authRouter)
app.include_router(users.usersRouter) app.include_router(users.usersRouter)
app.include_router(messages.messagesRouter) app.include_router(messages.messagesRouter)
app.include_router(media.mediaRouter) #app.include_router(media.mediaRouter)
app.include_router(wsRouter) app.include_router(wsRouter)
app.add_middleware( app.add_middleware(