Отправка фотографий
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: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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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.substring(0, 50)
|
? (forwardText.length > 50
|
||||||
: forwardText;
|
? forwardText.substring(0, 50)
|
||||||
|
: 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': isImage ? 'image' : 'text',
|
||||||
'message_type': '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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,68 +91,108 @@ 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(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
onTap: widget.onReplyTap,
|
||||||
margin: const EdgeInsets.only(bottom: 4),
|
behavior: HitTestBehavior.opaque,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: (isMe ? Colors.white : Colors.black).withOpacity(0.1),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
borderRadius: BorderRadius.circular(8),
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
border: Border(
|
decoration: BoxDecoration(
|
||||||
left: BorderSide(
|
color: (isMe ? Colors.white : Colors.black).withOpacity(0.1),
|
||||||
color: isMe ? Colors.black54 : Colors.black38,
|
borderRadius: BorderRadius.circular(8),
|
||||||
width: 2,
|
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
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(
|
if (widget.message.messageType == MessageType.image) ...[
|
||||||
onOpen: (link) async {
|
GestureDetector(
|
||||||
final Uri url = Uri.parse(link.url);
|
onTap: widget.onImageTap,
|
||||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
child: ClipRRect(
|
||||||
throw Exception('Could not launch $url');
|
borderRadius: BorderRadius.circular(8),
|
||||||
}
|
child: (_imageBytes ?? widget.message.localFileBytes) != null
|
||||||
},
|
? Image.memory(
|
||||||
text: message.text,
|
_imageBytes ?? widget.message.localFileBytes!,
|
||||||
style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black),
|
width: 200,
|
||||||
linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold),
|
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),
|
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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
74
pubspec.lock
74
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
@mediaRouter.post('/upload')
|
||||||
async def upload_file(request: Request, file: UploadFile = File(None)):
|
async def upload_file(
|
||||||
uploaded_file = file
|
request: Request,
|
||||||
if uploaded_file is None:
|
file: UploadFile = File(None),
|
||||||
raw_body = await request.body()
|
):
|
||||||
parsed = _parse_multipart_body(raw_body)
|
uploaded_file = await _get_upload_file(request, file)
|
||||||
if parsed is not None:
|
|
||||||
filename, content, content_type = parsed
|
|
||||||
uploaded_file = UploadFile(
|
|
||||||
filename=filename,
|
|
||||||
file=BytesIO(content),
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
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')
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(",")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue