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

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:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:chepuhagram/data/models/message_model.dart'; import 'package:chepuhagram/data/models/message_model.dart';
import 'dart:typed_data';
class LocalDbService { class LocalDbService {
static final LocalDbService _instance = LocalDbService._internal(); static final LocalDbService _instance = LocalDbService._internal();
@ -19,7 +20,7 @@ class LocalDbService {
String path = join(await getDatabasesPath(), 'chat_app.db'); String path = join(await getDatabasesPath(), 'chat_app.db');
return await openDatabase( return await openDatabase(
path, path,
version: 4, version: 7,
onCreate: (db, version) async { onCreate: (db, version) async {
await db.execute(''' await db.execute('''
CREATE TABLE messages( CREATE TABLE messages(
@ -32,7 +33,10 @@ class LocalDbService {
read_at TEXT, read_at TEXT,
reply_to_id INTEGER, reply_to_id INTEGER,
reply_to_text TEXT, 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) { if (oldVersion < 4) {
await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT'); 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'); await db.delete('messages');
} }
// Сохранение списка сообщений (из истории)
Future<void> saveMessages(List<dynamic> messages) async { Future<void> saveMessages(List<dynamic> messages) async {
final db = await database; 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(); Batch batch = db.batch();
if (incomingIds.isNotEmpty) {
batch.delete('messages', where: 'id NOT IN (${incomingIds.join(',')})');
}
for (var msg in messages) { for (var msg in messages) {
if (msg is MessageModel) { if (msg is MessageModel) {
batch.insert('messages', { batch.insert('messages', {
'id': msg.id, 'id': msg.id,
'sender_id': msg.senderId, 'sender_id': msg.senderId,
'receiver_id': msg.receiverId, 'receiver_id': msg.receiverId,
'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст! 'content': msg.text,
'timestamp': msg.createdAt.toIso8601String(), 'timestamp': msg.createdAt.toIso8601String(),
'delivered_at': null, 'delivered_at': null,
'read_at': null, 'read_at': null,
'reply_to_id': msg.replyToId, 'reply_to_id': msg.replyToId,
'reply_to_text': msg.replyToText, 'reply_to_text': msg.replyToText,
'edited_at': msg.editedAt?.toIso8601String(), '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); }, conflictAlgorithm: ConflictAlgorithm.replace);
} else { } else {
// Если это Map из API // Если это Map из API
@ -93,6 +142,9 @@ class LocalDbService {
'reply_to_id': msg['reply_to_id'], 'reply_to_id': msg['reply_to_id'],
'reply_to_text': msg['reply_to_text'], 'reply_to_text': msg['reply_to_text'],
'edited_at': msg['edited_at'], 'edited_at': msg['edited_at'],
'message_type': msg['message_type'] ?? 'text',
'file_id': msg['file_id'],
'encrypted_key': msg['encrypted_key'],
}, conflictAlgorithm: ConflictAlgorithm.replace); }, 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( Future<void> updateMessageContent(
int messageId, int messageId,
String content, String content,

View File

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

View File

@ -2,6 +2,8 @@ import 'dart:typed_data';
enum MessageStatus { sending, sent, delivered, read, failed } enum MessageStatus { sending, sent, delivered, read, failed }
enum MessageType { text, image }
class MessageModel { class MessageModel {
final int? id; // server id (null пока не подтверждено сервером) final int? id; // server id (null пока не подтверждено сервером)
final int? tempId; // client temp id (для сопоставления ack) final int? tempId; // client temp id (для сопоставления ack)
@ -15,6 +17,9 @@ class MessageModel {
final String? replyToText; // текст сообщения, на которое отвечают (для отображения) final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
final DateTime? editedAt; final DateTime? editedAt;
final Uint8List? localFileBytes; final Uint8List? localFileBytes;
final MessageType messageType;
final String? fileId;
final String? encryptedFileKey;
MessageModel({ MessageModel({
this.id, this.id,
@ -28,7 +33,10 @@ class MessageModel {
this.replyToId, this.replyToId,
this.replyToText, this.replyToText,
this.editedAt, this.editedAt,
this.localFileBytes this.localFileBytes,
this.messageType = MessageType.text,
this.fileId,
this.encryptedFileKey,
}); });
MessageModel copyWith({ MessageModel copyWith({
@ -44,6 +52,9 @@ class MessageModel {
String? replyToText, String? replyToText,
DateTime? editedAt, DateTime? editedAt,
Uint8List? localFileBytes, Uint8List? localFileBytes,
MessageType? messageType,
String? fileId,
String? encryptedFileKey,
}) { }) {
return MessageModel( return MessageModel(
id: id ?? this.id, id: id ?? this.id,
@ -58,6 +69,9 @@ class MessageModel {
replyToText: replyToText ?? this.replyToText, replyToText: replyToText ?? this.replyToText,
editedAt: editedAt ?? this.editedAt, editedAt: editedAt ?? this.editedAt,
localFileBytes: localFileBytes ?? this.localFileBytes, 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()), 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(), replyToText: json['reply_to_text'] == null ? null : json['reply_to_text'].toString(),
editedAt: json['edited_at'] == null ? null : DateTime.tryParse(json['edited_at'].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_id': replyToId,
'reply_to_text': replyToText, 'reply_to_text': replyToText,
'edited_at': editedAt?.toIso8601String(), '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 'package:jwt_decoder/jwt_decoder.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:chepuhagram/core/constants.dart'; import 'package:chepuhagram/core/constants.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:convert';
class ApiService extends ChangeNotifier { class ApiService extends ChangeNotifier {
final _client = http.Client(); final _client = http.Client();
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
bool _isRefreshing = false; bool _isRefreshing = false;
Future<String?> uploadMedia(List<int> bytes) async { Future<String?> uploadMedia(List<int> bytes, {String purpose = 'media'}) async {
try { try {
final token = getAccessToken(); final token = await getAccessToken();
var request = http.MultipartRequest( var request = http.MultipartRequest(
'POST', 'POST',
Uri.parse('${AppConstants.baseUrl}/media/upload'), Uri.parse('${AppConstants.baseUrl}/media/v2/upload'),
); );
request.headers.addAll({ request.headers.addAll({
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',
@ -28,9 +30,8 @@ class ApiService extends ChangeNotifier {
filename: 'media.enc', // Имя файла для сервера filename: 'media.enc', // Имя файла для сервера
), ),
); );
// Добавляем purpose
// Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.) request.fields['purpose'] = purpose;
// request.headers.addAll({'Authorization': 'Bearer $token'});
var streamedResponse = await request.send().timeout(Duration(seconds: 30)); var streamedResponse = await request.send().timeout(Duration(seconds: 30));
var response = await http.Response.fromStream(streamedResponse); var response = await http.Response.fromStream(streamedResponse);
@ -227,6 +228,26 @@ class ApiService extends ChangeNotifier {
return jsonDecode(response.body) as List<dynamic>; 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({ Future<Map<String, dynamic>> updateMe({
required String username, required String username,
required String firstName, required String firstName,

View File

@ -1,6 +1,7 @@
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 'dart:typed_data';
import 'package:chepuhagram/data/models/contact_model.dart'; import 'package:chepuhagram/data/models/contact_model.dart';
class CryptoService { class CryptoService {
@ -192,10 +193,19 @@ class CryptoService {
contact.copyWith( contact.copyWith(
lastMessage: utf8.decode(decrypted), lastMessage: utf8.decode(decrypted),
isLastMsgDecrypted: true, isLastMsgDecrypted: true,
avatarFileId: contact.avatarFileId,
avatarUrl: contact.avatarUrl,
), ),
); );
} catch (e) { } 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; 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 { Future<String> decryptMessage(String base64Data, SecretKey sharedKey) async {
final data = base64Decode(base64Data); final data = base64Decode(base64Data);

View File

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

View File

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

View File

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

View File

@ -1,8 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cryptography/cryptography.dart';
import '/data/models/message_model.dart'; import '/data/models/message_model.dart';
import '/data/models/contact_model.dart'; import '/data/models/contact_model.dart';
import 'package:chepuhagram/presentation/widgets/message_bubble.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/data/repositories/contact_repository.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart'; import 'package:chepuhagram/data/datasources/ws_client.dart';
@ -18,7 +22,6 @@ import 'package:flutter/services.dart';
import 'user_profile_screen.dart'; import 'user_profile_screen.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '/core/theme_manager.dart'; import '/core/theme_manager.dart';
import 'dart:io';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
final Contact contact; final Contact contact;
@ -43,6 +46,9 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
StreamSubscription<dynamic>? _socketSubscription; StreamSubscription<dynamic>? _socketSubscription;
final Set<int> _sentReadReceipts = <int>{}; final Set<int> _sentReadReceipts = <int>{};
final LocalDbService _localDbService = LocalDbService(); final LocalDbService _localDbService = LocalDbService();
final ScrollController _scrollController = ScrollController();
final Map<int, GlobalKey> _messageKeys = {};
bool _showScrollToEnd = false;
Uint8List? _pendingImageBytes; Uint8List? _pendingImageBytes;
MessageModel? _replyTo; MessageModel? _replyTo;
bool _isOnline = false; bool _isOnline = false;
@ -73,6 +79,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
startOnlineUpdates(); startOnlineUpdates();
_controller.addListener(_sendTypingStatus); _controller.addListener(_sendTypingStatus);
_scrollController.addListener(_updateScrollButtonVisibility);
final socketService = Provider.of<SocketService>(context, listen: false); final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage); _socketSubscription = socketService.messages.listen(_handleIncomingMessage);
} }
@ -197,6 +205,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
void dispose() { void dispose() {
currentActiveChatContactId = null; currentActiveChatContactId = null;
_socketSubscription?.cancel(); _socketSubscription?.cancel();
_scrollController.removeListener(_updateScrollButtonVisibility);
_scrollController.dispose();
_controller.dispose(); _controller.dispose();
routeObserver.unsubscribe(this); routeObserver.unsubscribe(this);
_inputFocusNode.dispose(); _inputFocusNode.dispose();
@ -301,13 +311,27 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
) )
: null, : null,
child: ListView.builder( child: ListView.builder(
controller: _scrollController,
reverse: true, // Сообщения растут снизу вверх reverse: true, // Сообщения растут снизу вверх
itemCount: messages.length, itemCount: messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index]; final msg = messages[messages.length - 1 - index];
final keyId = msg.id ?? msg.tempId ?? index;
final itemKey = _messageKeys.putIfAbsent(
keyId,
() => GlobalKey(),
);
return MessageBubble( return MessageBubble(
key: itemKey,
message: msg, message: msg,
onTap: () => _showMessageActions(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(), _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('Ответить'), title: const Text('Ответить'),
onTap: () { onTap: () {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
setState(() => _replyTo = msg); String text = msg.text;
_inputFocusNode.requestFocus(); if (msg.text.isEmpty && msg.messageType == MessageType.image) {
text = "[Фото]";
}
setState(() => _replyTo = msg.copyWith(text: text));
}, },
), ),
if (msg.isMe) 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( ListTile(
leading: const Icon(Icons.forward), leading: const Icon(Icons.forward),
title: const Text('Переслать'), title: const Text('Переслать'),
@ -577,7 +620,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
Future<void> _forwardMessage(MessageModel msg, Contact targetContact) async { Future<void> _forwardMessage(MessageModel msg, Contact targetContact) async {
final forwardText = msg.text.trim(); final forwardText = msg.text.trim();
if (forwardText.isEmpty) { final isImage = msg.messageType == MessageType.image;
if (forwardText.isEmpty && !isImage) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -603,7 +647,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
), ),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );
return; return;
@ -618,28 +662,90 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
myPrivKey, myPrivKey,
targetContact.publicKey!, targetContact.publicKey!,
); );
String contentToEncrypt = forwardText;
if (contentToEncrypt.isEmpty && isImage) {
contentToEncrypt = "";
}
final encryptedContent = await _cryptoService.encryptMessage( final encryptedContent = await _cryptoService.encryptMessage(
forwardText, contentToEncrypt,
sharedSecret, sharedSecret,
); );
final previewText = forwardText.length > 50 final String previewText = forwardText.isNotEmpty
? (forwardText.length > 50
? forwardText.substring(0, 50) ? forwardText.substring(0, 50)
: forwardText; : forwardText)
: (isImage ? "[Фото]" : "");
final encryptedContent50 = await _cryptoService.encryptMessage( final encryptedContent50 = await _cryptoService.encryptMessage(
previewText, previewText,
sharedSecret, 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 tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel( final localMessage = MessageModel(
tempId: tempId, tempId: tempId,
text: forwardText.isNotEmpty ? forwardText : "[Фото]", text: forwardText.isNotEmpty ? forwardText : (isImage ? "[Фото]" : ""),
isMe: true, isMe: true,
senderId: myId, senderId: myId,
receiverId: targetContact.id, receiverId: targetContact.id,
createdAt: DateTime.now(), createdAt: DateTime.now(),
status: MessageStatus.sending, status: MessageStatus.sending,
localFileBytes: _pendingImageBytes, localFileBytes: isImage ? localImageBytes : null,
messageType: isImage ? MessageType.image : MessageType.text,
fileId: fileIdToSend,
encryptedFileKey: encryptedFileKeyToSend,
); );
if (_currentContact.id == targetContact.id) { if (_currentContact.id == targetContact.id) {
setState(() { setState(() {
@ -648,15 +754,23 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}); });
} }
final ok = Provider.of<SocketService>(context, listen: false) final payload = {
.sendMessage({
'type': 'private_message', 'type': 'private_message',
'receiver_id': targetContact.id, 'receiver_id': targetContact.id,
'message_type': 'text', 'message_type': isImage ? 'image' : 'text',
'content': encryptedContent, 'content': encryptedContent,
'content50': encryptedContent50, 'content50': encryptedContent50,
'temp_id': tempId, 'temp_id': tempId,
}); if (isImage) ...{
'file_id': fileIdToSend,
'encrypted_key': encryptedFileKeyToSend,
},
};
final ok = Provider.of<SocketService>(
context,
listen: false,
).sendMessage(payload);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -667,12 +781,12 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
: 'Не удалось переслать сообщение.', : 'Не удалось переслать сообщение.',
), ),
behavior: SnackBarBehavior.floating, // Обязательно для margin behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only( margin: const EdgeInsets.only(
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
left: 10.0, left: 10.0,
right: 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( SnackBar(
content: Text('Ошибка пересылки: $e'), content: Text('Ошибка пересылки: $e'),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), margin: const EdgeInsets.only(
duration: Duration(seconds: 5), 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), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
_replyTo!.text, _replyTo!.text.isNotEmpty
? _replyTo!.text
: (_replyTo!.messageType == MessageType.image
? "[Фото]"
: ""),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -781,7 +903,6 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _controller, controller: _controller,
focusNode: _inputFocusNode,
minLines: 1, minLines: 1,
maxLines: 5, maxLines: 5,
textInputAction: TextInputAction.newline, textInputAction: TextInputAction.newline,
@ -845,6 +966,7 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
String? encryptedFileKey; String? encryptedFileKey;
String encryptedContent; String encryptedContent;
String encryptedContent50; String encryptedContent50;
String? encryptedReplyToText;
// 2. Если есть изображение сначала загружаем его // 2. Если есть изображение сначала загружаем его
if (hasImage) { if (hasImage) {
@ -883,17 +1005,27 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
sharedSecret, sharedSecret,
); );
if (_replyTo?.id != null && _replyTo!.text.trim().isNotEmpty) {
encryptedReplyToText = await _cryptoService.encryptMessage(
_replyTo!.text,
sharedSecret,
);
}
// 4. Создаем локальную модель для мгновенного отображения // 4. Создаем локальную модель для мгновенного отображения
final tempId = DateTime.now().microsecondsSinceEpoch; final tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel( final localMessage = MessageModel(
tempId: tempId, tempId: tempId,
text: rawText.isNotEmpty ? rawText : "[Фото]", text: rawText,
isMe: true, isMe: true,
senderId: myId, senderId: myId,
receiverId: _currentContact.id, receiverId: _currentContact.id,
createdAt: DateTime.now(), createdAt: DateTime.now(),
status: MessageStatus.sending, status: MessageStatus.sending,
localFileBytes: _pendingImageBytes, localFileBytes: _pendingImageBytes,
messageType: hasImage ? MessageType.image : MessageType.text,
fileId: fileId,
encryptedFileKey: encryptedFileKey,
replyToId: _replyTo?.id, replyToId: _replyTo?.id,
replyToText: _replyTo?.text, replyToText: _replyTo?.text,
); );
@ -917,7 +1049,8 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
}, },
if (_replyTo?.id != null) ...{ if (_replyTo?.id != null) ...{
"reply_to_id": _replyTo!.id, "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') { if (data['type'] == 'private_message') {
print('DEBUG incoming private_message raw: $data');
setState(() { setState(() {
_typingTimer?.cancel(); _typingTimer?.cancel();
_isTyping = false; _isTyping = false;
@ -1137,7 +1271,10 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
); );
// 4. Добавляем в список и обновляем экран // 4. Добавляем в список и обновляем экран
await LocalDbService().saveMessages([data]); String? encryptedFileKey = data['encrypted_key']?.toString();
Uint8List? decryptedImageBytes;
// Lazy load images later
if (!mounted) return; if (!mounted) return;
final serverMessageId = int.tryParse(data['id']?.toString() ?? ''); final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
@ -1150,6 +1287,11 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
_sentReadReceipts.add(serverMessageId); _sentReadReceipts.add(serverMessageId);
} }
final replyToText = await _decryptReplyText(
data['reply_to_text']?.toString(),
sharedSecret,
);
setState(() { setState(() {
messages.add( messages.add(
MessageModel( MessageModel(
@ -1163,12 +1305,22 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
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: data['reply_to_text'] != null replyToText: replyToText,
? data['reply_to_text'].toString() messageType: data['message_type'] == 'image'
: null, ? 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) { } catch (e) {
print("Ошибка расшифровки входящего сообщения: $e"); print("Ошибка расшифровки входящего сообщения: $e");
} }
@ -1224,15 +1376,26 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey); await prefs.remove(_notificationLaunchKey);
try { try {
print('[DEBUG] Начало загрузки истории');
final myPrivKey = await _cryptoService.getPrivateKey(); final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret( final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!, myPrivKey!,
widget.contact.publicKey!, widget.contact.publicKey!,
); );
print('[DEBUG] Ключи получены');
final cached = await _localDbService.getChatHistory( final cached = await _localDbService.getChatHistory(
widget.contact.id, widget.contact.id,
myId, 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 { try {
List<MessageModel> loadedLocalMessages = []; 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( loadedLocalMessages.add(
MessageModel( MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''), id: int.tryParse(msg['id']?.toString() ?? ''),
@ -1272,12 +1440,19 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
replyToId: msg['reply_to_id'] == null replyToId: msg['reply_to_id'] == null
? null ? null
: int.tryParse(msg['reply_to_id'].toString()), : int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] != null replyToText: await _decryptReplyText(
? msg['reply_to_text'].toString() msg['reply_to_text']?.toString(),
: null, sharedSecret,
),
editedAt: msg['edited_at'] != null editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null, : 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); final history = await apiService.getChatHistory(widget.contact.id);
print('[DEBUG] История с сервера загружена: ${history.length} сообщений');
print(history); print(history);
final alreadyReadIncomingMessageIds = <int>{}; final alreadyReadIncomingMessageIds = <int>{};
List<MessageModel> loadedMessages = []; 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( loadedMessages.insert(
0, 0,
MessageModel( MessageModel(
@ -1340,20 +1519,34 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
replyToId: msg['reply_to_id'] == null replyToId: msg['reply_to_id'] == null
? null ? null
: int.tryParse(msg['reply_to_id'].toString()), : int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] != null replyToText: await _decryptReplyText(
? msg['reply_to_text'].toString() msg['reply_to_text']?.toString(),
: null, sharedSecret,
),
editedAt: msg['edited_at'] != null editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset)
: null, : 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 { try {
await _localDbService.deleteChatHistory(widget.contact.id, myId); print('[DEBUG] Начинаем очищение и сохранение истории в локальную БД');
await _localDbService.saveMessages(history); //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) { } catch (e) {
print("Ошибка сохранения истории в локальную базу: $e"); print("[ERROR] Ошибка сохранения истории в локальную базу: $e");
} }
if (!mounted) return; if (!mounted) return;
@ -1378,6 +1571,202 @@ class _ChatScreenState extends State<ChatScreen> with RouteAware {
setState(() => _isKeyLoading = false); 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 { class TypingIndicator extends StatefulWidget {

View File

@ -8,6 +8,7 @@ import '../screens/settings_screen.dart';
import '../screens/new_chat_screen.dart'; import '../screens/new_chat_screen.dart';
import '../screens/chat_screen.dart'; import '../screens/chat_screen.dart';
import '/logic/contact_provider.dart'; import '/logic/contact_provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '/logic/auth_provider.dart'; import '/logic/auth_provider.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.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 ? Theme.of(context).colorScheme.onSurface
: null, : null,
backgroundImage: authProvider.avatarUrl != null backgroundImage: authProvider.avatarUrl != null
? NetworkImage(authProvider.avatarUrl!) ? CachedNetworkImageProvider(authProvider.avatarUrl!)
: authProvider.avatarPath != null : authProvider.avatarPath != null
? FileImage(File(authProvider.avatarPath!)) ? FileImage(File(authProvider.avatarPath!))
: null, : null,

View File

@ -51,9 +51,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authProv = context.watch<AuthProvider>(); final authProv = context.watch<AuthProvider>();
final accountEmail = authProv.email?.isNotEmpty == true final accountUsername = authProv.username?.isNotEmpty == true
? authProv.email!
: authProv.username?.isNotEmpty == true
? '@${authProv.username!}' ? '@${authProv.username!}'
: 'Не указано'; : 'Не указано';
@ -78,7 +76,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
accountEmail: Text( accountEmail: Text(
accountEmail, accountUsername,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
currentAccountPicture: GestureDetector( 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:chepuhagram/data/datasources/ws_client.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '/core/constants.dart'; import '/core/constants.dart';
import 'package:cached_network_image/cached_network_image.dart';
class UserProfileScreen extends StatefulWidget { class UserProfileScreen extends StatefulWidget {
final int userId; final int userId;
@ -135,7 +136,7 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
backgroundImage: backgroundImage:
(avatarUrl != null && _userData?['show_avatar'] == true) (avatarUrl != null && _userData?['show_avatar'] == true)
? NetworkImage(avatarUrl) ? CachedNetworkImageProvider(avatarUrl)
: null, : null,
child: (avatarUrl == null || _userData?['show_avatar'] != true) child: (avatarUrl == null || _userData?['show_avatar'] != true)
? Text( ? Text(

View File

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

View File

@ -3,21 +3,55 @@ import '/data/models/message_model.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'dart:typed_data';
import '/core/theme_manager.dart'; import '/core/theme_manager.dart';
import '/core/constants.dart';
class MessageBubble extends StatelessWidget { class MessageBubble extends StatefulWidget {
final MessageModel message; final MessageModel message;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onReplyTap;
final VoidCallback? onImageTap;
final Future<Uint8List?> Function(MessageModel)? onImageNeeded;
const MessageBubble({ const MessageBubble({
super.key, super.key,
required this.message, required this.message,
this.onTap, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMe = message.isMe; final isMe = widget.message.isMe;
final themeProv = context.watch<ThemeProvider>(); final themeProv = context.watch<ThemeProvider>();
return Align( return Align(
// Выравниваем вправо, если это мое сообщение, и влево если чужое // Выравниваем вправо, если это мое сообщение, и влево если чужое
@ -25,10 +59,10 @@ class MessageBubble extends StatelessWidget {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: onTap, onTap: widget.onTap,
// На телефонах иногда удобнее/надежнее long-press (как в мессенджерах), // На телефонах иногда удобнее/надежнее long-press (как в мессенджерах),
// поэтому поддерживаем оба жеста. // поэтому поддерживаем оба жеста.
onLongPress: onTap, onLongPress: widget.onTap,
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16), topLeft: const Radius.circular(16),
topRight: const Radius.circular(16), topRight: const Radius.circular(16),
@ -57,8 +91,11 @@ class MessageBubble extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (message.replyToText != null) ...[ if (widget.message.replyToText != null) ...[
Container( GestureDetector(
onTap: widget.onReplyTap,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(bottom: 4), margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -82,7 +119,7 @@ class MessageBubble extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
message.replyToText!, widget.message.replyToText!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
@ -95,7 +132,43 @@ class MessageBubble extends StatelessWidget {
], ],
), ),
), ),
),
], ],
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( Linkify(
onOpen: (link) async { onOpen: (link) async {
final Uri url = Uri.parse(link.url); final Uri url = Uri.parse(link.url);
@ -103,22 +176,23 @@ class MessageBubble extends StatelessWidget {
throw Exception('Could not launch $url'); throw Exception('Could not launch $url');
} }
}, },
text: message.text, text: widget.message.text,
style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black), 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), linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold),
), ),
],
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
_formatTime(message.createdAt), _formatTime(widget.message.createdAt),
style: TextStyle( style: TextStyle(
color: isMe ? Colors.black87 : Colors.black54, color: isMe ? Colors.black87 : Colors.black54,
fontSize: 10, fontSize: 10,
), ),
), ),
if (message.editedAt != null) ...[ if (widget.message.editedAt != null) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
'(изменено)', '(изменено)',
@ -132,9 +206,9 @@ class MessageBubble extends StatelessWidget {
if (isMe) ...[ if (isMe) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Icon( Icon(
_statusIcon(message.status), _statusIcon(widget.message.status),
size: 12, 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_image_compress_macos
import flutter_local_notifications import flutter_local_notifications
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import gal
import local_auth_darwin import local_auth_darwin
import package_info_plus import package_info_plus
import path_provider_foundation import path_provider_foundation
@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))

View File

@ -33,6 +33,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" 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: characters:
dependency: transitive dependency: transitive
description: description:
@ -249,11 +273,27 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.5.18" version: "3.5.18"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_image_compress:
dependency: "direct main" dependency: "direct main"
description: description:
@ -408,6 +448,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
gal:
dependency: "direct main"
description:
name: gal
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -648,6 +696,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" 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: open_filex:
dependency: "direct main" dependency: "direct main"
description: description:
@ -689,7 +745,7 @@ packages:
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@ -768,6 +824,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1005,6 +1069,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View File

@ -51,11 +51,15 @@ dependencies:
flutter_linkify: ^6.0.0 flutter_linkify: ^6.0.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2
image_picker: ^1.0.4 image_picker: ^1.0.4
gal: ^2.3.2
flutter_image_compress: ^2.1.0 flutter_image_compress: ^2.1.0
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 convert: ^3.1.2
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.0.2
path_provider: ^2.1.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -1,53 +1,43 @@
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, File, UploadFile, Request from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core import security from sqlalchemy.sql import func
from app.api import schemas
from app.db import models
from jose import JWTError, jwt
from app.core.security import get_current_user 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 os
import re import re
import uuid import uuid
import urllib.request
import urllib.error
from io import BytesIO from io import BytesIO
# бд import asyncio
def get_db(): def _ensure_directory(path: str):
db = models.SessionLocal() if not os.path.exists(path):
try: os.makedirs(path, exist_ok=True)
yield db
finally:
db.close()
mediaRouter = APIRouter(
prefix="/media",
tags=[],
)
UPLOAD_FOLDER = 'uploads' UPLOAD_FOLDER = 'uploads'
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
def _parse_multipart_body(body: bytes): def _parse_multipart_body(body: bytes):
try: try:
if not body.startswith(b'--'): if not body.startswith(b"--"):
return None return None
boundary, rest = body.split(b'\r\n', 1) boundary, _ = body.split(b"\r\n", 1)
parts = body.split(boundary) parts = body.split(boundary)
for part in parts: 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 continue
part = part.strip(b'\r\n') part = part.strip(b"\r\n")
if not part: if not part:
continue 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: if not headers or content is None:
continue continue
@ -77,57 +67,350 @@ def _parse_multipart_body(body: bytes):
return None return None
@mediaRouter.post('/upload') async def _get_upload_file(request: Request, uploaded_file: UploadFile | None):
async def upload_file(request: Request, file: UploadFile = File(None)): if uploaded_file is not None:
uploaded_file = file return uploaded_file
if uploaded_file is None:
raw_body = await request.body() raw_body = await request.body()
parsed = _parse_multipart_body(raw_body) parsed = _parse_multipart_body(raw_body)
if parsed is not None: if parsed is None:
return None
filename, content, content_type = parsed filename, content, content_type = parsed
uploaded_file = UploadFile( return UploadFile(filename=filename, file=BytesIO(content), content_type=content_type)
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 = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename: 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() content = await uploaded_file.read()
if len(content) > MAX_FILE_SIZE: if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=400, detail="File too large (max 10MB)") raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
# Валидация типа файла (для зашифрованных файлов пропускаем, так как content_type не image) file_id = uuid.uuid4().hex
# 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())
filename = f"{file_id}.enc" filename = f"{file_id}.enc"
file_path = os.path.join(UPLOAD_FOLDER, filename) 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) f.write(content)
print(f"Файл сохранен: {file_path}")
return { return {
"status": "ok", 'status': 'ok',
"file_id": file_id '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}') @mediaRouter.get('/{file_id}')
async def get_file(file_id: str): async def get_file(file_id: str):
filename = f"{file_id}.enc" local_path = _find_local_media_path(file_id)
file_path = os.path.join(UPLOAD_FOLDER, filename) if local_path:
return FileResponse(local_path, media_type='application/octet-stream')
if not os.path.exists(file_path): if config.SERVER_ROLE == 'cloud':
raise HTTPException(status_code=404, detail="File not found") 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 == current_user.id) & (models.Message.receiver_id == contact_id) |
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id) (models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.desc()).limit(limit).all() ).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) return jsonable_encoder(messages)

View File

@ -250,7 +250,7 @@ async def read_users_chats(
"username": user.username, "username": user.username,
"name": f"{user.first_name} {user.last_name or ''}".strip(), "name": f"{user.first_name} {user.last_name or ''}".strip(),
"public_key": user.public_key, "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, "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": last_msg.content if last_msg else None,
"last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp 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 # Server
HOST: str = os.getenv("HOST", "0.0.0.0") HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000")) 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 # CORS
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",") 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}) engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()
Base.metadata.create_all(bind=engine)
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -50,6 +49,38 @@ class Message(Base):
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True) reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
reply_to_text = Column(Text, nullable=True) reply_to_text = Column(Text, nullable=True)
edited_at = Column(DateTime(timezone=True), 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) 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")) conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
if "edited_at" not in existing: if "edited_at" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME")) 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() 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") temp_id = message_data.get("temp_id")
content = message_data.get("content") content = message_data.get("content")
content50 = message_data.get("content50") 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: if receiver_id is None or content is None:
await websocket.send_json({ await websocket.send_json({
@ -89,6 +96,9 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
sender_id=user_id, sender_id=user_id,
receiver_id=receiver_id, receiver_id=receiver_id,
content=content, 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_id=message_data.get("reply_to_id"),
reply_to_text=message_data.get("reply_to_text") 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.commit()
db.refresh(new_msg) 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 отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента). # ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента).
await manager.send_personal_message({ await manager.send_personal_message({
"type": "message_sent", "type": "message_sent",
@ -124,14 +138,22 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"sender_id": user_id, "sender_id": user_id,
"receiver_id": receiver_id, "receiver_id": receiver_id,
"content": message_data.get("content"), "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(), "timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
"reply_to_id": new_msg.reply_to_id, "reply_to_id": new_msg.reply_to_id,
"reply_to_text": new_msg.reply_to_text, "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)) 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. # Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
if sent_to_receiver: if sent_to_receiver:
try: try:

View File

@ -57,6 +57,10 @@ async def head_image():
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
asyncio.create_task(cleanup_uploads()) 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(): async def cleanup_uploads():

View File

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

View File

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