Merge remote-tracking branch 'origin/feature/FullStack' into feature/app-icon-switcher
# Conflicts: # pubspec.lock
This commit is contained in:
Vendored
+226
@@ -0,0 +1,226 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../backend/api.dart';
|
||||
import '../protocol/opcode_map.dart';
|
||||
|
||||
Api? _api;
|
||||
|
||||
void attachInfoCacheApi(Api api) {
|
||||
_api = api;
|
||||
}
|
||||
|
||||
class _Entry<T> {
|
||||
T? value;
|
||||
DateTime? fetchedAt;
|
||||
DateTime? failedAt;
|
||||
Future<T?>? inFlight;
|
||||
}
|
||||
|
||||
class InfoCache<T> {
|
||||
final Duration ttl;
|
||||
final Duration failureBackoff;
|
||||
final Future<T?> Function(int id) fetcher;
|
||||
final Map<int, _Entry<T>> _entries = {};
|
||||
|
||||
InfoCache({
|
||||
required this.ttl,
|
||||
required this.fetcher,
|
||||
this.failureBackoff = const Duration(seconds: 10),
|
||||
});
|
||||
|
||||
bool _isFresh(_Entry<T> e) {
|
||||
if (e.fetchedAt == null) return false;
|
||||
return DateTime.now().difference(e.fetchedAt!) < ttl;
|
||||
}
|
||||
|
||||
bool _isInFailureBackoff(_Entry<T> e) {
|
||||
if (e.failedAt == null) return false;
|
||||
return DateTime.now().difference(e.failedAt!) < failureBackoff;
|
||||
}
|
||||
|
||||
Future<T?> get(int id, {bool forceRefresh = false}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
|
||||
if (!forceRefresh && _isFresh(entry)) {
|
||||
return Future.value(entry.value);
|
||||
}
|
||||
if (!forceRefresh && _isInFailureBackoff(entry)) {
|
||||
return Future.value(null);
|
||||
}
|
||||
if (entry.inFlight != null) return entry.inFlight!;
|
||||
|
||||
final future = _runFetch(entry, id);
|
||||
entry.inFlight = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<T?> _runFetch(_Entry<T> entry, int id) async {
|
||||
try {
|
||||
final result = await fetcher(id);
|
||||
entry.value = result;
|
||||
entry.fetchedAt = DateTime.now();
|
||||
entry.failedAt = null;
|
||||
return result;
|
||||
} catch (_) {
|
||||
entry.failedAt = DateTime.now();
|
||||
return null;
|
||||
} finally {
|
||||
entry.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
T? peek(int id) {
|
||||
final entry = _entries[id];
|
||||
if (entry == null || !_isFresh(entry)) return null;
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
void invalidate(int id) => _entries.remove(id);
|
||||
void clear() => _entries.clear();
|
||||
|
||||
void putValue(int id, T value, {DateTime? at}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
entry.value = value;
|
||||
entry.fetchedAt = at ?? DateTime.now();
|
||||
entry.failedAt = null;
|
||||
}
|
||||
|
||||
void markFailed(int id, {DateTime? at}) {
|
||||
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
|
||||
entry.failedAt = at ?? DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
class ContactInfoFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.contactInfo, {
|
||||
'contactIds': [id],
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return null;
|
||||
final contacts = data['contacts'];
|
||||
if (contacts is! List || contacts.isEmpty) return null;
|
||||
final first = contacts.first;
|
||||
if (first is! Map) return null;
|
||||
return Map<String, dynamic>.from(first);
|
||||
}
|
||||
}
|
||||
|
||||
class PresenceFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
ttl: const Duration(seconds: 60),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
final results = await _fetchBatch([id]);
|
||||
return results[id];
|
||||
}
|
||||
|
||||
static Future<Map<int, Map<String, dynamic>>> getMany(
|
||||
List<int> ids, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final result = <int, Map<String, dynamic>>{};
|
||||
final missing = <int>[];
|
||||
for (final id in ids) {
|
||||
if (!forceRefresh) {
|
||||
final cached = _cache.peek(id);
|
||||
if (cached != null) {
|
||||
result[id] = cached;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
missing.add(id);
|
||||
}
|
||||
if (missing.isNotEmpty) {
|
||||
final fetched = await _fetchBatch(missing);
|
||||
final now = DateTime.now();
|
||||
for (final id in missing) {
|
||||
final value = fetched[id];
|
||||
if (value != null) {
|
||||
_cache.putValue(id, value, at: now);
|
||||
result[id] = value;
|
||||
} else {
|
||||
_cache.markFailed(id, at: now);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<Map<int, Map<String, dynamic>>> _fetchBatch(List<int> ids) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online || ids.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
final resp = await api.sendRequest(Opcode.contactPresence, {
|
||||
'contactIds': ids,
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return const {};
|
||||
final presence = data['presence'];
|
||||
if (presence is! Map) return const {};
|
||||
final out = <int, Map<String, dynamic>>{};
|
||||
for (final id in ids) {
|
||||
final entry = presence[id.toString()] ?? presence[id];
|
||||
if (entry is Map) {
|
||||
out[id] = Map<String, dynamic>.from(entry);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatInfoFetch {
|
||||
static final _cache = InfoCache<Map<String, dynamic>>(
|
||||
ttl: const Duration(minutes: 5),
|
||||
fetcher: _fetch,
|
||||
);
|
||||
|
||||
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
|
||||
_cache.get(id, forceRefresh: forceRefresh);
|
||||
|
||||
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
|
||||
|
||||
static void invalidate(int id) => _cache.invalidate(id);
|
||||
static void clear() => _cache.clear();
|
||||
|
||||
static Future<Map<String, dynamic>?> _fetch(int id) async {
|
||||
final api = _api;
|
||||
if (api == null || api.state != SessionState.online) return null;
|
||||
final resp = await api.sendRequest(Opcode.chatInfo, {
|
||||
'chatIds': [id],
|
||||
});
|
||||
final data = resp.payload;
|
||||
if (data is! Map) return null;
|
||||
final chats = data['chats'];
|
||||
if (chats is! List || chats.isEmpty) return null;
|
||||
final first = chats.first;
|
||||
if (first is! Map) return null;
|
||||
return Map<String, dynamic>.from(first);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class AppFonts {
|
||||
static const String customPrefKey = 'app_custom_fonts';
|
||||
static const String customPrefix = 'g:';
|
||||
|
||||
static const double minScale = 0.85;
|
||||
static const double minScale = 0.60;
|
||||
static const double maxScale = 1.35;
|
||||
static const double defaultScale = 1.0;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppMediaCacheLimit {
|
||||
static const prefKey = 'media_cache_limit_bytes';
|
||||
static const int defaultValue = 500 * 1024 * 1024; // 500 МБ
|
||||
|
||||
/// Значение «без лимита» — вытеснение из кэша отключено.
|
||||
static const int unlimited = 0;
|
||||
|
||||
/// Доступные пресеты лимита, байты (0 — без лимита).
|
||||
static const List<int> presets = [
|
||||
100 * 1024 * 1024,
|
||||
250 * 1024 * 1024,
|
||||
500 * 1024 * 1024,
|
||||
1024 * 1024 * 1024,
|
||||
2 * 1024 * 1024 * 1024,
|
||||
unlimited,
|
||||
];
|
||||
|
||||
static final ValueNotifier<int> current = ValueNotifier(defaultValue);
|
||||
|
||||
static Future<int> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(prefKey) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> save(int value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppPranks {
|
||||
static const prefKey = 'dev_pranks';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppStories {
|
||||
static const prefKey = 'dev_stories';
|
||||
static const bool defaultValue = false;
|
||||
|
||||
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||
|
||||
static Future<bool> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(prefKey) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> save(bool value) async {
|
||||
current.value = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
class ChatCacheFingerprint {
|
||||
static final Uint8List _signatureDigest = _hex(
|
||||
'1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93',
|
||||
);
|
||||
static final Uint8List _soDigest = _hex(
|
||||
'c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111',
|
||||
);
|
||||
static final Uint8List _dexDigest = _hex(
|
||||
'490a2746c7ebbff050353c575a186ca65bc708f9b6e0c1329b59a3bfab6c3924',
|
||||
);
|
||||
|
||||
static Uint8List compute(int callsSeed, String deviceId) {
|
||||
final seed = _int64BigEndian(callsSeed);
|
||||
final device = Uint8List.fromList(utf8.encode(deviceId));
|
||||
final result = BytesBuilder();
|
||||
result.add(_sha256(_signatureDigest, seed, device));
|
||||
result.add(_sha256(_soDigest, seed, device));
|
||||
result.add(_sha256(_dexDigest, seed, device));
|
||||
return result.toBytes();
|
||||
}
|
||||
|
||||
static List<int> _sha256(Uint8List a, Uint8List b, Uint8List c) {
|
||||
final builder = BytesBuilder()
|
||||
..add(a)
|
||||
..add(b)
|
||||
..add(c);
|
||||
return sha256.convert(builder.toBytes()).bytes;
|
||||
}
|
||||
|
||||
static Uint8List _int64BigEndian(int value) {
|
||||
final data = ByteData(8)..setInt64(0, value, Endian.big);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Uint8List _hex(String hex) {
|
||||
final out = Uint8List(hex.length ~/ 2);
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -82,19 +82,41 @@ String messageFromErrorPayload(dynamic payload) {
|
||||
return s.isNotEmpty ? s : 'Неизвестная ошибка';
|
||||
}
|
||||
|
||||
/// Упаковка пакета для отправки на сервер
|
||||
/// Payload меньше этого размера отправляется без сжатия (как в оригинале).
|
||||
const int _compressionThreshold = 32;
|
||||
|
||||
/// Упаковка пакета для отправки на сервер.
|
||||
///
|
||||
/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold]
|
||||
/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия:
|
||||
/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по
|
||||
/// которому получатель выделяет буфер под распаковку).
|
||||
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
|
||||
final header = ByteData(headerSize);
|
||||
final Uint8List raw = msgpack.serialize(payload);
|
||||
|
||||
final List<int> body;
|
||||
final int flag;
|
||||
if (raw.length < _compressionThreshold) {
|
||||
body = raw;
|
||||
flag = 0;
|
||||
} else {
|
||||
body = lz4Compress(raw);
|
||||
flag = (raw.length ~/ body.length) + 1;
|
||||
}
|
||||
|
||||
final out = Uint8List(headerSize + body.length);
|
||||
final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize);
|
||||
header.setUint8(0, 10);
|
||||
header.setUint8(1, CmdType.request);
|
||||
header.setUint16(2, seq, Endian.big);
|
||||
header.setUint16(4, opcode, Endian.big);
|
||||
|
||||
final payloadBytes = msgpack.serialize(payload);
|
||||
final payloadLen = payloadBytes.length & 0xFFFFFF;
|
||||
header.setUint32(6, payloadLen, Endian.big);
|
||||
|
||||
return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes);
|
||||
header.setUint32(
|
||||
6,
|
||||
((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
|
||||
Endian.big,
|
||||
);
|
||||
out.setRange(headerSize, out.length, body);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Распаковка пакета от сервера
|
||||
|
||||
@@ -435,17 +435,20 @@ class AppDatabase {
|
||||
// Chats cache
|
||||
|
||||
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
try {
|
||||
final db = await _instance;
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'chats_cache',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert(
|
||||
'chats_cache',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.e("Ошибка при сохранении чата: $e");
|
||||
}
|
||||
@@ -587,4 +590,33 @@ class AppDatabase {
|
||||
whereArgs: [accountId, chatId],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> loadMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String messageId,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'messages',
|
||||
where: 'account_id = ? AND chat_id = ? AND id = ?',
|
||||
whereArgs: [accountId, chatId, messageId],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first;
|
||||
}
|
||||
|
||||
static Future<void> deleteMessage(
|
||||
int accountId,
|
||||
int chatId,
|
||||
String messageId,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
await db.delete(
|
||||
'messages',
|
||||
where: 'account_id = ? AND chat_id = ? AND id = ?',
|
||||
whereArgs: [accountId, chatId, messageId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SpoofingService {
|
||||
static const String hardcodedAppVersion = '26.14.1';
|
||||
static const int hardcodedBuildNumber = 6606;
|
||||
static const String hardcodedAppVersion = '26.17.1';
|
||||
static const int hardcodedBuildNumber = 6712;
|
||||
|
||||
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -24,6 +24,11 @@ class TokenStorage {
|
||||
await prefs.setString(_activeAccountKey, accountId.toString());
|
||||
}
|
||||
|
||||
static Future<void> clearActiveAccount() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_activeAccountKey);
|
||||
}
|
||||
|
||||
static Future<int?> getActiveAccountId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final val = prefs.getString(_activeAccountKey);
|
||||
|
||||
@@ -7,46 +7,73 @@ import '../utils/logger.dart';
|
||||
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
|
||||
class PacketReceiver {
|
||||
Uint8List _buffer = Uint8List(0);
|
||||
int _start = 0;
|
||||
int _end = 0;
|
||||
|
||||
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
|
||||
|
||||
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
|
||||
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
|
||||
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
|
||||
///
|
||||
/// Накопление идёт без перекопирования всего буфера на каждый чанк: целые
|
||||
/// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается
|
||||
/// сдвигом указателя `_start`, а не пересборкой буфера.
|
||||
List<Uint8List> feed(Uint8List data) {
|
||||
final newBuffer = Uint8List(_buffer.length + data.length);
|
||||
newBuffer.setAll(0, _buffer);
|
||||
newBuffer.setAll(_buffer.length, data);
|
||||
_buffer = newBuffer;
|
||||
_append(data);
|
||||
|
||||
if (_buffer.length > _maxBufferSize) {
|
||||
if (_end - _start > _maxBufferSize) {
|
||||
logger.e(
|
||||
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
|
||||
'PacketReceiver: переполнение буфера (${_end - _start} B), сброс',
|
||||
);
|
||||
reset();
|
||||
return const [];
|
||||
}
|
||||
|
||||
final packets = <Uint8List>[];
|
||||
while (_buffer.length >= headerSize) {
|
||||
while (_end - _start >= headerSize) {
|
||||
final bd = ByteData.view(
|
||||
_buffer.buffer,
|
||||
_buffer.offsetInBytes,
|
||||
_buffer.offsetInBytes + _start,
|
||||
headerSize,
|
||||
);
|
||||
final packedLen = bd.getUint32(6, Endian.big);
|
||||
final payloadLength = packedLen & 0xFFFFFF;
|
||||
final totalLength = headerSize + payloadLength;
|
||||
|
||||
if (_buffer.length < totalLength) break;
|
||||
if (_end - _start < totalLength) break;
|
||||
|
||||
packets.add(Uint8List.sublistView(_buffer, 0, totalLength));
|
||||
_buffer = _buffer.sublist(totalLength);
|
||||
packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength));
|
||||
_start += totalLength;
|
||||
}
|
||||
|
||||
if (_start == _end) {
|
||||
_start = 0;
|
||||
_end = 0;
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
void _append(Uint8List data) {
|
||||
final pending = _end - _start;
|
||||
if (pending == 0) {
|
||||
_buffer = Uint8List.fromList(data);
|
||||
_start = 0;
|
||||
_end = data.length;
|
||||
return;
|
||||
}
|
||||
final total = pending + data.length;
|
||||
final newBuffer = Uint8List(total);
|
||||
newBuffer.setRange(0, pending, _buffer, _start);
|
||||
newBuffer.setRange(pending, total, data);
|
||||
_buffer = newBuffer;
|
||||
_start = 0;
|
||||
_end = total;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_buffer = Uint8List(0);
|
||||
_start = 0;
|
||||
_end = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Прогресс активных загрузок вложений, ключ — имя в кэше.
|
||||
///
|
||||
/// Значение: `null` — не загружается; `0..1` — доля загруженного.
|
||||
class MediaDownloadProgress {
|
||||
static final Map<String, ValueNotifier<double?>> _notifiers = {};
|
||||
|
||||
static ValueNotifier<double?> notifier(String key) =>
|
||||
_notifiers.putIfAbsent(key, () => ValueNotifier<double?>(null));
|
||||
|
||||
static void set(String key, double? value) {
|
||||
notifier(key).value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
|
||||
import 'media_cache.dart';
|
||||
|
||||
class FileDownloadResult {
|
||||
final bool ok;
|
||||
final String? path;
|
||||
final String? error;
|
||||
|
||||
const FileDownloadResult({required this.ok, this.path, this.error});
|
||||
}
|
||||
|
||||
/// Открывает файл из кэша, скачивая его при отсутствии.
|
||||
///
|
||||
/// [cacheName] — стабильное имя в кэше (например, `<fileId>_имя.ext`).
|
||||
/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше,
|
||||
/// чтобы не дёргать сервер за временной ссылкой повторно.
|
||||
Future<FileDownloadResult> openCachedFile(
|
||||
String cacheName,
|
||||
Future<String?> Function() resolveUrl, {
|
||||
void Function(double progress)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
var file = await MediaCache.existing(cacheName);
|
||||
|
||||
if (file == null) {
|
||||
final url = await resolveUrl();
|
||||
if (url == null || url.isEmpty) {
|
||||
return const FileDownloadResult(ok: false, error: 'нет ссылки');
|
||||
}
|
||||
file = await MediaCache.getOrDownload(
|
||||
cacheName,
|
||||
url,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
if (file == null) {
|
||||
return const FileDownloadResult(ok: false, error: 'ошибка загрузки');
|
||||
}
|
||||
}
|
||||
|
||||
final opened = await OpenFilex.open(file.path);
|
||||
return FileDownloadResult(
|
||||
ok: opened.type == ResultType.done,
|
||||
path: file.path,
|
||||
error: opened.type == ResultType.done ? null : opened.message,
|
||||
);
|
||||
} catch (e) {
|
||||
return FileDownloadResult(ok: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
const int _avatarMaxDimension = 1024;
|
||||
const int _avatarTargetBytes = 900 * 1024;
|
||||
|
||||
Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input);
|
||||
|
||||
Uint8List? _encodeAvatar(Uint8List input) {
|
||||
final decoded = img.decodeImage(input);
|
||||
if (decoded == null) return null;
|
||||
final oriented = img.bakeOrientation(decoded);
|
||||
final image = oriented.width > _avatarMaxDimension || oriented.height > _avatarMaxDimension
|
||||
? img.copyResize(
|
||||
oriented,
|
||||
width: oriented.width >= oriented.height ? _avatarMaxDimension : null,
|
||||
height: oriented.height > oriented.width ? _avatarMaxDimension : null,
|
||||
interpolation: img.Interpolation.average,
|
||||
)
|
||||
: oriented;
|
||||
var quality = 88;
|
||||
var out = img.encodeJpg(image, quality: quality);
|
||||
while (out.lengthInBytes > _avatarTargetBytes && quality > 35) {
|
||||
quality -= 12;
|
||||
out = img.encodeJpg(image, quality: quality);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../config/app_media_cache.dart';
|
||||
|
||||
/// Постоянный дисковый кэш скачанных медиа (файлы, видео).
|
||||
///
|
||||
/// Хранит файлы в `<appSupport>/media_cache/` под детерминированным именем
|
||||
/// (обычно по id вложения), чтобы повторные открытия не качали заново.
|
||||
class MediaCache {
|
||||
/// Максимальный размер кэша (настраивается в дев-меню); при превышении
|
||||
/// вытесняются старые файлы (LRU).
|
||||
static int get maxBytes => AppMediaCacheLimit.current.value;
|
||||
|
||||
static Directory? _dir;
|
||||
static int? _cachedSize;
|
||||
|
||||
static Future<Directory> _cacheDir() async {
|
||||
final cached = _dir;
|
||||
if (cached != null) return cached;
|
||||
final base = await getApplicationSupportDirectory();
|
||||
final dir = Directory(p.join(base.path, 'media_cache'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
_dir = dir;
|
||||
return dir;
|
||||
}
|
||||
|
||||
/// Путь к кэш-файлу с именем [name] (файл может ещё не существовать).
|
||||
static Future<File> fileFor(String name) async {
|
||||
final dir = await _cacheDir();
|
||||
return File(p.join(dir.path, _sanitize(name)));
|
||||
}
|
||||
|
||||
/// Существует ли непустой кэш-файл [name].
|
||||
///
|
||||
/// При попадании обновляет mtime файла — это делает вытеснение LRU
|
||||
/// (часто используемые файлы переживают очистку).
|
||||
static Future<File?> existing(String name) async {
|
||||
final file = await fileFor(name);
|
||||
if (await file.exists() && await file.length() > 0) {
|
||||
try {
|
||||
await file.setLastModified(DateTime.now());
|
||||
} catch (_) {}
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Возвращает кэш-файл [name], скачивая [url] при отсутствии.
|
||||
///
|
||||
/// Загрузка идёт во временный `.part` и переименовывается атомарно —
|
||||
/// прерванная закачка не считается валидным кэшем.
|
||||
static Future<File?> getOrDownload(
|
||||
String name,
|
||||
String url, {
|
||||
void Function(double progress)? onProgress,
|
||||
}) async {
|
||||
final existingFile = await existing(name);
|
||||
if (existingFile != null) return existingFile;
|
||||
|
||||
final file = await fileFor(name);
|
||||
final part = File('${file.path}.part');
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final request = await client.getUrl(Uri.parse(url));
|
||||
final response = await request.close();
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final total = response.contentLength;
|
||||
var received = 0;
|
||||
final sink = part.openWrite();
|
||||
await for (final chunk in response) {
|
||||
received += chunk.length;
|
||||
sink.add(chunk);
|
||||
if (onProgress != null && total > 0) {
|
||||
onProgress(received / total);
|
||||
}
|
||||
}
|
||||
await sink.close();
|
||||
await part.rename(file.path);
|
||||
final known = _cachedSize;
|
||||
if (known != null) {
|
||||
try {
|
||||
_cachedSize = known + await file.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _enforceLimit();
|
||||
return file;
|
||||
} catch (_) {
|
||||
if (await part.exists()) {
|
||||
try {
|
||||
await part.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Суммарный размер кэша в байтах.
|
||||
///
|
||||
/// Результат держится в памяти и поддерживается инкрементально при
|
||||
/// загрузке/очистке/вытеснении — повторные вызовы не пересканируют каталог.
|
||||
static Future<int> currentSize() async {
|
||||
final cached = _cachedSize;
|
||||
if (cached != null) return cached;
|
||||
final total = await _scanSize();
|
||||
_cachedSize = total;
|
||||
return total;
|
||||
}
|
||||
|
||||
static Future<int> _scanSize() async {
|
||||
final dir = await _cacheDir();
|
||||
var total = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
total += await entity.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Полностью очищает кэш. Возвращает число удалённых байт.
|
||||
static Future<int> clear() async {
|
||||
final dir = await _cacheDir();
|
||||
var freed = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
freed += await entity.length();
|
||||
await entity.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
_cachedSize = 0;
|
||||
return freed;
|
||||
}
|
||||
|
||||
/// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes].
|
||||
///
|
||||
/// Под лимитом — ранний выход без сканирования каталога (частый случай).
|
||||
/// Каталог обходится только когда лимит реально превышен.
|
||||
static Future<void> _enforceLimit() async {
|
||||
final limit = maxBytes;
|
||||
if (limit <= 0) return;
|
||||
|
||||
var total = _cachedSize ?? await _scanSize();
|
||||
if (total <= limit) {
|
||||
_cachedSize = total;
|
||||
return;
|
||||
}
|
||||
|
||||
final dir = await _cacheDir();
|
||||
final files = <File>[];
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File && !entity.path.endsWith('.part')) {
|
||||
files.add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) =>
|
||||
a.statSync().modified.compareTo(b.statSync().modified));
|
||||
|
||||
for (final file in files) {
|
||||
if (total <= limit) break;
|
||||
try {
|
||||
total -= await file.length();
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
_cachedSize = total;
|
||||
}
|
||||
|
||||
static String _sanitize(String name) {
|
||||
final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
||||
return cleaned.isEmpty ? 'file' : cleaned;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user