688 lines
21 KiB
Dart
688 lines
21 KiB
Dart
import 'dart:math';
|
||
import 'package:chepuhagram/data/models/contact_model.dart';
|
||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||
import 'dart:convert';
|
||
import 'dart:typed_data';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||
import 'package:chepuhagram/core/constants.dart';
|
||
import 'package:flutter_http_cache/flutter_http_cache.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'dart:io';
|
||
|
||
class ApiService extends ChangeNotifier {
|
||
final _client = http.Client();
|
||
final _storage = const FlutterSecureStorage();
|
||
bool _isRefreshing = false;
|
||
bool _isCacheInitialized = false;
|
||
|
||
final cache = HttpCache(
|
||
config: const CacheConfig(
|
||
maxMemorySize: 100 * 1024 * 1024, // 100MB
|
||
maxDiskSize: 500 * 1024 * 1024, // 500MB
|
||
),
|
||
);
|
||
|
||
Future<void> _ensureCacheReady() async {
|
||
if (!_isCacheInitialized) {
|
||
await cache.initialize();
|
||
_isCacheInitialized = true;
|
||
}
|
||
}
|
||
|
||
/// Получает данные пользователя (включая его публичный ключ E2EE) по username
|
||
Future<Contact?> getUserByUsername(String username) async {
|
||
try {
|
||
// Подставляй свой эндпоинт, например: /users/by-username/
|
||
final response = await Dio().get('/users/by-username/$username');
|
||
|
||
if (response.statusCode == 200 && response.data != null) {
|
||
// Парсим полученные данные в модель контакта.
|
||
// Убедись, что метод Contact.fromJson или Contact.fromMap корректно обрабатывает поле public_key
|
||
return Contact.fromJson(response.data);
|
||
}
|
||
return null;
|
||
} catch (e) {
|
||
print("[ApiService] Ошибка при получении пользователя по username: $e");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<String?> copyMediaOnServer(String fileId, int receiverId) async {
|
||
try {
|
||
final token = await getAccessToken();
|
||
final response = await Dio().post(
|
||
'${AppConstants.baseUrl}/media/copy',
|
||
data: FormData.fromMap({'file_id': fileId, 'receiver_id': receiverId}),
|
||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||
);
|
||
if (response.statusCode == 200) {
|
||
return response.data['new_file_id'];
|
||
}
|
||
} catch (e) {
|
||
print("Ошибка копирования на сервере: $e");
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<String?> uploadFile(
|
||
List<int> bytes, {
|
||
String purpose = 'media',
|
||
Function(double)? onProgress,
|
||
}) async {
|
||
final token = await getAccessToken();
|
||
final dio = Dio();
|
||
final formData = FormData.fromMap({
|
||
'file': MultipartFile.fromBytes(bytes, filename: 'media.enc'),
|
||
'purpose': purpose,
|
||
});
|
||
|
||
final response = await dio.post(
|
||
'${AppConstants.baseUrl}/media/v2/upload',
|
||
data: formData,
|
||
onSendProgress: (sent, total) {
|
||
onProgress?.call(sent / total);
|
||
},
|
||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||
);
|
||
|
||
return response.data['file_id'];
|
||
}
|
||
|
||
Future<(int?, String?)> getRemoteFileSizeAndName(String fileId) async {
|
||
try {
|
||
final token = await getAccessToken();
|
||
final url =
|
||
'${AppConstants.baseUrl}/media/size/$fileId'; // Скорректируй путь согласно роутеру
|
||
|
||
final response = await Dio().get(
|
||
url,
|
||
options: Options(
|
||
headers: {
|
||
'Authorization': 'Bearer $token', // Твой токен, если требуется
|
||
},
|
||
),
|
||
);
|
||
|
||
if (response.statusCode == 200 && response.data != null) {
|
||
// Извлекаем размер из JSON: {"file_id": "...", "size": 123456}
|
||
final intSize = response.data['size'] as int?;
|
||
String? fileName = response.data['file_name'] as String?;
|
||
|
||
if (fileName != null) {
|
||
fileName = Uri.decodeComponent(fileName);
|
||
print("Имя файла, полученное от сервера: $fileName");
|
||
}
|
||
debugPrint(
|
||
'Успешно получен размер файла через API-size: $intSize байт',
|
||
);
|
||
return (intSize, fileName);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Ошибка при получении размера файла через API-size: $e');
|
||
}
|
||
return (null, null);
|
||
}
|
||
|
||
Future<(http.ByteStream, String)> downloadFileAsStream(String fileId) async {
|
||
final token = await getAccessToken(); // Получаем JWT токен авторизации
|
||
|
||
final url = Uri.parse(
|
||
'${AppConstants.baseUrl}/media/$fileId',
|
||
); // Замените на ваш эндпоинт скачивания
|
||
|
||
final request = http.Request('GET', url);
|
||
request.headers.addAll({'Authorization': 'Bearer $token'});
|
||
|
||
// Отправляем запрос
|
||
final http.StreamedResponse response = await _client.send(request);
|
||
|
||
final contentDisposition = response.headers['content-disposition'];
|
||
String serverFileName = 'media.enc';
|
||
|
||
if (contentDisposition != null) {
|
||
final match = RegExp(
|
||
r"filename\*=UTF-8''(.+)",
|
||
).firstMatch(contentDisposition);
|
||
if (match != null) {
|
||
serverFileName = Uri.decodeComponent(match.group(1)!);
|
||
print("Имя файла, полученное от сервера: $serverFileName");
|
||
}
|
||
}
|
||
|
||
if (response.statusCode == 200) {
|
||
return (response.stream, serverFileName);
|
||
} else {
|
||
throw Exception(
|
||
'Ошибка скачивания файла: сервер вернул статус ${response.statusCode}',
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<File?> downloadFile(String fileId, String filePath) async {
|
||
final token = await getAccessToken();
|
||
try {
|
||
final response = await Dio().download(
|
||
'${AppConstants.baseUrl}/media/$fileId',
|
||
filePath,
|
||
options: Options(
|
||
headers: {'Authorization': 'Bearer $token'},
|
||
validateStatus: (status) => status == 200,
|
||
),
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
return File(filePath);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Ошибка при скачивании: $e');
|
||
final file = File(filePath);
|
||
if (await file.exists()) await file.delete();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<String?> uploadFileStream(
|
||
Stream<List<int>> stream,
|
||
int sourceLength, {
|
||
String purpose = 'media',
|
||
void Function(int processed, int total)? onProgress,
|
||
String? fileName,
|
||
}) async {
|
||
try {
|
||
final token = await getAccessToken();
|
||
final dio = Dio();
|
||
|
||
print(
|
||
'[DEBUG] uploadFileStream: работаем через поточное чтение. Размер=$sourceLength bytes, purpose=$purpose',
|
||
);
|
||
|
||
// БЕЗОПАСНО ДЛЯ RAM: Передаем стрим напрямую в Dio. Память не забивается!
|
||
final formData = FormData.fromMap({
|
||
'file': MultipartFile.fromStream(
|
||
() => stream,
|
||
sourceLength, // Передаем точную длину стрима, это важно для прогресса!
|
||
filename: fileName,
|
||
),
|
||
'purpose': purpose,
|
||
});
|
||
|
||
final response = await dio.post(
|
||
'${AppConstants.baseUrl}/media/v2/upload',
|
||
data: formData,
|
||
onSendProgress: (sent, total) {
|
||
// Твой print(sent) теперь будет вызываться динамически по мере ухода байт в сеть!
|
||
if (total > 0) {
|
||
onProgress?.call(sent, total);
|
||
}
|
||
},
|
||
options: Options(
|
||
headers: {'Authorization': 'Bearer $token'},
|
||
validateStatus: (status) => status != null && status < 500,
|
||
),
|
||
);
|
||
|
||
print('[DEBUG] uploadFileStream response status=${response.statusCode}');
|
||
print('[DEBUG] uploadFileStream response data=${response.data}');
|
||
|
||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||
final fileId = response.data['file_id']?.toString();
|
||
print('[DEBUG] uploadFileStream: успешно загружен, file_id=$fileId');
|
||
return fileId;
|
||
} else {
|
||
print(
|
||
'[ERROR] uploadFileStream: ошибка ${response.statusCode} - ${response.data}',
|
||
);
|
||
return null;
|
||
}
|
||
} catch (e) {
|
||
print('[ERROR] uploadFileStream exception: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<bool> refreshToken() async {
|
||
if (_isRefreshing) {
|
||
// Already refreshing, wait for completion or return true assuming it will succeed
|
||
return true;
|
||
}
|
||
_isRefreshing = true;
|
||
notifyListeners();
|
||
|
||
try {
|
||
final refreshToken = await _storage.read(key: 'refresh_token');
|
||
final response = await _client
|
||
.post(
|
||
Uri.parse('${AppConstants.baseUrl}/auth/refresh'),
|
||
body: jsonEncode({'refresh_token': refreshToken}),
|
||
headers: {'Content-Type': 'application/json'},
|
||
)
|
||
.timeout(Duration(seconds: 30));
|
||
|
||
final decodedResponse =
|
||
jsonDecode(utf8.decode(response.bodyBytes)) as Map;
|
||
|
||
if (response.statusCode == 200) {
|
||
await _storage.write(
|
||
key: 'access_token',
|
||
value: decodedResponse['access_token'],
|
||
);
|
||
await _storage.write(
|
||
key: 'refresh_token',
|
||
value: decodedResponse['refresh_token'],
|
||
);
|
||
notifyListeners();
|
||
return true;
|
||
} else {
|
||
notifyListeners();
|
||
return false;
|
||
}
|
||
} catch (e) {
|
||
notifyListeners();
|
||
return false;
|
||
} finally {
|
||
_isRefreshing = false;
|
||
}
|
||
}
|
||
|
||
Future<String?> getAccessToken() async {
|
||
String? token = await _storage.read(key: 'access_token');
|
||
|
||
if (token != null) {
|
||
bool isExpiredSoon =
|
||
JwtDecoder.isExpired(token) ||
|
||
JwtDecoder.getRemainingTime(token).inMinutes < 2;
|
||
|
||
if (isExpiredSoon) {
|
||
bool refreshed = await refreshToken();
|
||
if (refreshed) {
|
||
token = await _storage.read(key: 'access_token');
|
||
} else {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
return token;
|
||
}
|
||
|
||
Future<bool> updateFcmToken(String fcmtoken) async {
|
||
notifyListeners();
|
||
try {
|
||
final token = await getAccessToken();
|
||
if (token == null) return false; // Нет токена — прерываем выполнение
|
||
|
||
final response = await _client
|
||
.post(
|
||
Uri.parse(
|
||
'${AppConstants.baseUrl}/auth/update-fcm?token=$fcmtoken',
|
||
),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
)
|
||
.timeout(const Duration(seconds: 10)); // Ограничиваем время ожидания
|
||
|
||
if (response.statusCode == 200) {
|
||
return true;
|
||
} else {
|
||
print("Ошибка установки FCM ключа: ${response.statusCode}");
|
||
return false;
|
||
}
|
||
} catch (e) {
|
||
print("ℹ️ Не удалось обновить FCM токен (нет сети): $e");
|
||
return false; // Возвращаем false вместо падения приложения
|
||
} finally {
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
Future<bool> setPublicKey(String publicKey) async {
|
||
notifyListeners();
|
||
|
||
try {
|
||
final token = await getAccessToken();
|
||
final response = await _client.post(
|
||
Uri.parse('${AppConstants.baseUrl}/auth/set-public-key'),
|
||
body: jsonEncode({'public_key': publicKey}),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
return true;
|
||
} else {
|
||
print("Ошибка установки ключа: ${response.statusCode}");
|
||
return false;
|
||
}
|
||
} catch (e) {
|
||
rethrow;
|
||
} finally {
|
||
notifyListeners();
|
||
}
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getMe() async {
|
||
final token = await getAccessToken();
|
||
await cache.initialize();
|
||
final client = CachedHttpClient(
|
||
cache: cache,
|
||
defaultCachePolicy: CachePolicy.networkFirst,
|
||
);
|
||
final response = await client.get(
|
||
Uri.parse('${AppConstants.baseUrl}/users/me'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
return jsonDecode(utf8.decode(response.bodyBytes))
|
||
as Map<String, dynamic>;
|
||
}
|
||
throw Exception('Не удалось получить данные пользователя');
|
||
}
|
||
|
||
Future<bool> updateEncryptedPrivateKey(String encryptedPrivateKey) async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.put(
|
||
Uri.parse('${AppConstants.baseUrl}/users/me/encryption-key'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
body: jsonEncode({'encrypted_private_key': encryptedPrivateKey}),
|
||
);
|
||
|
||
return response.statusCode == 200;
|
||
}
|
||
|
||
Future<bool> changePassword(
|
||
String currentPassword,
|
||
String newPassword,
|
||
) async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.put(
|
||
Uri.parse('${AppConstants.baseUrl}/users/me/password'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
body: jsonEncode({
|
||
'current_password': currentPassword,
|
||
'new_password': newPassword,
|
||
}),
|
||
);
|
||
|
||
return response.statusCode == 200;
|
||
}
|
||
|
||
Future<List<dynamic>> getChatHistory(
|
||
int contactId, {
|
||
bool forceRefresh = false,
|
||
}) async {
|
||
final token = await getAccessToken();
|
||
await _ensureCacheReady();
|
||
|
||
final client = CachedHttpClient(
|
||
cache: cache,
|
||
defaultCachePolicy: CachePolicy.networkFirst,
|
||
);
|
||
|
||
final Map<String, String> requestHeaders = {
|
||
'Authorization': 'Bearer $token',
|
||
};
|
||
|
||
if (forceRefresh) {
|
||
requestHeaders['Cache-Control'] = 'no-cache';
|
||
}
|
||
|
||
final response = await client.get(
|
||
Uri.parse(
|
||
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
|
||
),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
"Authorization": "Bearer $token",
|
||
},
|
||
);
|
||
return jsonDecode(utf8.decode(response.bodyBytes)) as List<dynamic>;
|
||
}
|
||
|
||
Future<Uint8List?> downloadMedia(
|
||
String fileId, {
|
||
void Function(int received, int total)? onProgress,
|
||
}) async {
|
||
try {
|
||
final token = await getAccessToken();
|
||
await _ensureCacheReady();
|
||
|
||
final client = CachedHttpClient(
|
||
cache: cache,
|
||
defaultCachePolicy: CachePolicy.networkFirst,
|
||
);
|
||
|
||
final uri = Uri.parse('${AppConstants.baseUrl}/media/$fileId');
|
||
|
||
if (onProgress == null) {
|
||
final response = await client.get(
|
||
uri,
|
||
headers: {'Authorization': 'Bearer $token'},
|
||
);
|
||
if (response.statusCode == 200) {
|
||
return response.bodyBytes;
|
||
}
|
||
print('?????? ???????? ?????: ${response.statusCode}');
|
||
return null;
|
||
}
|
||
|
||
final req = http.Request('GET', uri);
|
||
req.headers['Authorization'] = 'Bearer $token';
|
||
final streamed = await client.send(req);
|
||
|
||
final total =
|
||
int.tryParse(streamed.headers['content-length'] ?? '') ?? -1;
|
||
int received = 0;
|
||
final bytes = <int>[];
|
||
await for (final chunk in streamed.stream) {
|
||
bytes.addAll(chunk);
|
||
received += chunk.length;
|
||
onProgress(received, total);
|
||
}
|
||
|
||
if (streamed.statusCode == 200) {
|
||
return Uint8List.fromList(bytes);
|
||
}
|
||
print('?????? ???????? ?????: ${streamed.statusCode}');
|
||
return null;
|
||
} catch (e) {
|
||
print('?????? downloadMedia: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<Map<String, dynamic>> updateMe({
|
||
required String username,
|
||
required String firstName,
|
||
required String lastName,
|
||
String? phone,
|
||
String? email,
|
||
String? about,
|
||
}) async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.put(
|
||
Uri.parse('${AppConstants.baseUrl}/users/me'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
body: jsonEncode({
|
||
'username': username,
|
||
'first_name': firstName,
|
||
'last_name': lastName,
|
||
'phone': (phone == null || phone.trim().isEmpty) ? null : phone.trim(),
|
||
'email': (email == null || email.trim().isEmpty) ? null : email.trim(),
|
||
'about': (about == null || about.trim().isEmpty) ? null : about.trim(),
|
||
}),
|
||
);
|
||
|
||
final decoded = jsonDecode(utf8.decode(response.bodyBytes));
|
||
if (response.statusCode == 200) {
|
||
return decoded as Map<String, dynamic>;
|
||
}
|
||
throw Exception(
|
||
(decoded is Map && decoded['detail'] != null)
|
||
? decoded['detail']
|
||
: 'Failed to update profile',
|
||
);
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getUserById(int userId) async {
|
||
final token = await getAccessToken();
|
||
await _ensureCacheReady();
|
||
final client = CachedHttpClient(
|
||
cache: cache,
|
||
defaultCachePolicy: CachePolicy.networkFirst,
|
||
);
|
||
final response = await client.get(
|
||
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
return jsonDecode(utf8.decode(response.bodyBytes))
|
||
as Map<String, dynamic>;
|
||
}
|
||
throw Exception('Не удалось получить информацию о пользователе');
|
||
}
|
||
|
||
Future<bool> updatePrivacySettings({
|
||
bool? showEmail,
|
||
bool? showPhone,
|
||
bool? showAvatar,
|
||
bool? showAbout,
|
||
bool? showUsername,
|
||
bool? showLastOnline,
|
||
}) async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.put(
|
||
Uri.parse('${AppConstants.baseUrl}/users/me/privacy'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
body: jsonEncode({
|
||
if (showEmail != null) 'show_email': showEmail,
|
||
if (showPhone != null) 'show_phone': showPhone,
|
||
if (showAvatar != null) 'show_avatar': showAvatar,
|
||
if (showAbout != null) 'show_about': showAbout,
|
||
if (showUsername != null) 'show_username': showUsername,
|
||
if (showLastOnline != null) 'show_last_online': showLastOnline,
|
||
}),
|
||
);
|
||
|
||
return response.statusCode == 200;
|
||
}
|
||
|
||
Future<Map<String, dynamic>> getPrivacySettings() async {
|
||
final token = await getAccessToken();
|
||
|
||
await _ensureCacheReady();
|
||
final client = CachedHttpClient(
|
||
cache: cache,
|
||
defaultCachePolicy: CachePolicy.networkFirst,
|
||
);
|
||
final response = await client.get(
|
||
Uri.parse('${AppConstants.baseUrl}/users/me/privacy'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
return jsonDecode(utf8.decode(response.bodyBytes))
|
||
as Map<String, dynamic>;
|
||
}
|
||
throw Exception('Не удалось получить настройки конфиденциальности');
|
||
}
|
||
|
||
Future<bool> updateAvatar(String fileId) async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.put(
|
||
Uri.parse('${AppConstants.baseUrl}/users/me/avatar'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
body: jsonEncode({'avatar_file_id': fileId}),
|
||
);
|
||
return response.statusCode == 200;
|
||
}
|
||
|
||
Future<Map<String, dynamic>> enableTotp() async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.post(
|
||
Uri.parse('${AppConstants.baseUrl}/auth/totp/enable'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
return jsonDecode(utf8.decode(response.bodyBytes))
|
||
as Map<String, dynamic>;
|
||
}
|
||
throw Exception(
|
||
(jsonDecode(response.body) as Map<String, dynamic>)['detail'] ??
|
||
'Failed to enable TOTP',
|
||
);
|
||
}
|
||
|
||
Future<bool> verifyTotp(String code) async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.post(
|
||
Uri.parse('${AppConstants.baseUrl}/auth/totp/verify'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
body: jsonEncode({'code': code}),
|
||
);
|
||
return response.statusCode == 200;
|
||
}
|
||
|
||
Future<bool> disableTotp() async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.post(
|
||
Uri.parse('${AppConstants.baseUrl}/auth/totp/disable'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
);
|
||
return response.statusCode == 200;
|
||
}
|
||
|
||
Future<bool> deleteAllMessages() async {
|
||
final token = await getAccessToken();
|
||
final response = await _client.delete(
|
||
Uri.parse('${AppConstants.baseUrl}/messages/all'),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer $token',
|
||
},
|
||
);
|
||
return response.statusCode == 200;
|
||
}
|
||
}
|