Отправка фотографий

This commit is contained in:
Artur 2026-05-03 17:36:04 +05:00
parent ee7d325856
commit 981d322e1d
27 changed files with 1274 additions and 190 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,7 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:chepuhagram/data/models/message_model.dart';
import 'dart:typed_data';
class LocalDbService {
static final LocalDbService _instance = LocalDbService._internal();
@ -19,7 +20,7 @@ class LocalDbService {
String path = join(await getDatabasesPath(), 'chat_app.db');
return await openDatabase(
path,
version: 4,
version: 7,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE messages(
@ -32,7 +33,10 @@ class LocalDbService {
read_at TEXT,
reply_to_id INTEGER,
reply_to_text TEXT,
edited_at TEXT
edited_at TEXT,
message_type TEXT DEFAULT 'text',
file_id TEXT,
encrypted_key TEXT
)
''');
},
@ -52,6 +56,38 @@ class LocalDbService {
if (oldVersion < 4) {
await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT');
}
if (oldVersion < 5) {
try {
await db.execute(
'ALTER TABLE messages ADD COLUMN message_type TEXT',
);
} catch (e) {
print('message_type column already exists: $e');
}
try {
await db.execute('ALTER TABLE messages ADD COLUMN file_id TEXT');
} catch (e) {
print('file_id column already exists: $e');
}
}
if (oldVersion < 6) {
try {
await db.execute(
'ALTER TABLE messages ADD COLUMN encrypted_key TEXT',
);
} catch (e) {
print('encrypted_key column already exists: $e');
}
}
if (oldVersion < 7) {
try {
await db.execute(
'ALTER TABLE messages ADD COLUMN local_file_bytes BLOB',
);
} catch (e) {
print('local_file_bytes column already exists: $e');
}
}
},
);
}
@ -61,23 +97,36 @@ class LocalDbService {
await db.delete('messages');
}
// Сохранение списка сообщений (из истории)
Future<void> saveMessages(List<dynamic> messages) async {
final db = await database;
final List<int> incomingIds = messages.map((msg) {
return (msg is MessageModel) ? msg.id! : (msg['id'] as int);
}).toList();
Batch batch = db.batch();
if (incomingIds.isNotEmpty) {
batch.delete('messages', where: 'id NOT IN (${incomingIds.join(',')})');
}
for (var msg in messages) {
if (msg is MessageModel) {
batch.insert('messages', {
'id': msg.id,
'sender_id': msg.senderId,
'receiver_id': msg.receiverId,
'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст!
'content': msg.text,
'timestamp': msg.createdAt.toIso8601String(),
'delivered_at': null,
'read_at': null,
'reply_to_id': msg.replyToId,
'reply_to_text': msg.replyToText,
'edited_at': msg.editedAt?.toIso8601String(),
'message_type': msg.messageType == MessageType.image
? 'image'
: 'text',
'file_id': msg.fileId,
'encrypted_key': msg.encryptedFileKey,
'local_file_bytes': msg.localFileBytes,
}, conflictAlgorithm: ConflictAlgorithm.replace);
} else {
// Если это Map из API
@ -93,6 +142,9 @@ class LocalDbService {
'reply_to_id': msg['reply_to_id'],
'reply_to_text': msg['reply_to_text'],
'edited_at': msg['edited_at'],
'message_type': msg['message_type'] ?? 'text',
'file_id': msg['file_id'],
'encrypted_key': msg['encrypted_key'],
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
@ -159,6 +211,19 @@ class LocalDbService {
);
}
Future<void> updateMessageLocalFileBytes(
int messageId,
Uint8List localFileBytes,
) async {
final db = await database;
await db.update(
'messages',
{'local_file_bytes': localFileBytes},
where: 'id = ?',
whereArgs: [messageId],
);
}
Future<void> updateMessageContent(
int messageId,
String content,

View File

@ -6,8 +6,8 @@ class Contact {
String name;
String surname;
final String? lastMessage;
final String? avatarFileId;
final String? avatarUrl;
String? avatarFileId;
String? avatarUrl;
final DateTime? lastMessageTime;
final bool isOnline;
final int unreadCount;
@ -51,8 +51,8 @@ class Contact {
name: name ?? this.name,
surname: surname ?? this.surname,
lastMessage: lastMessage ?? this.lastMessage,
avatarFileId: avatarFileId ?? this.avatarFileId,
avatarUrl: avatarUrl ?? this.avatarUrl,
avatarFileId: avatarFileId,
avatarUrl: avatarUrl,
lastMessageTime: lastMessageTime ?? this.lastMessageTime,
isOnline: isOnline ?? this.isOnline,
unreadCount: unreadCount ?? this.unreadCount,

View File

@ -2,6 +2,8 @@ import 'dart:typed_data';
enum MessageStatus { sending, sent, delivered, read, failed }
enum MessageType { text, image }
class MessageModel {
final int? id; // server id (null пока не подтверждено сервером)
final int? tempId; // client temp id (для сопоставления ack)
@ -15,6 +17,9 @@ class MessageModel {
final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
final DateTime? editedAt;
final Uint8List? localFileBytes;
final MessageType messageType;
final String? fileId;
final String? encryptedFileKey;
MessageModel({
this.id,
@ -28,7 +33,10 @@ class MessageModel {
this.replyToId,
this.replyToText,
this.editedAt,
this.localFileBytes
this.localFileBytes,
this.messageType = MessageType.text,
this.fileId,
this.encryptedFileKey,
});
MessageModel copyWith({
@ -44,6 +52,9 @@ class MessageModel {
String? replyToText,
DateTime? editedAt,
Uint8List? localFileBytes,
MessageType? messageType,
String? fileId,
String? encryptedFileKey,
}) {
return MessageModel(
id: id ?? this.id,
@ -58,6 +69,9 @@ class MessageModel {
replyToText: replyToText ?? this.replyToText,
editedAt: editedAt ?? this.editedAt,
localFileBytes: localFileBytes ?? this.localFileBytes,
messageType: messageType ?? this.messageType,
fileId: fileId ?? this.fileId,
encryptedFileKey: encryptedFileKey ?? this.encryptedFileKey,
);
}
@ -78,6 +92,9 @@ class MessageModel {
replyToId: json['reply_to_id'] == null ? null : int.tryParse(json['reply_to_id'].toString()),
replyToText: json['reply_to_text'] == null ? null : json['reply_to_text'].toString(),
editedAt: json['edited_at'] == null ? null : DateTime.tryParse(json['edited_at'].toString()),
messageType: json['message_type'] == 'image' ? MessageType.image : MessageType.text,
fileId: json['file_id']?.toString(),
encryptedFileKey: json['encrypted_key']?.toString(),
);
}
@ -93,6 +110,9 @@ class MessageModel {
'reply_to_id': replyToId,
'reply_to_text': replyToText,
'edited_at': editedAt?.toIso8601String(),
'message_type': messageType == MessageType.image ? 'image' : 'text',
'file_id': fileId,
'encrypted_key': encryptedFileKey,
};
}
}

View File

@ -1,21 +1,23 @@
import 'package:jwt_decoder/jwt_decoder.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:chepuhagram/core/constants.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class ApiService extends ChangeNotifier {
final _client = http.Client();
final _storage = const FlutterSecureStorage();
bool _isRefreshing = false;
Future<String?> uploadMedia(List<int> bytes) async {
Future<String?> uploadMedia(List<int> bytes, {String purpose = 'media'}) async {
try {
final token = getAccessToken();
final token = await getAccessToken();
var request = http.MultipartRequest(
'POST',
Uri.parse('${AppConstants.baseUrl}/media/upload'),
Uri.parse('${AppConstants.baseUrl}/media/v2/upload'),
);
request.headers.addAll({
'Authorization': 'Bearer $token',
@ -28,9 +30,8 @@ class ApiService extends ChangeNotifier {
filename: 'media.enc', // Имя файла для сервера
),
);
// Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.)
// request.headers.addAll({'Authorization': 'Bearer $token'});
// Добавляем purpose
request.fields['purpose'] = purpose;
var streamedResponse = await request.send().timeout(Duration(seconds: 30));
var response = await http.Response.fromStream(streamedResponse);
@ -227,6 +228,26 @@ class ApiService extends ChangeNotifier {
return jsonDecode(response.body) as List<dynamic>;
}
Future<Uint8List?> downloadMedia(String fileId) async {
try {
final token = await getAccessToken();
final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/media/$fileId'),
headers: {
'Authorization': 'Bearer $token',
},
);
if (response.statusCode == 200) {
return response.bodyBytes;
}
print('Ошибка загрузки медиа: ${response.statusCode}');
return null;
} catch (e) {
print('Ошибка downloadMedia: $e');
return null;
}
}
Future<Map<String, dynamic>> updateMe({
required String username,
required String firstName,

View File

@ -1,6 +1,7 @@
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:chepuhagram/data/models/contact_model.dart';
class CryptoService {
@ -192,10 +193,19 @@ class CryptoService {
contact.copyWith(
lastMessage: utf8.decode(decrypted),
isLastMsgDecrypted: true,
avatarFileId: contact.avatarFileId,
avatarUrl: contact.avatarUrl,
),
);
} catch (e) {
result.add(contact.copyWith(lastMessage: '[не удалось расшифровать: $e]', isLastMsgDecrypted: true));
result.add(
contact.copyWith(
lastMessage: '[не удалось расшифровать: $e]',
isLastMsgDecrypted: true,
avatarFileId: contact.avatarFileId,
avatarUrl: contact.avatarUrl,
),
);
}
}
return result;
@ -264,6 +274,70 @@ class CryptoService {
}
}
Future<Uint8List?> decryptAesKey(
String encryptedKey,
SecretKey sharedKey,
) async {
try {
final keyBytes = base64Decode(encryptedKey);
final nonce = keyBytes.sublist(0, 12);
final cipherText = keyBytes.sublist(12, keyBytes.length - 16);
final mac = keyBytes.sublist(keyBytes.length - 16);
final decrypted = await aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: sharedKey,
);
return Uint8List.fromList(decrypted);
} catch (e) {
print('Ошибка дешифровки AES ключа: $e');
return null;
}
}
Future<String?> encryptAesKey(List<int> keyBytes, SecretKey sharedKey) async {
try {
final encrypted = await aesGcm.encrypt(keyBytes, secretKey: sharedKey);
return base64Encode(encrypted.concatenation());
} catch (e) {
print('Ошибка шифрования AES ключа: $e');
return null;
}
}
Future<Uint8List?> decryptImage(
List<int> encryptedData,
String encryptedKey,
SecretKey sharedKey,
) async {
try {
final keyBytes = base64Decode(encryptedKey);
final keyNonce = keyBytes.sublist(0, 12);
final keyCipher = keyBytes.sublist(12, keyBytes.length - 16);
final keyMac = keyBytes.sublist(keyBytes.length - 16);
final decryptedFileKey = await aesGcm.decrypt(
SecretBox(keyCipher, nonce: keyNonce, mac: Mac(keyMac)),
secretKey: sharedKey,
);
final fileSecretKey = SecretKey(decryptedFileKey);
final nonce = encryptedData.sublist(0, 12);
final cipherText = encryptedData.sublist(12, encryptedData.length - 16);
final mac = encryptedData.sublist(encryptedData.length - 16);
final decryptedBytes = await aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: fileSecretKey,
);
return Uint8List.fromList(decryptedBytes);
} catch (e) {
print('Ошибка дешифровки медиа: $e');
return null;
}
}
Future<String> decryptMessage(String base64Data, SecretKey sharedKey) async {
final data = base64Decode(base64Data);

View File

@ -358,7 +358,7 @@ class AuthProvider extends ChangeNotifier {
Future<bool> updateAvatar(String path) async {
try {
final bytes = await File(path).readAsBytes();
final fileId = await _apiService.uploadMedia(bytes);
final fileId = await _apiService.uploadMedia(bytes, purpose: 'avatar');
if (fileId != null) {
final success = await _apiService.updateAvatar(fileId);
if (success) {

View File

@ -139,6 +139,7 @@ class ContactProvider extends ChangeNotifier {
isOnline: updatedContact.isOnline,
publicKey: updatedContact.publicKey,
);
print("Контакт ${updatedContact.name} ${updatedContact.surname} ${updatedContact.id} ${updatedContact.avatarFileId} ${updatedContact.avatarUrl} обновлен");
notifyListeners();
}
} catch (e) {

View File

@ -155,8 +155,8 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
decoration: const InputDecoration(
labelText: 'О себе',
),
minLines: 2,
maxLines: 5,
minLines: 1,
maxLines: 10,
),
],
),

View File

@ -1,8 +1,12 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cryptography/cryptography.dart';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
import 'package:chepuhagram/presentation/widgets/message_bubble.dart';
import 'package:gal/gal.dart';
import 'package:chepuhagram/data/repositories/contact_repository.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
@ -18,7 +22,6 @@ import 'package:flutter/services.dart';
import 'user_profile_screen.dart';
import 'package:image_picker/image_picker.dart';
import '/core/theme_manager.dart';
import 'dart:io';
class ChatScreen extends StatefulWidget {
final Contact contact;
@ -43,6 +46,9 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
StreamSubscription<dynamic>? _socketSubscription;
final Set<int> _sentReadReceipts = <int>{};
final LocalDbService _localDbService = LocalDbService();
final ScrollController _scrollController = ScrollController();
final Map<int, GlobalKey> _messageKeys = {};
bool _showScrollToEnd = false;
Uint8List? _pendingImageBytes;
MessageModel? _replyTo;
bool _isOnline = false;
@ -73,6 +79,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
startOnlineUpdates();
_controller.addListener(_sendTypingStatus);
_scrollController.addListener(_updateScrollButtonVisibility);
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
}
@ -197,6 +205,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
void dispose() {
currentActiveChatContactId = null;
_socketSubscription?.cancel();
_scrollController.removeListener(_updateScrollButtonVisibility);
_scrollController.dispose();
_controller.dispose();
routeObserver.unsubscribe(this);
_inputFocusNode.dispose();
@ -301,13 +311,27 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
)
: null,
child: ListView.builder(
controller: _scrollController,
reverse: true, // Сообщения растут снизу вверх
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
final keyId = msg.id ?? msg.tempId ?? index;
final itemKey = _messageKeys.putIfAbsent(
keyId,
() => GlobalKey(),
);
return MessageBubble(
key: itemKey,
message: msg,
onTap: () => _showMessageActions(msg),
onReplyTap: msg.replyToId != null
? () => _scrollToMessage(msg.replyToId)
: null,
onImageTap: msg.messageType == MessageType.image
? () => _openFullScreenImage(msg)
: null,
onImageNeeded: _loadImageBytesForMessage,
);
},
),
@ -316,6 +340,13 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
_buildMessageInput(),
],
),
floatingActionButton: _showScrollToEnd
? FloatingActionButton(
onPressed: _scrollToBottom,
child: const Icon(Icons.keyboard_arrow_down),
tooltip: 'Перейти к последнему сообщению',
)
: null,
);
}
@ -364,8 +395,11 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
title: const Text('Ответить'),
onTap: () {
Navigator.of(ctx).pop();
setState(() => _replyTo = msg);
_inputFocusNode.requestFocus();
String text = msg.text;
if (msg.text.isEmpty && msg.messageType == MessageType.image) {
text = "[Фото]";
}
setState(() => _replyTo = msg.copyWith(text: text));
},
),
if (msg.isMe)
@ -401,6 +435,15 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
);
},
),
if (msg.messageType == MessageType.image)
ListTile(
leading: const Icon(Icons.save_alt),
title: const Text('Сохранить в галерею'),
onTap: () {
Navigator.of(ctx).pop();
_saveImageToGallery(msg);
},
),
ListTile(
leading: const Icon(Icons.forward),
title: const Text('Переслать'),
@ -577,7 +620,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
Future<void> _forwardMessage(MessageModel msg, Contact targetContact) async {
final forwardText = msg.text.trim();
if (forwardText.isEmpty) {
final isImage = msg.messageType == MessageType.image;
if (forwardText.isEmpty && !isImage) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@ -603,7 +647,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 3),
duration: const Duration(seconds: 3),
),
);
return;
@ -618,28 +662,90 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
myPrivKey,
targetContact.publicKey!,
);
String contentToEncrypt = forwardText;
if (contentToEncrypt.isEmpty && isImage) {
contentToEncrypt = "";
}
final encryptedContent = await _cryptoService.encryptMessage(
forwardText,
contentToEncrypt,
sharedSecret,
);
final previewText = forwardText.length > 50
? forwardText.substring(0, 50)
: forwardText;
final String previewText = forwardText.isNotEmpty
? (forwardText.length > 50
? forwardText.substring(0, 50)
: forwardText)
: (isImage ? "[Фото]" : "");
final encryptedContent50 = await _cryptoService.encryptMessage(
previewText,
sharedSecret,
);
String? fileIdToSend;
String? encryptedFileKeyToSend;
Uint8List? localImageBytes = msg.localFileBytes;
if (isImage) {
if (msg.fileId != null &&
msg.encryptedFileKey != null &&
_currentContact.publicKey != null) {
final currentChatSharedSecret = await _cryptoService
.deriveSharedSecret(myPrivKey, _currentContact.publicKey!);
final originalFileKeyBytes = await _cryptoService.decryptAesKey(
msg.encryptedFileKey!,
currentChatSharedSecret,
);
if (originalFileKeyBytes != null) {
final reencryptedKey = await _cryptoService.encryptAesKey(
originalFileKeyBytes,
sharedSecret,
);
if (reencryptedKey != null) {
fileIdToSend = msg.fileId;
encryptedFileKeyToSend = reencryptedKey;
}
}
}
if (fileIdToSend == null || encryptedFileKeyToSend == null) {
if (msg.localFileBytes != null) {
final imageEncryptResult = await _cryptoService.encryptImage(
msg.localFileBytes!,
sharedSecret,
);
if (imageEncryptResult == null) {
throw Exception('Ошибка шифрования пересылаемой картинки');
}
fileIdToSend = await apiService.uploadMedia(imageEncryptResult.$1);
if (fileIdToSend == null) {
throw Exception(
'Не удалось загрузить пересылаемое изображение на сервер',
);
}
encryptedFileKeyToSend = imageEncryptResult.$2;
}
}
if (fileIdToSend == null || encryptedFileKeyToSend == null) {
throw Exception(
'Невозможно переслать изображение: отсутствует шифрованный ключ или файл.',
);
}
}
final tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel(
tempId: tempId,
text: forwardText.isNotEmpty ? forwardText : "[Фото]",
text: forwardText.isNotEmpty ? forwardText : (isImage ? "[Фото]" : ""),
isMe: true,
senderId: myId,
receiverId: targetContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
localFileBytes: _pendingImageBytes,
localFileBytes: isImage ? localImageBytes : null,
messageType: isImage ? MessageType.image : MessageType.text,
fileId: fileIdToSend,
encryptedFileKey: encryptedFileKeyToSend,
);
if (_currentContact.id == targetContact.id) {
setState(() {
@ -648,15 +754,23 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
});
}
final ok = Provider.of<SocketService>(context, listen: false)
.sendMessage({
'type': 'private_message',
'receiver_id': targetContact.id,
'message_type': 'text',
'content': encryptedContent,
'content50': encryptedContent50,
'temp_id': tempId,
});
final payload = {
'type': 'private_message',
'receiver_id': targetContact.id,
'message_type': isImage ? 'image' : 'text',
'content': encryptedContent,
'content50': encryptedContent50,
'temp_id': tempId,
if (isImage) ...{
'file_id': fileIdToSend,
'encrypted_key': encryptedFileKeyToSend,
},
};
final ok = Provider.of<SocketService>(
context,
listen: false,
).sendMessage(payload);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@ -667,12 +781,12 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
: 'Не удалось переслать сообщение.',
),
behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(
margin: const EdgeInsets.only(
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
duration: Duration(seconds: 3),
duration: const Duration(seconds: 3),
),
);
@ -697,8 +811,12 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
SnackBar(
content: Text('Ошибка пересылки: $e'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 5),
margin: const EdgeInsets.only(
bottom: 80.0 + 10.0,
left: 10.0,
right: 10.0,
),
duration: const Duration(seconds: 5),
),
);
}
@ -730,7 +848,11 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
const SizedBox(width: 8),
Expanded(
child: Text(
_replyTo!.text,
_replyTo!.text.isNotEmpty
? _replyTo!.text
: (_replyTo!.messageType == MessageType.image
? "[Фото]"
: ""),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@ -781,7 +903,6 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
Expanded(
child: TextField(
controller: _controller,
focusNode: _inputFocusNode,
minLines: 1,
maxLines: 5,
textInputAction: TextInputAction.newline,
@ -845,6 +966,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
String? encryptedFileKey;
String encryptedContent;
String encryptedContent50;
String? encryptedReplyToText;
// 2. Если есть изображение сначала загружаем его
if (hasImage) {
@ -883,17 +1005,27 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
sharedSecret,
);
if (_replyTo?.id != null && _replyTo!.text.trim().isNotEmpty) {
encryptedReplyToText = await _cryptoService.encryptMessage(
_replyTo!.text,
sharedSecret,
);
}
// 4. Создаем локальную модель для мгновенного отображения
final tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel(
tempId: tempId,
text: rawText.isNotEmpty ? rawText : "[Фото]",
text: rawText,
isMe: true,
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
localFileBytes: _pendingImageBytes,
messageType: hasImage ? MessageType.image : MessageType.text,
fileId: fileId,
encryptedFileKey: encryptedFileKey,
replyToId: _replyTo?.id,
replyToText: _replyTo?.text,
);
@ -917,7 +1049,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
},
if (_replyTo?.id != null) ...{
"reply_to_id": _replyTo!.id,
"reply_to_text": _replyTo!.text,
if (encryptedReplyToText != null)
"reply_to_text": encryptedReplyToText,
},
};
@ -1102,6 +1235,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}
if (data['type'] == 'private_message') {
print('DEBUG incoming private_message raw: $data');
setState(() {
_typingTimer?.cancel();
_isTyping = false;
@ -1137,7 +1271,10 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
);
// 4. Добавляем в список и обновляем экран
await LocalDbService().saveMessages([data]);
String? encryptedFileKey = data['encrypted_key']?.toString();
Uint8List? decryptedImageBytes;
// Lazy load images later
if (!mounted) return;
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
@ -1150,6 +1287,11 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
_sentReadReceipts.add(serverMessageId);
}
final replyToText = await _decryptReplyText(
data['reply_to_text']?.toString(),
sharedSecret,
);
setState(() {
messages.add(
MessageModel(
@ -1163,12 +1305,22 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
replyToId: data['reply_to_id'] == null
? null
: int.tryParse(data['reply_to_id'].toString()),
replyToText: data['reply_to_text'] != null
? data['reply_to_text'].toString()
: null,
replyToText: replyToText,
messageType: data['message_type'] == 'image'
? MessageType.image
: MessageType.text,
fileId: data['file_id']?.toString(),
encryptedFileKey: encryptedFileKey,
localFileBytes: decryptedImageBytes,
),
);
});
// Save to local DB with cached image bytes
try {
await _localDbService.saveMessages([messages.last]);
} catch (e) {
print('Error saving incoming message to DB: $e');
}
} catch (e) {
print("Ошибка расшифровки входящего сообщения: $e");
}
@ -1224,15 +1376,26 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
try {
print('[DEBUG] Начало загрузки истории');
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
print('[DEBUG] Ключи получены');
final cached = await _localDbService.getChatHistory(
widget.contact.id,
myId,
);
print('[DEBUG] Локальная история загружена: ${cached.length} сообщений');
// Сохраняем кэшированные изображения перед обновлением
Map<int, Uint8List> cachedImages = {};
for (var msg in cached) {
if (msg['id'] != null && msg['local_file_bytes'] != null) {
cachedImages[msg['id'] as int] = msg['local_file_bytes'] as Uint8List;
}
}
try {
List<MessageModel> loadedLocalMessages = [];
@ -1260,6 +1423,11 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}
}
Uint8List? decryptedImageBytes;
if (msg['message_type'] == 'image') {
decryptedImageBytes = msg['local_file_bytes'] as Uint8List?;
}
loadedLocalMessages.add(
MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
@ -1272,12 +1440,19 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
replyToId: msg['reply_to_id'] == null
? null
: int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] != null
? msg['reply_to_text'].toString()
: null,
replyToText: await _decryptReplyText(
msg['reply_to_text']?.toString(),
sharedSecret,
),
editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null,
messageType: msg['message_type'] == 'image'
? MessageType.image
: MessageType.text,
fileId: msg['file_id']?.toString(),
encryptedFileKey: msg['encrypted_key']?.toString(),
localFileBytes: decryptedImageBytes,
),
);
}
@ -1293,6 +1468,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}
final history = await apiService.getChatHistory(widget.contact.id);
print('[DEBUG] История с сервера загружена: ${history.length} сообщений');
print(history);
final alreadyReadIncomingMessageIds = <int>{};
List<MessageModel> loadedMessages = [];
@ -1327,6 +1503,9 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}
}
Uint8List? decryptedImageBytes;
// Lazy load images later to avoid downloading all at once
loadedMessages.insert(
0,
MessageModel(
@ -1340,20 +1519,34 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
replyToId: msg['reply_to_id'] == null
? null
: int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] != null
? msg['reply_to_text'].toString()
: null,
replyToText: await _decryptReplyText(
msg['reply_to_text']?.toString(),
sharedSecret,
),
editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null,
messageType: msg['message_type'] == 'image'
? MessageType.image
: MessageType.text,
fileId: msg['file_id']?.toString(),
encryptedFileKey: msg['encrypted_key']?.toString(),
localFileBytes: cachedImages[int.tryParse(msg['id']?.toString() ?? '')] ?? decryptedImageBytes,
),
);
}
try {
await _localDbService.deleteChatHistory(widget.contact.id, myId);
await _localDbService.saveMessages(history);
print('[DEBUG] Начинаем очищение и сохранение истории в локальную БД');
//await _localDbService.deleteChatHistory(widget.contact.id, myId);
await _localDbService.saveMessages(loadedMessages);
print('[DEBUG] История успешно сохранена в локальную БД');
// Восстанавливаем кэшированные изображения
for (var entry in cachedImages.entries) {
await _localDbService.updateMessageLocalFileBytes(entry.key, entry.value);
}
} catch (e) {
print("Ошибка сохранения истории в локальную базу: $e");
print("[ERROR] Ошибка сохранения истории в локальную базу: $e");
}
if (!mounted) return;
@ -1378,6 +1571,202 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
setState(() => _isKeyLoading = false);
}
}
Future<void> _updateScrollButtonVisibility() async {
if (!mounted) return;
final shouldShow =
_scrollController.hasClients && _scrollController.offset > 100;
if (shouldShow != _showScrollToEnd) {
setState(() {
_showScrollToEnd = shouldShow;
});
}
}
Future<void> _scrollToBottom() async {
if (!_scrollController.hasClients) return;
await _scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
Future<void> _scrollToMessage(int? messageId) async {
if (messageId == null) return;
final itemKey = _messageKeys[messageId];
if (itemKey?.currentContext == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Сообщение не найдено для перехода.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
return;
}
await Scrollable.ensureVisible(
itemKey!.currentContext!,
duration: const Duration(milliseconds: 300),
alignment: 0.1,
curve: Curves.easeInOut,
);
}
Future<Uint8List?> _loadImageBytesForMessage(MessageModel msg) async {
if (msg.localFileBytes != null) return msg.localFileBytes;
if (msg.fileId == null || msg.encryptedFileKey == null) return null;
final myPrivKey = await _cryptoService.getPrivateKey();
if (myPrivKey == null) return null;
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
_currentContact.publicKey!,
);
final bytes = await _downloadAndDecryptImage(
msg.fileId!,
msg.encryptedFileKey!,
sharedSecret,
);
// Cache the downloaded bytes
if (bytes != null && msg.id != null) {
try {
await _localDbService.updateMessageLocalFileBytes(msg.id!, bytes);
// Update in-memory message
setState(() {
final idx = messages.indexWhere((m) => m.id == msg.id);
if (idx != -1) {
messages[idx] = messages[idx].copyWith(localFileBytes: bytes);
}
});
} catch (e) {
print('Error caching image bytes: $e');
}
}
return bytes;
}
Future<void> _openFullScreenImage(MessageModel msg) async {
final bytes = await _loadImageBytesForMessage(msg);
if (bytes == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось открыть изображение.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
return;
}
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _FullScreenImageScreen(imageBytes: bytes),
),
);
}
Future<void> _saveImageToGallery(MessageModel msg) async {
final bytes = await _loadImageBytesForMessage(msg);
if (bytes == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось получить изображение для сохранения.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
return;
}
try {
await Gal.putImageBytes(bytes);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Изображение сохранено в галерею.'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Не удалось сохранить изображение: $e'),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
),
);
}
}
Future<Uint8List?> _downloadAndDecryptImage(
String fileId,
String encryptedFileKey,
SecretKey sharedSecret,
) async {
try {
print('DEBUG downloadMedia(fileId=$fileId)');
final bytes = await apiService.downloadMedia(fileId);
if (bytes == null) {
print('DEBUG downloadMedia returned null for fileId=$fileId');
return null;
}
print(
'DEBUG downloadMedia bytes length=${bytes.length} for fileId=$fileId',
);
final result = await _cryptoService.decryptImage(
bytes,
encryptedFileKey,
sharedSecret,
);
print(
'DEBUG decryptImage result length=${result?.length ?? 'null'} for fileId=$fileId',
);
return result;
} catch (e) {
print('Ошибка загрузки и дешифровки медиа: $e');
return null;
}
}
Future<String?> _decryptReplyText(
String? encryptedReplyText,
SecretKey sharedSecret,
) async {
if (encryptedReplyText == null) return null;
try {
return await _cryptoService.decryptMessage(
encryptedReplyText,
sharedSecret,
);
} catch (_) {
return encryptedReplyText;
}
}
}
class _FullScreenImageScreen extends StatelessWidget {
final Uint8List imageBytes;
const _FullScreenImageScreen({required this.imageBytes});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: Colors.white),
),
body: Center(child: InteractiveViewer(child: Image.memory(imageBytes))),
);
}
}
class TypingIndicator extends StatefulWidget {

View File

@ -8,6 +8,7 @@ import '../screens/settings_screen.dart';
import '../screens/new_chat_screen.dart';
import '../screens/chat_screen.dart';
import '/logic/contact_provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '/logic/auth_provider.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@ -509,7 +510,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
? Theme.of(context).colorScheme.onSurface
: null,
backgroundImage: authProvider.avatarUrl != null
? NetworkImage(authProvider.avatarUrl!)
? CachedNetworkImageProvider(authProvider.avatarUrl!)
: authProvider.avatarPath != null
? FileImage(File(authProvider.avatarPath!))
: null,

View File

@ -51,9 +51,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
final authProv = context.watch<AuthProvider>();
final accountEmail = authProv.email?.isNotEmpty == true
? authProv.email!
: authProv.username?.isNotEmpty == true
final accountUsername = authProv.username?.isNotEmpty == true
? '@${authProv.username!}'
: 'Не указано';
@ -78,7 +76,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
accountEmail: Text(
accountEmail,
accountUsername,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
currentAccountPicture: GestureDetector(

View File

@ -5,6 +5,7 @@ import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:provider/provider.dart';
import '/core/constants.dart';
import 'package:cached_network_image/cached_network_image.dart';
class UserProfileScreen extends StatefulWidget {
final int userId;
@ -135,7 +136,7 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
backgroundImage:
(avatarUrl != null && _userData?['show_avatar'] == true)
? NetworkImage(avatarUrl)
? CachedNetworkImageProvider(avatarUrl)
: null,
child: (avatarUrl == null || _userData?['show_avatar'] != true)
? Text(

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/data/models/contact_model.dart';
import 'package:cached_network_image/cached_network_image.dart';
class ContactTile extends StatefulWidget {
final Contact contact;
@ -78,7 +79,7 @@ class _ContactTileState extends State<ContactTile> {
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
backgroundImage: widget.contact.effectiveAvatarUrl != null
? NetworkImage(widget.contact.effectiveAvatarUrl!)
? CachedNetworkImageProvider(widget.contact.effectiveAvatarUrl!)
: null,
child: widget.contact.effectiveAvatarUrl == null
? Text(

View File

@ -3,21 +3,55 @@ import '/data/models/message_model.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import 'dart:typed_data';
import '/core/theme_manager.dart';
import '/core/constants.dart';
class MessageBubble extends StatelessWidget {
class MessageBubble extends StatefulWidget {
final MessageModel message;
final VoidCallback? onTap;
final VoidCallback? onReplyTap;
final VoidCallback? onImageTap;
final Future<Uint8List?> Function(MessageModel)? onImageNeeded;
const MessageBubble({
super.key,
required this.message,
this.onTap,
this.onReplyTap,
this.onImageTap,
this.onImageNeeded,
});
@override
State<MessageBubble> createState() => _MessageBubbleState();
}
class _MessageBubbleState extends State<MessageBubble> {
Uint8List? _imageBytes;
@override
void initState() {
super.initState();
if (widget.message.localFileBytes == null &&
widget.message.messageType == MessageType.image &&
widget.onImageNeeded != null) {
_loadImage();
}
}
Future<void> _loadImage() async {
final bytes = await widget.onImageNeeded!(widget.message);
if (mounted) {
setState(() {
_imageBytes = bytes;
});
}
}
@override
Widget build(BuildContext context) {
final isMe = message.isMe;
final isMe = widget.message.isMe;
final themeProv = context.watch<ThemeProvider>();
return Align(
// Выравниваем вправо, если это мое сообщение, и влево если чужое
@ -25,10 +59,10 @@ class MessageBubble extends StatelessWidget {
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
onTap: widget.onTap,
// На телефонах иногда удобнее/надежнее long-press (как в мессенджерах),
// поэтому поддерживаем оба жеста.
onLongPress: onTap,
onLongPress: widget.onTap,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
@ -57,68 +91,108 @@ class MessageBubble extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (message.replyToText != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: (isMe ? Colors.white : Colors.black).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(
color: isMe ? Colors.black54 : Colors.black38,
width: 2,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.reply,
size: 14,
color: isMe ? Colors.black54 : Colors.black54,
),
const SizedBox(width: 4),
Expanded(
child: Text(
message.replyToText!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isMe ? const Color.fromARGB(221, 21, 21, 21) : const Color.fromARGB(221, 21, 21, 21),
fontSize: 12,
fontStyle: FontStyle.italic,
),
if (widget.message.replyToText != null) ...[
GestureDetector(
onTap: widget.onReplyTap,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: (isMe ? Colors.white : Colors.black).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(
color: isMe ? Colors.black54 : Colors.black38,
width: 2,
),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.reply,
size: 14,
color: isMe ? Colors.black54 : Colors.black54,
),
const SizedBox(width: 4),
Expanded(
child: Text(
widget.message.replyToText!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isMe ? const Color.fromARGB(221, 21, 21, 21) : const Color.fromARGB(221, 21, 21, 21),
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
),
],
Linkify(
onOpen: (link) async {
final Uri url = Uri.parse(link.url);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
throw Exception('Could not launch $url');
}
},
text: message.text,
style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black),
linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold),
),
if (widget.message.messageType == MessageType.image) ...[
GestureDetector(
onTap: widget.onImageTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: (_imageBytes ?? widget.message.localFileBytes) != null
? Image.memory(
_imageBytes ?? widget.message.localFileBytes!,
width: 200,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 200,
height: 200,
color: Colors.grey[300],
child: const Icon(Icons.broken_image, size: 50),
);
},
)
: Container(
width: 200,
height: 200,
color: Colors.grey[300],
child: const Center(
child: CircularProgressIndicator(),
),
),
),
),
if (widget.message.text.isNotEmpty) ...[
const SizedBox(height: 8),
],
],
if (widget.message.messageType == MessageType.text || widget.message.text.isNotEmpty) ...[
Linkify(
onOpen: (link) async {
final Uri url = Uri.parse(link.url);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
throw Exception('Could not launch $url');
}
},
text: widget.message.text,
style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black),
linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold),
),
],
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.createdAt),
_formatTime(widget.message.createdAt),
style: TextStyle(
color: isMe ? Colors.black87 : Colors.black54,
fontSize: 10,
),
),
if (message.editedAt != null) ...[
if (widget.message.editedAt != null) ...[
const SizedBox(width: 6),
Text(
'(изменено)',
@ -132,9 +206,9 @@ class MessageBubble extends StatelessWidget {
if (isMe) ...[
const SizedBox(width: 6),
Icon(
_statusIcon(message.status),
_statusIcon(widget.message.status),
size: 12,
color: _statusColor(message.status, isMe),
color: _statusColor(widget.message.status, isMe),
),
],
],

View File

@ -12,6 +12,7 @@ import firebase_messaging
import flutter_image_compress_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
import gal
import local_auth_darwin
import package_info_plus
import path_provider_foundation
@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))

View File

@ -33,6 +33,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters:
dependency: transitive
description:
@ -249,11 +273,27 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.5.18"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_image_compress:
dependency: "direct main"
description:
@ -408,6 +448,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
gal:
dependency: "direct main"
description:
name: gal
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: "direct main"
description:
@ -648,6 +696,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
open_filex:
dependency: "direct main"
description:
@ -689,7 +745,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@ -768,6 +824,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
@ -1005,6 +1069,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:

View File

@ -51,11 +51,15 @@ dependencies:
flutter_linkify: ^6.0.0
url_launcher: ^6.3.2
image_picker: ^1.0.4
gal: ^2.3.2
flutter_image_compress: ^2.1.0
dio: ^5.9.2
package_info_plus: ^9.0.1
open_filex: ^4.3.2
convert: ^3.1.2
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.0.2
path_provider: ^2.1.3
dev_dependencies:
flutter_test:

View File

@ -1,53 +1,43 @@
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, File, UploadFile, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from app.core import security
from app.api import schemas
from app.db import models
from jose import JWTError, jwt
from sqlalchemy.sql import func
from app.core.security import get_current_user
from fastapi.responses import FileResponse
from app.db import models
from app.core.config import config
import os
import re
import uuid
import urllib.request
import urllib.error
from io import BytesIO
# бд
import asyncio
def get_db():
db = models.SessionLocal()
try:
yield db
finally:
db.close()
def _ensure_directory(path: str):
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
mediaRouter = APIRouter(
prefix="/media",
tags=[],
)
UPLOAD_FOLDER = 'uploads'
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
def _parse_multipart_body(body: bytes):
try:
if not body.startswith(b'--'):
if not body.startswith(b"--"):
return None
boundary, rest = body.split(b'\r\n', 1)
boundary, _ = body.split(b"\r\n", 1)
parts = body.split(boundary)
for part in parts:
if not part or part in (b'--', b'--\r\n'):
if not part or part in (b"--", b"--\r\n"):
continue
part = part.strip(b'\r\n')
part = part.strip(b"\r\n")
if not part:
continue
headers, _, content = part.partition(b'\r\n\r\n')
headers, _, content = part.partition(b"\r\n\r\n")
if not headers or content is None:
continue
@ -77,57 +67,350 @@ def _parse_multipart_body(body: bytes):
return None
async def _get_upload_file(request: Request, uploaded_file: UploadFile | None):
if uploaded_file is not None:
return uploaded_file
raw_body = await request.body()
parsed = _parse_multipart_body(raw_body)
if parsed is None:
return None
filename, content, content_type = parsed
return UploadFile(filename=filename, file=BytesIO(content), content_type=content_type)
def _encode_multipart_formdata(fields, files):
boundary = uuid.uuid4().hex
body = BytesIO()
for name, value in fields.items():
body.write(f"--{boundary}\r\n".encode('utf-8'))
body.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode('utf-8'))
body.write(str(value).encode('utf-8'))
body.write(b"\r\n")
for field_name, filename, content_type, file_bytes in files:
body.write(f"--{boundary}\r\n".encode('utf-8'))
body.write(
f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode('utf-8')
)
body.write(f"Content-Type: {content_type}\r\n\r\n".encode('utf-8'))
body.write(file_bytes)
body.write(b"\r\n")
body.write(f"--{boundary}--\r\n".encode('utf-8'))
return body.getvalue(), boundary
def _get_cloud_cache_size_bytes(db: Session) -> int:
total = db.query(func.sum(models.CloudMediaItem.size_bytes)).filter(
models.CloudMediaItem.status.in_(['pending', 'sending']),
models.CloudMediaItem.is_avatar == 0,
).scalar()
return int(total or 0)
def _find_local_media_path(file_id: str) -> str | None:
candidates = [
os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, f"{file_id}.enc"),
os.path.join('uploads', f"{file_id}.enc"),
os.path.join(config.HOME_MEDIA_FOLDER, f"{file_id}.enc"),
]
for path in candidates:
if os.path.exists(path):
return path
return None
def _stream_response_from_remote(url: str):
try:
request = urllib.request.Request(url)
response = urllib.request.urlopen(request, timeout=45)
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise HTTPException(status_code=404, detail='File not found')
raise HTTPException(status_code=502, detail=f'Error fetching media from home server: {exc.code}')
except Exception as exc:
raise HTTPException(status_code=502, detail=f'Could not reach home server: {exc}')
headers = {k.lower(): v for k, v in response.getheaders()}
content_type = headers.get('content-type', 'application/octet-stream')
return StreamingResponse(
iter(lambda: response.read(8192), b""),
media_type=content_type,
headers={
'Content-Disposition': headers.get('content-disposition', f'attachment; filename="{os.path.basename(url)}"')
},
)
def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]:
file_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if not os.path.exists(file_path):
return False, 'Local cache file not found'
with open(file_path, 'rb') as f:
content = f.read()
fields = {
'owner_id': item.owner_id or '',
'cloud_file_id': item.file_id,
'original_filename': item.original_filename or item.local_filename,
}
files = [
('file', item.original_filename or item.local_filename, item.content_type or 'application/octet-stream', content),
]
body, boundary = _encode_multipart_formdata(fields, files)
request = urllib.request.Request(
f"{config.HOME_SERVER_URL}/media/receive",
data=body,
headers={
'Content-Type': f'multipart/form-data; boundary={boundary}',
'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET,
},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
if response.status == 200:
return True, ''
return False, f'Home server returned {response.status}'
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors='ignore')
return False, f'Home server HTTP error {exc.code}: {body}'
except Exception as exc:
return False, str(exc)
def _cleanup_home_quota(db: Session, owner_id: int | None):
if owner_id is None:
return
total = db.query(func.sum(models.HomeMediaFile.size_bytes)).filter(
models.HomeMediaFile.owner_id == owner_id
).scalar() or 0
total = int(total)
if total <= config.HOME_USER_QUOTA_BYTES:
return
files = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.owner_id == owner_id
).order_by(models.HomeMediaFile.created_at.asc()).all()
for file_record in files:
if total <= config.HOME_USER_QUOTA_BYTES:
break
path = os.path.join(config.HOME_MEDIA_FOLDER, file_record.storage_filename)
if os.path.exists(path):
os.remove(path)
total -= file_record.size_bytes
db.delete(file_record)
db.commit()
def _cleanup_all_home_storage():
db = models.SessionLocal()
try:
owner_ids = db.query(models.HomeMediaFile.owner_id).filter(models.HomeMediaFile.owner_id.isnot(None)).distinct().all()
for owner_id_tuple in owner_ids:
_cleanup_home_quota(db, owner_id_tuple[0])
finally:
db.close()
async def forward_pending_media_loop():
while True:
if config.SERVER_ROLE != 'cloud':
await asyncio.sleep(10)
continue
db = models.SessionLocal()
try:
total_cache = _get_cloud_cache_size_bytes(db)
if total_cache >= config.CLOUD_CACHE_MAX_BYTES:
await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS)
continue
pending_items = db.query(models.CloudMediaItem).filter(
models.CloudMediaItem.status == 'pending',
models.CloudMediaItem.is_avatar == 0,
).order_by(models.CloudMediaItem.created_at.asc()).limit(5).all()
for item in pending_items:
item.status = 'sending'
item.attempts += 1
db.commit()
success, error = _post_file_to_home(item)
if success:
item.status = 'sent'
item.sent_at = func.now()
item.error_message = None
db.commit()
cache_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cache_path):
os.remove(cache_path)
else:
item.status = 'failed'
item.error_message = error
db.commit()
except Exception:
pass
finally:
db.close()
await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS)
async def home_storage_maintenance_loop():
while True:
if config.SERVER_ROLE != 'home':
await asyncio.sleep(10)
continue
_cleanup_all_home_storage()
await asyncio.sleep(600)
mediaRouter = APIRouter(
prefix='/media',
tags=['media'],
)
_ensure_directory(UPLOAD_FOLDER)
_ensure_directory(config.CLOUD_MEDIA_CACHE_FOLDER)
_ensure_directory(config.HOME_MEDIA_FOLDER)
@mediaRouter.post('/upload')
async def upload_file(request: Request, file: UploadFile = File(None)):
uploaded_file = file
if uploaded_file is None:
raw_body = await request.body()
parsed = _parse_multipart_body(raw_body)
if parsed is not None:
filename, content, content_type = parsed
uploaded_file = UploadFile(
filename=filename,
file=BytesIO(content),
content_type=content_type,
)
async def upload_file(
request: Request,
file: UploadFile = File(None),
):
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
raise HTTPException(status_code=400, detail="No selected file")
raise HTTPException(status_code=400, detail='No selected file')
# Валидация размера файла (макс 10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024
content = await uploaded_file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10MB)")
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
# Валидация типа файла (для зашифрованных файлов пропускаем, так как content_type не image)
# ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
# if uploaded_file.content_type not in ALLOWED_TYPES:
# raise HTTPException(status_code=400, detail="Invalid file type")
# Генерируем уникальное имя, чтобы файлы не перезаписывались
file_id = str(uuid.uuid4())
file_id = uuid.uuid4().hex
filename = f"{file_id}.enc"
file_path = os.path.join(UPLOAD_FOLDER, filename)
# Сохраняем
with open(file_path, "wb") as f:
with open(file_path, 'wb') as f:
f.write(content)
print(f"Файл сохранен: {file_path}")
return {
"status": "ok",
"file_id": file_id
'status': 'ok',
'file_id': file_id,
}
@mediaRouter.post('/v2/upload')
async def upload_file_v2(
request: Request,
file: UploadFile = File(None),
purpose: str = Form('media'),
current_user: models.User = Depends(get_current_user),
):
if config.SERVER_ROLE != 'cloud':
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Upload endpoint is available only on cloud server')
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
raise HTTPException(status_code=400, detail='No selected file')
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
db = models.SessionLocal()
try:
cache_size = _get_cloud_cache_size_bytes(db)
is_avatar = purpose == 'avatar'
if cache_size >= config.CLOUD_CACHE_MAX_BYTES and not is_avatar:
raise HTTPException(
status_code=503,
detail='Cloud media cache is full; new uploads are temporarily paused until pending files are forwarded.',
)
file_id = uuid.uuid4().hex
local_filename = f"{file_id}.enc"
storage_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
with open(storage_path, 'wb') as f:
f.write(content)
item = models.CloudMediaItem(
file_id=file_id,
owner_id=current_user.id,
original_filename=uploaded_file.filename,
content_type=uploaded_file.content_type or 'application/octet-stream',
local_filename=local_filename,
size_bytes=len(content),
status='avatar' if is_avatar else 'pending',
is_avatar=1 if is_avatar else 0,
)
db.add(item)
db.commit()
finally:
db.close()
return {'status': 'ok', 'file_id': file_id}
@mediaRouter.post('/receive')
async def receive_media(
request: Request,
file: UploadFile = File(None),
owner_id: int | None = Form(None),
cloud_file_id: str | None = Form(None),
original_filename: str | None = Form(None),
):
secret = request.headers.get('X-Media-Forwarding-Secret')
if secret != config.MEDIA_FORWARDING_SECRET:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid forwarding secret')
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
raise HTTPException(status_code=400, detail='No selected file')
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
file_id = cloud_file_id or uuid.uuid4().hex
storage_filename = f"{file_id}.enc"
file_path = os.path.join(config.HOME_MEDIA_FOLDER, storage_filename)
with open(file_path, 'wb') as f:
f.write(content)
db = models.SessionLocal()
try:
home_record = models.HomeMediaFile(
file_id=file_id,
owner_id=owner_id,
original_filename=original_filename or uploaded_file.filename,
content_type=uploaded_file.content_type or 'application/octet-stream',
storage_filename=storage_filename,
size_bytes=len(content),
)
db.add(home_record)
db.commit()
_cleanup_home_quota(db, owner_id)
finally:
db.close()
return {'status': 'ok', 'file_id': file_id}
@mediaRouter.get('/{file_id}')
async def get_file(file_id: str):
filename = f"{file_id}.enc"
file_path = os.path.join(UPLOAD_FOLDER, filename)
local_path = _find_local_media_path(file_id)
if local_path:
return FileResponse(local_path, media_type='application/octet-stream')
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found")
if config.SERVER_ROLE == 'cloud':
return _stream_response_from_remote(f"{config.HOME_SERVER_URL}/media/{file_id}")
return FileResponse(file_path, media_type="application/octet-stream")
raise HTTPException(status_code=404, detail='File not found')

View File

@ -31,7 +31,9 @@ async def get_chat_history(
(models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) |
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.desc()).limit(limit).all()
print(
f"DEBUG get_chat_history: user={current_user.id}, contact={contact_id}, count={len(messages)}, ids={[m.id for m in messages]}",
)
return jsonable_encoder(messages)

View File

@ -250,7 +250,7 @@ async def read_users_chats(
"username": user.username,
"name": f"{user.first_name} {user.last_name or ''}".strip(),
"public_key": user.public_key,
"avatar_file_id": user.avatar_file_id,
"avatar_file_id": user.avatar_file_id if user.show_avatar else None,
"avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if user.show_avatar and user.avatar_file_id else None,
"last_message": last_msg.content if last_msg else None,
"last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None),

View File

@ -18,6 +18,15 @@ class Config:
# Server
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
SERVER_ROLE: str = os.getenv("SERVER_ROLE", "cloud").lower()
HOME_SERVER_URL: str = os.getenv("HOME_SERVER_URL", "http://home-server.local:8000")
MEDIA_FORWARDING_SECRET: str = os.getenv("MEDIA_FORWARDING_SECRET", "changeme")
CLOUD_MEDIA_CACHE_FOLDER: str = os.getenv("CLOUD_MEDIA_CACHE_FOLDER", "cloud_media_cache")
HOME_MEDIA_FOLDER: str = os.getenv("HOME_MEDIA_FOLDER", "home_media_store")
CLOUD_CACHE_MAX_BYTES: int = int(os.getenv("CLOUD_CACHE_MAX_BYTES", str(5 * 1024 * 1024 * 1024)))
HOME_USER_QUOTA_BYTES: int = int(os.getenv("HOME_USER_QUOTA_BYTES", str(10 * 1024 * 1024 * 1024)))
MEDIA_UPLOAD_MAX_BYTES: int = int(os.getenv("MEDIA_UPLOAD_MAX_BYTES", str(100 * 1024 * 1024)))
MEDIA_FORWARD_INTERVAL_SECONDS: int = int(os.getenv("MEDIA_FORWARD_INTERVAL_SECONDS", "12"))
# CORS
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")

View File

@ -10,7 +10,6 @@ SQLALCHEMY_DATABASE_URL = config.DATABASE_URL
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
Base.metadata.create_all(bind=engine)
class User(Base):
__tablename__ = "users"
@ -50,6 +49,38 @@ class Message(Base):
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
reply_to_text = Column(Text, nullable=True)
edited_at = Column(DateTime(timezone=True), nullable=True)
message_type = Column(String, nullable=False, server_default="text")
file_id = Column(String, nullable=True)
encrypted_key = Column(String, nullable=True)
class CloudMediaItem(Base):
__tablename__ = "cloud_media_items"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(String, unique=True, nullable=False, index=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
original_filename = Column(String, nullable=True)
content_type = Column(String, nullable=True)
local_filename = Column(String, nullable=False)
size_bytes = Column(Integer, nullable=False)
status = Column(String, nullable=False, server_default="pending")
is_avatar = Column(Integer, nullable=False, server_default="0")
attempts = Column(Integer, nullable=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
sent_at = Column(DateTime(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
class HomeMediaFile(Base):
__tablename__ = "home_media_files"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(String, unique=True, nullable=False, index=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
original_filename = Column(String, nullable=True)
content_type = Column(String, nullable=True)
storage_filename = Column(String, nullable=False)
size_bytes = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
Base.metadata.create_all(bind=engine)
@ -71,6 +102,12 @@ def _ensure_sqlite_message_columns():
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
if "edited_at" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME"))
if "message_type" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN message_type VARCHAR(32) DEFAULT 'text' NOT NULL"))
if "file_id" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN file_id VARCHAR(255)"))
if "encrypted_key" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN encrypted_key VARCHAR(1024)"))
conn.commit()

View File

@ -68,6 +68,13 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
temp_id = message_data.get("temp_id")
content = message_data.get("content")
content50 = message_data.get("content50")
message_type = message_data.get("message_type") or "text"
file_id = message_data.get("file_id")
encrypted_key = message_data.get("encrypted_key")
print(
f"DEBUG private_message payload: temp_id={temp_id}, receiver_id={receiver_id}, message_type={message_type}, file_id={file_id}, encrypted_key_present={encrypted_key is not None}",
)
if receiver_id is None or content is None:
await websocket.send_json({
@ -89,6 +96,9 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
sender_id=user_id,
receiver_id=receiver_id,
content=content,
message_type=message_type,
file_id=file_id,
encrypted_key=encrypted_key,
reply_to_id=message_data.get("reply_to_id"),
reply_to_text=message_data.get("reply_to_text")
)
@ -96,6 +106,10 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
db.commit()
db.refresh(new_msg)
print(
f"DEBUG saved message: id={new_msg.id}, sender={new_msg.sender_id}, receiver={new_msg.receiver_id}, message_type={new_msg.message_type}, file_id={new_msg.file_id}, encrypted_key_present={new_msg.encrypted_key is not None}",
)
# ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента).
await manager.send_personal_message({
"type": "message_sent",
@ -124,14 +138,22 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"sender_id": user_id,
"receiver_id": receiver_id,
"content": message_data.get("content"),
"message_type": message_type,
"file_id": file_id,
"encrypted_key": message_data.get("encrypted_key"),
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
"reply_to_id": new_msg.reply_to_id,
"reply_to_text": new_msg.reply_to_text,
}
print(
f"DEBUG outgoing_message: id={outgoing_message['id']}, receiver_id={outgoing_message['receiver_id']}, file_id={outgoing_message['file_id']}, encrypted_key_present={outgoing_message['encrypted_key'] is not None}",
)
# Пересылаем получателю, если он в сети
sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id))
print(f"DEBUG send_personal_message returned: {sent_to_receiver}")
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
if sent_to_receiver:
try:

View File

@ -57,6 +57,10 @@ async def head_image():
@app.on_event("startup")
async def startup_event():
asyncio.create_task(cleanup_uploads())
if config.SERVER_ROLE == 'cloud':
asyncio.create_task(media.forward_pending_media_loop())
elif config.SERVER_ROLE == 'home':
asyncio.create_task(media.home_storage_maintenance_loop())
async def cleanup_uploads():

View File

@ -9,6 +9,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <gal/gal_plugin_c_api.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GalPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GalPluginCApi"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_core
flutter_secure_storage_windows
gal
local_auth_windows
url_launcher_windows
)