Отправка фотографий
This commit is contained in:
parent
ee7d325856
commit
981d322e1d
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -155,8 +155,8 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'О себе',
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
maxLines: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
74
pubspec.lock
74
pubspec.lock
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -43,13 +42,45 @@ class Message(Base):
|
|||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("users.id"))
|
||||
receiver_id = Column(Integer, ForeignKey("users.id"))
|
||||
content = Column(Text)
|
||||
content = Column(Text)
|
||||
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||
delivered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||
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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue