отправка файлов, анимации с реанимации
This commit is contained in:
@@ -0,0 +1,249 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert' show utf8;
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../api.dart';
|
||||||
|
import '../../core/config/proxy_config.dart';
|
||||||
|
import '../../core/protocol/opcode_map.dart';
|
||||||
|
import '../../core/transport/proxy_connector.dart';
|
||||||
|
import 'messages.dart';
|
||||||
|
|
||||||
|
sealed class UploadEvent {
|
||||||
|
const UploadEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadProgress extends UploadEvent {
|
||||||
|
final int sent;
|
||||||
|
final int total;
|
||||||
|
const UploadProgress({required this.sent, required this.total});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadDone extends UploadEvent {
|
||||||
|
final int fileId;
|
||||||
|
final String? token;
|
||||||
|
final String? url;
|
||||||
|
final String filename;
|
||||||
|
final int size;
|
||||||
|
const UploadDone({
|
||||||
|
required this.fileId,
|
||||||
|
required this.filename,
|
||||||
|
required this.size,
|
||||||
|
this.token,
|
||||||
|
this.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadError extends UploadEvent {
|
||||||
|
final String message;
|
||||||
|
const UploadError(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileUploader {
|
||||||
|
final Api api;
|
||||||
|
final MessagesModule messages;
|
||||||
|
|
||||||
|
FileUploader({required this.api, required this.messages});
|
||||||
|
|
||||||
|
Stream<UploadEvent> upload({
|
||||||
|
required int chatId,
|
||||||
|
required File file,
|
||||||
|
required String filename,
|
||||||
|
required int totalSize,
|
||||||
|
Duration autoForceAfter = const Duration(seconds: 1),
|
||||||
|
Duration overallTimeout = const Duration(minutes: 5),
|
||||||
|
Duration progressThrottle = const Duration(milliseconds: 16),
|
||||||
|
}) {
|
||||||
|
final ctrl = StreamController<UploadEvent>();
|
||||||
|
var cancelled = false;
|
||||||
|
Socket? socket;
|
||||||
|
|
||||||
|
ctrl.onCancel = () {
|
||||||
|
cancelled = true;
|
||||||
|
try {
|
||||||
|
socket?.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> run() async {
|
||||||
|
try {
|
||||||
|
final info = await messages.requestUploadUrl();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (info == null) {
|
||||||
|
ctrl.add(const UploadError('no_upload_url'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await api.sendRequest(Opcode.msgTyping, {
|
||||||
|
'chatId': chatId,
|
||||||
|
'type': 'FILE',
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}());
|
||||||
|
|
||||||
|
final uri = Uri.parse(info.url);
|
||||||
|
socket = await _openSocket(uri);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
_writeHeaders(socket!, uri, filename, totalSize);
|
||||||
|
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
var sent = 0;
|
||||||
|
final body = file.openRead().map((chunk) {
|
||||||
|
sent += chunk.length;
|
||||||
|
if (stopwatch.elapsed >= progressThrottle) {
|
||||||
|
ctrl.add(UploadProgress(sent: sent, total: totalSize));
|
||||||
|
stopwatch.reset();
|
||||||
|
}
|
||||||
|
return chunk;
|
||||||
|
});
|
||||||
|
await socket!.addStream(body);
|
||||||
|
await socket!.flush();
|
||||||
|
if (cancelled) return;
|
||||||
|
ctrl.add(UploadProgress(sent: totalSize, total: totalSize));
|
||||||
|
|
||||||
|
final statusCode = await _readResponse(
|
||||||
|
socket!,
|
||||||
|
autoForceAfter: autoForceAfter,
|
||||||
|
overallTimeout: overallTimeout,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
socket!.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (statusCode != 200 && statusCode != 0) {
|
||||||
|
ctrl.add(UploadError('http_$statusCode'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ok = await messages.sendFileMessage(
|
||||||
|
chatId,
|
||||||
|
info.fileId,
|
||||||
|
token: info.token,
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!ok) {
|
||||||
|
ctrl.add(const UploadError('send_failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl.add(UploadDone(
|
||||||
|
fileId: info.fileId,
|
||||||
|
token: info.token,
|
||||||
|
url: info.url,
|
||||||
|
filename: filename,
|
||||||
|
size: totalSize,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) ctrl.add(UploadError(e.toString()));
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
socket?.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
await ctrl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(run());
|
||||||
|
return ctrl.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Socket> _openSocket(Uri uri) async {
|
||||||
|
final proxySettings = await ProxyConfig.load();
|
||||||
|
final base = proxySettings.isEnabled
|
||||||
|
? await ProxyConnector(proxySettings).connect(uri.host, uri.port)
|
||||||
|
: await Socket.connect(uri.host, uri.port);
|
||||||
|
if (uri.scheme != 'https') return base;
|
||||||
|
return SecureSocket.secure(
|
||||||
|
base,
|
||||||
|
host: uri.host,
|
||||||
|
onBadCertificate: (_) => true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _writeHeaders(Socket socket, Uri uri, String filename, int total) {
|
||||||
|
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
||||||
|
final headers = StringBuffer()
|
||||||
|
..write('POST $path HTTP/1.1\r\n')
|
||||||
|
..write('Host: ${uri.host}\r\n')
|
||||||
|
..write('Content-Type: application/x-binary; charset=x-user-defined\r\n')
|
||||||
|
..write('Content-Disposition: attachment; filename=$filename\r\n')
|
||||||
|
..write('Connection: keep-alive\r\n')
|
||||||
|
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
||||||
|
..write('Content-Range: bytes 0-${total - 1}/$total\r\n')
|
||||||
|
..write('Content-Length: $total\r\n')
|
||||||
|
..write('\r\n');
|
||||||
|
socket.add(utf8.encode(headers.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _readResponse(
|
||||||
|
Socket socket, {
|
||||||
|
required Duration autoForceAfter,
|
||||||
|
required Duration overallTimeout,
|
||||||
|
}) {
|
||||||
|
final responseBytes = <int>[];
|
||||||
|
final completer = Completer<int>();
|
||||||
|
Timer? force;
|
||||||
|
Timer? overall;
|
||||||
|
StreamSubscription<List<int>>? sub;
|
||||||
|
|
||||||
|
void finish(int code) {
|
||||||
|
if (completer.isCompleted) return;
|
||||||
|
force?.cancel();
|
||||||
|
overall?.cancel();
|
||||||
|
sub?.cancel();
|
||||||
|
completer.complete(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fail(Object e) {
|
||||||
|
if (completer.isCompleted) return;
|
||||||
|
force?.cancel();
|
||||||
|
overall?.cancel();
|
||||||
|
sub?.cancel();
|
||||||
|
completer.completeError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
force = Timer(autoForceAfter, () => finish(0));
|
||||||
|
|
||||||
|
sub = socket.listen(
|
||||||
|
responseBytes.addAll,
|
||||||
|
onError: fail,
|
||||||
|
onDone: () {
|
||||||
|
final code = _parseHttpStatus(responseBytes);
|
||||||
|
if (code == null) {
|
||||||
|
fail(const SocketException('Не удалось прочитать заголовок ответа'));
|
||||||
|
} else {
|
||||||
|
finish(code);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
overall = Timer(overallTimeout, () => fail(TimeoutException('Тайм-аут загрузки')));
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parseHttpStatus(List<int> bytes) {
|
||||||
|
final headerEnd = _findHeaderEnd(bytes);
|
||||||
|
if (headerEnd == -1) return null;
|
||||||
|
final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true);
|
||||||
|
final statusLine = headerStr.split('\r\n').first;
|
||||||
|
final parts = statusLine.split(' ');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return int.tryParse(parts[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _findHeaderEnd(List<int> bytes) {
|
||||||
|
for (var i = 0; i < bytes.length - 3; i++) {
|
||||||
|
if (bytes[i] == 0x0D &&
|
||||||
|
bytes[i + 1] == 0x0A &&
|
||||||
|
bytes[i + 2] == 0x0D &&
|
||||||
|
bytes[i + 3] == 0x0A) {
|
||||||
|
return i + 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
import '../../core/protocol/opcode_map.dart';
|
import '../../core/protocol/opcode_map.dart';
|
||||||
import '../../core/storage/app_database.dart';
|
import '../../core/storage/app_database.dart';
|
||||||
@@ -55,27 +56,93 @@ class FileHistoryEntry {
|
|||||||
final int fileId;
|
final int fileId;
|
||||||
final String? url;
|
final String? url;
|
||||||
final String? token;
|
final String? token;
|
||||||
|
final String? filename;
|
||||||
|
final int? size;
|
||||||
final DateTime sentAt;
|
final DateTime sentAt;
|
||||||
|
|
||||||
FileHistoryEntry({
|
FileHistoryEntry({
|
||||||
required this.fileId,
|
required this.fileId,
|
||||||
this.url,
|
this.url,
|
||||||
this.token,
|
this.token,
|
||||||
|
this.filename,
|
||||||
|
this.size,
|
||||||
required this.sentAt,
|
required this.sentAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'fileId': fileId,
|
||||||
|
if (url != null) 'url': url,
|
||||||
|
if (token != null) 'token': token,
|
||||||
|
if (filename != null) 'filename': filename,
|
||||||
|
if (size != null) 'size': size,
|
||||||
|
'sentAt': sentAt.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
|
||||||
|
static FileHistoryEntry? fromJson(Map<String, dynamic> j) {
|
||||||
|
final id = j['fileId'];
|
||||||
|
final ts = j['sentAt'];
|
||||||
|
if (id is! int || ts is! int) return null;
|
||||||
|
return FileHistoryEntry(
|
||||||
|
fileId: id,
|
||||||
|
url: j['url'] as String?,
|
||||||
|
token: j['token'] as String?,
|
||||||
|
filename: j['filename'] as String?,
|
||||||
|
size: j['size'] as int?,
|
||||||
|
sentAt: DateTime.fromMillisecondsSinceEpoch(ts),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileHistoryCache {
|
class FileHistoryCache {
|
||||||
static final List<FileHistoryEntry> _history = [];
|
static const _prefKey = 'file_history_v1';
|
||||||
|
static const _maxEntries = 50;
|
||||||
|
|
||||||
static List<FileHistoryEntry> get history => List.unmodifiable(_history);
|
static final ValueNotifier<List<FileHistoryEntry>> notifier =
|
||||||
|
ValueNotifier(const []);
|
||||||
|
|
||||||
static void add(FileHistoryEntry entry) {
|
static List<FileHistoryEntry> get history => notifier.value;
|
||||||
_history.insert(0, entry);
|
static bool get isEmpty => notifier.value.isEmpty;
|
||||||
if (_history.length > 50) _history.removeLast();
|
|
||||||
|
static SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
static Future<void> load(SharedPreferences prefs) async {
|
||||||
|
_prefs = prefs;
|
||||||
|
final raw = prefs.getString(_prefKey);
|
||||||
|
if (raw == null) return;
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(raw);
|
||||||
|
if (list is! List) return;
|
||||||
|
final entries = <FileHistoryEntry>[];
|
||||||
|
for (final e in list) {
|
||||||
|
if (e is Map) {
|
||||||
|
final entry = FileHistoryEntry.fromJson(Map<String, dynamic>.from(e));
|
||||||
|
if (entry != null) entries.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifier.value = entries;
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool get isEmpty => _history.isEmpty;
|
static void add(FileHistoryEntry entry) {
|
||||||
|
final next = [entry, ...notifier.value.where((e) => e.fileId != entry.fileId)];
|
||||||
|
if (next.length > _maxEntries) next.removeRange(_maxEntries, next.length);
|
||||||
|
notifier.value = next;
|
||||||
|
_persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void remove(int fileId) {
|
||||||
|
final next = notifier.value.where((e) => e.fileId != fileId).toList();
|
||||||
|
if (next.length == notifier.value.length) return;
|
||||||
|
notifier.value = next;
|
||||||
|
_persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _persist() {
|
||||||
|
final prefs = _prefs;
|
||||||
|
if (prefs == null) return;
|
||||||
|
final encoded = jsonEncode(notifier.value.map((e) => e.toJson()).toList());
|
||||||
|
prefs.setString(_prefKey, encoded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileUploadInfo {
|
class FileUploadInfo {
|
||||||
@@ -393,6 +460,8 @@ class MessagesModule {
|
|||||||
int fileId, {
|
int fileId, {
|
||||||
String? token,
|
String? token,
|
||||||
bool notify = true,
|
bool notify = true,
|
||||||
|
int maxAttempts = 5,
|
||||||
|
Duration retryDelay = const Duration(seconds: 1),
|
||||||
}) async {
|
}) async {
|
||||||
final payload = {
|
final payload = {
|
||||||
'chatId': chatId,
|
'chatId': chatId,
|
||||||
@@ -411,8 +480,16 @@ class MessagesModule {
|
|||||||
'notify': notify,
|
'notify': notify,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
return response.isOk;
|
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||||
|
if (response.isOk) return true;
|
||||||
|
final err = response.payload is Map ? response.payload['error'] : null;
|
||||||
|
if (err != 'attachment.not.ready' || attempt == maxAttempts - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await Future.delayed(retryDelay);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ enum SocketState { disconnected, connecting, connected }
|
|||||||
/// Обёртка над TCP + TLS сокетом.
|
/// Обёртка над TCP + TLS сокетом.
|
||||||
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
|
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
|
||||||
class Connection {
|
class Connection {
|
||||||
RawSecureSocket? _socket;
|
SecureSocket? _socket;
|
||||||
StreamSubscription<RawSocketEvent>? _subscription;
|
StreamSubscription<Uint8List>? _subscription;
|
||||||
SocketState _state = SocketState.disconnected;
|
SocketState _state = SocketState.disconnected;
|
||||||
|
|
||||||
final _dataController = StreamController<Uint8List>.broadcast();
|
final _dataController = StreamController<Uint8List>.broadcast();
|
||||||
@@ -63,18 +63,7 @@ class Connection {
|
|||||||
logger.i('Подключено к $host:$port');
|
logger.i('Подключено к $host:$port');
|
||||||
|
|
||||||
_subscription = _socket!.listen(
|
_subscription = _socket!.listen(
|
||||||
(event) {
|
(data) => _dataController.add(data),
|
||||||
if (event == RawSocketEvent.read) {
|
|
||||||
final data = _socket?.read();
|
|
||||||
if (data != null) {
|
|
||||||
_dataController.add(data);
|
|
||||||
}
|
|
||||||
} else if (event == RawSocketEvent.readClosed ||
|
|
||||||
event == RawSocketEvent.closed) {
|
|
||||||
logger.w('Сокет закрыт сервером');
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (Object error) {
|
onError: (Object error) {
|
||||||
logger.e('Ошибка сокета: $error');
|
logger.e('Ошибка сокета: $error');
|
||||||
disconnect();
|
disconnect();
|
||||||
@@ -91,41 +80,41 @@ class Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RawSecureSocket> _openSecureSocket(
|
Future<SecureSocket> _openSecureSocket(
|
||||||
String host,
|
String host,
|
||||||
int port,
|
int port,
|
||||||
ProxySettings proxySettings, {
|
ProxySettings proxySettings, {
|
||||||
Duration? timeout,
|
Duration? timeout,
|
||||||
}) async {
|
}) async {
|
||||||
RawSocket rawSocket;
|
Socket socket;
|
||||||
if (proxySettings.isEnabled) {
|
if (proxySettings.isEnabled) {
|
||||||
final connector = ProxyConnector(proxySettings);
|
final connector = ProxyConnector(proxySettings);
|
||||||
rawSocket = await connector.connect(host, port);
|
socket = await connector.connect(host, port);
|
||||||
logger.i('Подключено через прокси ${proxySettings.type.name}');
|
logger.i('Подключено через прокси ${proxySettings.type.name}');
|
||||||
} else {
|
} else {
|
||||||
rawSocket = timeout == null
|
socket = timeout == null
|
||||||
? await RawSocket.connect(host, port)
|
? await Socket.connect(host, port)
|
||||||
: await RawSocket.connect(host, port, timeout: timeout);
|
: await Socket.connect(host, port, timeout: timeout);
|
||||||
}
|
}
|
||||||
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
final allowInsecure = await TlsConfig.isInsecureAllowed();
|
||||||
if (allowInsecure) {
|
if (allowInsecure) {
|
||||||
logger.w(
|
logger.w(
|
||||||
'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM',
|
'TLS: проверка сертификата отключена (дебаг) — соединение уязвимо к MitM',
|
||||||
);
|
);
|
||||||
return RawSecureSocket.secure(
|
return SecureSocket.secure(
|
||||||
rawSocket,
|
socket,
|
||||||
host: host,
|
host: host,
|
||||||
onBadCertificate: (_) => true,
|
onBadCertificate: (_) => true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return RawSecureSocket.secure(rawSocket, host: host);
|
return SecureSocket.secure(socket, host: host);
|
||||||
}
|
}
|
||||||
|
|
||||||
void write(Uint8List data) {
|
void write(Uint8List data) {
|
||||||
if (_socket == null || !isConnected) {
|
if (_socket == null || !isConnected) {
|
||||||
throw StateError('Нельзя писать: сокет не подключён');
|
throw StateError('Нельзя писать: сокет не подключён');
|
||||||
}
|
}
|
||||||
_socket!.write(data);
|
_socket!.add(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
@@ -136,7 +125,7 @@ class Connection {
|
|||||||
|
|
||||||
if (socket != null) {
|
if (socket != null) {
|
||||||
try {
|
try {
|
||||||
socket.close();
|
await socket.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.w('Ошибка при закрытии сокета: $e');
|
logger.w('Ошибка при закрытии сокета: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,28 +6,25 @@ import 'dart:typed_data';
|
|||||||
import '../config/proxy_config.dart';
|
import '../config/proxy_config.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
|
|
||||||
/// Устанавливает TCP-соединение через SOCKS5 или HTTP CONNECT прокси.
|
|
||||||
/// Возвращает [RawSocket], который никогда не слушался —
|
|
||||||
/// его можно передать в [RawSecureSocket.secure].
|
|
||||||
class ProxyConnector {
|
class ProxyConnector {
|
||||||
final ProxySettings settings;
|
final ProxySettings settings;
|
||||||
|
|
||||||
ProxyConnector(this.settings);
|
ProxyConnector(this.settings);
|
||||||
|
|
||||||
Future<RawSocket> connect(String targetHost, int targetPort) async {
|
Future<Socket> connect(String targetHost, int targetPort) async {
|
||||||
switch (settings.type) {
|
switch (settings.type) {
|
||||||
case ProxyType.socks5:
|
case ProxyType.socks5:
|
||||||
return _connectSocks5(targetHost, targetPort);
|
return _connectSocks5(targetHost, targetPort);
|
||||||
case ProxyType.httpConnect:
|
case ProxyType.httpConnect:
|
||||||
return _connectHttpConnect(targetHost, targetPort);
|
return _connectHttpConnect(targetHost, targetPort);
|
||||||
case ProxyType.none:
|
case ProxyType.none:
|
||||||
return RawSocket.connect(targetHost, targetPort);
|
return Socket.connect(targetHost, targetPort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SOCKS5 (RFC 1928) ──────────────────────────────────────────────────
|
// ── SOCKS5 (RFC 1928) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<RawSocket> _connectSocks5(String targetHost, int targetPort) async {
|
Future<Socket> _connectSocks5(String targetHost, int targetPort) async {
|
||||||
final proxySocket = await RawSocket.connect(settings.host, settings.port);
|
final proxySocket = await RawSocket.connect(settings.host, settings.port);
|
||||||
logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}');
|
logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}');
|
||||||
|
|
||||||
@@ -128,7 +125,7 @@ class ProxyConnector {
|
|||||||
|
|
||||||
// ── HTTP CONNECT ────────────────────────────────────────────────────────
|
// ── HTTP CONNECT ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<RawSocket> _connectHttpConnect(
|
Future<Socket> _connectHttpConnect(
|
||||||
String targetHost,
|
String targetHost,
|
||||||
int targetPort,
|
int targetPort,
|
||||||
) async {
|
) async {
|
||||||
@@ -194,19 +191,13 @@ class ProxyConnector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Мост: создаём свежий сокет и проксируем через loopback ─────────────
|
Future<Socket> _bridgeToFreshSocket(
|
||||||
|
|
||||||
/// После handshake proxy-сокет уже прослушан (single-subscription).
|
|
||||||
/// Создаём пару локальных сокетов через loopback и проксируем данные
|
|
||||||
/// между прокси-сокетом и одним концом. Второй конец возвращаем —
|
|
||||||
/// он «свежий» и его можно передать в [RawSecureSocket.secure].
|
|
||||||
Future<RawSocket> _bridgeToFreshSocket(
|
|
||||||
RawSocket proxySocket,
|
RawSocket proxySocket,
|
||||||
_RawSocketIO io,
|
_RawSocketIO io,
|
||||||
) async {
|
) async {
|
||||||
RawServerSocket? server;
|
ServerSocket? server;
|
||||||
try {
|
try {
|
||||||
server = await RawServerSocket.bind(
|
server = await ServerSocket.bind(
|
||||||
InternetAddress.loopbackIPv4,
|
InternetAddress.loopbackIPv4,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -215,31 +206,36 @@ class ProxyConnector {
|
|||||||
proxySocket.close();
|
proxySocket.close();
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
final clientSide = await RawSocket.connect(
|
final clientFuture = Socket.connect(
|
||||||
InternetAddress.loopbackIPv4,
|
InternetAddress.loopbackIPv4,
|
||||||
server.port,
|
server.port,
|
||||||
);
|
);
|
||||||
final serverSide = await server.first;
|
final serverSide = await server.first;
|
||||||
|
final clientSide = await clientFuture;
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|
||||||
// proxy → local (через уже имеющуюся подписку _RawSocketIO)
|
|
||||||
io.onData = (data) {
|
io.onData = (data) {
|
||||||
serverSide.write(data);
|
serverSide.add(data);
|
||||||
};
|
};
|
||||||
io.onClosed = () {
|
io.onClosed = () {
|
||||||
serverSide.shutdown(SocketDirection.send);
|
serverSide.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
// local → proxy
|
serverSide.listen(
|
||||||
serverSide.listen((event) {
|
(data) {
|
||||||
if (event == RawSocketEvent.read) {
|
unawaited(io.write(data).catchError((Object _) {
|
||||||
final data = serverSide.read();
|
try {
|
||||||
if (data != null) proxySocket.write(data);
|
serverSide.destroy();
|
||||||
} else if (event == RawSocketEvent.readClosed ||
|
} catch (_) {}
|
||||||
event == RawSocketEvent.closed) {
|
}));
|
||||||
|
},
|
||||||
|
onError: (Object _) {
|
||||||
proxySocket.shutdown(SocketDirection.send);
|
proxySocket.shutdown(SocketDirection.send);
|
||||||
}
|
},
|
||||||
});
|
onDone: () {
|
||||||
|
proxySocket.shutdown(SocketDirection.send);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Сливаем данные, буферизованные во время handshake
|
// Сливаем данные, буферизованные во время handshake
|
||||||
io.flushBuffered();
|
io.flushBuffered();
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io' show File;
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:komet/backend/modules/chats.dart';
|
import 'package:komet/backend/modules/chats.dart';
|
||||||
|
import 'package:komet/backend/modules/file_uploader.dart';
|
||||||
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
||||||
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../../backend/api.dart';
|
import '../../../backend/api.dart';
|
||||||
@@ -15,6 +19,22 @@ import '../../../models/attachment.dart';
|
|||||||
import '../../widgets/message_bubble.dart';
|
import '../../widgets/message_bubble.dart';
|
||||||
import '../../widgets/attachment_panel.dart';
|
import '../../widgets/attachment_panel.dart';
|
||||||
|
|
||||||
|
class _UploadStatus {
|
||||||
|
final bool active;
|
||||||
|
final int sent;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
const _UploadStatus({
|
||||||
|
this.active = false,
|
||||||
|
this.sent = 0,
|
||||||
|
this.total = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get awaitingResponse => active && total > 0 && sent >= total;
|
||||||
|
double? get progressValue =>
|
||||||
|
(!active || total == 0 || awaitingResponse) ? null : sent / total;
|
||||||
|
}
|
||||||
|
|
||||||
class _DateSeparatorItem {
|
class _DateSeparatorItem {
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
final GlobalKey key;
|
final GlobalKey key;
|
||||||
@@ -53,6 +73,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
final ValueNotifier<bool> _hasText = ValueNotifier(false);
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
final ValueNotifier<bool> _showAttachmentPanel = ValueNotifier(false);
|
||||||
|
final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus());
|
||||||
|
StreamSubscription<UploadEvent>? _uploadSub;
|
||||||
|
int _tempIdCounter = 0;
|
||||||
|
late final AnimationController _attachAnim;
|
||||||
|
|
||||||
|
String _nextTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||||||
late AnimationController _shimmerController;
|
late AnimationController _shimmerController;
|
||||||
List<CachedMessage> _messages = [];
|
List<CachedMessage> _messages = [];
|
||||||
int _myId = 0;
|
int _myId = 0;
|
||||||
@@ -75,6 +101,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 1500),
|
duration: const Duration(milliseconds: 1500),
|
||||||
)..repeat();
|
)..repeat();
|
||||||
|
_attachAnim = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 320),
|
||||||
|
reverseDuration: const Duration(milliseconds: 240),
|
||||||
|
);
|
||||||
|
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||||||
_floatingDateAnimController = AnimationController(
|
_floatingDateAnimController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 220),
|
duration: const Duration(milliseconds: 220),
|
||||||
@@ -149,7 +181,11 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_floatingDateAnimController.dispose();
|
_floatingDateAnimController.dispose();
|
||||||
_floatingDate.dispose();
|
_floatingDate.dispose();
|
||||||
_hasText.dispose();
|
_hasText.dispose();
|
||||||
|
_showAttachmentPanel.removeListener(_onAttachPanelToggle);
|
||||||
_showAttachmentPanel.dispose();
|
_showAttachmentPanel.dispose();
|
||||||
|
_uploadSub?.cancel();
|
||||||
|
_uploadStatus.dispose();
|
||||||
|
_attachAnim.dispose();
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_shimmerController.dispose();
|
_shimmerController.dispose();
|
||||||
@@ -163,6 +199,14 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onAttachPanelToggle() {
|
||||||
|
if (_showAttachmentPanel.value) {
|
||||||
|
_attachAnim.forward();
|
||||||
|
} else {
|
||||||
|
_attachAnim.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String? _effectiveStatus(CachedMessage msg) {
|
String? _effectiveStatus(CachedMessage msg) {
|
||||||
if (msg.senderId != _myId) return null;
|
if (msg.senderId != _myId) return null;
|
||||||
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
|
if (msg.status == 'sending' || msg.status == 'error') return msg.status;
|
||||||
@@ -182,7 +226,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final text = _messageController.text.trim();
|
final text = _messageController.text.trim();
|
||||||
if (text.isEmpty || _myId == 0) return;
|
if (text.isEmpty || _myId == 0) return;
|
||||||
|
|
||||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
final tempId = _nextTempId();
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -555,33 +599,37 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
body: Stack(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Expanded(
|
||||||
children: [
|
child: _isLoading && _messages.isEmpty
|
||||||
Expanded(
|
? _buildShimmerLoading()
|
||||||
child: _isLoading && _messages.isEmpty
|
: _buildMessagesList(),
|
||||||
? _buildShimmerLoading()
|
|
||||||
: _buildMessagesList(),
|
|
||||||
),
|
|
||||||
_buildInputArea(context),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
ValueListenableBuilder<bool>(
|
AnimatedBuilder(
|
||||||
valueListenable: _showAttachmentPanel,
|
animation: _attachAnim,
|
||||||
builder: (context, open, _) {
|
builder: (context, _) {
|
||||||
if (!open) return const SizedBox.shrink();
|
if (_attachAnim.value == 0) return const SizedBox.shrink();
|
||||||
return Positioned(
|
return Padding(
|
||||||
left: 0,
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
right: 0,
|
child: ClipRect(
|
||||||
bottom: 0,
|
child: Align(
|
||||||
child: AttachmentPanel(
|
alignment: Alignment.bottomCenter,
|
||||||
chatId: widget.chatId,
|
heightFactor: Curves.easeOutCubic.transform(_attachAnim.value),
|
||||||
onClose: () => _showAttachmentPanel.value = false,
|
child: Opacity(
|
||||||
|
opacity: Curves.easeOut.transform(_attachAnim.value),
|
||||||
|
child: AttachmentPanel(
|
||||||
|
onClose: () => _showAttachmentPanel.value = false,
|
||||||
|
onPickFile: _pickAndUploadFile,
|
||||||
|
onSendById: _sendFileById,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_buildInputArea(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -843,74 +891,147 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
width: 0.5,
|
width: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
clipBehavior: Clip.hardEdge,
|
||||||
child: Row(
|
child: Stack(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400),
|
AnimatedBuilder(
|
||||||
const SizedBox(width: 12),
|
animation: _attachAnim,
|
||||||
Expanded(
|
builder: (context, child) {
|
||||||
child: Focus(
|
final t = _attachAnim.value;
|
||||||
onKeyEvent: (node, event) {
|
return IgnorePointer(
|
||||||
if (event is KeyDownEvent &&
|
ignoring: t > 0.5,
|
||||||
event.logicalKey == LogicalKeyboardKey.enter &&
|
child: Opacity(opacity: (1 - t).clamp(0.0, 1.0), child: child),
|
||||||
!HardwareKeyboard.instance.isShiftPressed) {
|
);
|
||||||
if (_hasText.value) _sendMessage();
|
},
|
||||||
return KeyEventResult.handled;
|
child: Padding(
|
||||||
}
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
return KeyEventResult.ignored;
|
child: Row(
|
||||||
},
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
child: TextField(
|
children: [
|
||||||
controller: _messageController,
|
Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400),
|
||||||
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
const SizedBox(width: 12),
|
||||||
maxLines: null,
|
Expanded(
|
||||||
keyboardType: TextInputType.multiline,
|
child: Focus(
|
||||||
textAlignVertical: TextAlignVertical.center,
|
onKeyEvent: (node, event) {
|
||||||
decoration: InputDecoration(
|
if (event is KeyDownEvent &&
|
||||||
hintText: 'Message',
|
event.logicalKey == LogicalKeyboardKey.enter &&
|
||||||
hintStyle: TextStyle(
|
!HardwareKeyboard.instance.isShiftPressed) {
|
||||||
color: cs.onSurfaceVariant,
|
if (_hasText.value) _sendMessage();
|
||||||
fontSize: 16,
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
},
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
style: TextStyle(color: cs.onSurface, fontSize: 16),
|
||||||
|
maxLines: null,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
textAlignVertical: TextAlignVertical.center,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Message',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
border: InputBorder.none,
|
_AttachButton(
|
||||||
isDense: true,
|
hasText: _hasText,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
panelOpen: _showAttachmentPanel,
|
||||||
vertical: 14,
|
uploadStatus: _uploadStatus,
|
||||||
|
mutedIcon: mutedIcon,
|
||||||
|
cs: cs,
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_AttachButton(
|
Positioned(
|
||||||
hasText: _hasText,
|
left: 0,
|
||||||
panelOpen: _showAttachmentPanel,
|
right: 0,
|
||||||
mutedIcon: mutedIcon,
|
bottom: 0,
|
||||||
cs: cs,
|
child: SizedBox(
|
||||||
|
height: 54,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _attachAnim,
|
||||||
|
builder: (context, child) {
|
||||||
|
final t = _attachAnim.value;
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: t < 0.5,
|
||||||
|
child: Opacity(opacity: t.clamp(0.0, 1.0), child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _HistoryStrip(
|
||||||
|
anim: _attachAnim,
|
||||||
|
cs: cs,
|
||||||
|
onTapEntry: _sendHistoryFile,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
AnimatedBuilder(
|
||||||
ValueListenableBuilder<bool>(
|
animation: _attachAnim,
|
||||||
valueListenable: _hasText,
|
builder: (context, child) {
|
||||||
builder: (context, hasText, _) => Container(
|
final t = _attachAnim.value;
|
||||||
width: 54,
|
return ClipRect(
|
||||||
height: 54,
|
child: Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.centerLeft,
|
||||||
decoration: BoxDecoration(
|
widthFactor: (1 - t).clamp(0.0, 1.0),
|
||||||
color: hasText ? cs.primary : cs.surfaceContainerHighest,
|
child: child,
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: hasText ? _sendMessage : null,
|
|
||||||
child: Icon(
|
|
||||||
hasText ? Symbols.send : Symbols.mic,
|
|
||||||
color: hasText ? cs.onPrimary : cs.onSurface,
|
|
||||||
size: 24,
|
|
||||||
weight: 400,
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _attachAnim,
|
||||||
|
builder: (context, child) {
|
||||||
|
final t = _attachAnim.value;
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(t * 80, 0),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: (1 - t * 1.5).clamp(0.0, 1.0),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: _hasText,
|
||||||
|
builder: (context, hasText, _) => Container(
|
||||||
|
width: 54,
|
||||||
|
height: 54,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: hasText ? cs.primary : cs.surfaceContainerHighest,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: hasText ? _sendMessage : null,
|
||||||
|
child: Icon(
|
||||||
|
hasText ? Symbols.send : Symbols.mic,
|
||||||
|
color: hasText ? cs.onPrimary : cs.onSurface,
|
||||||
|
size: 24,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -918,26 +1039,204 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _addOptimisticFileMessage(FileAttachment attachment) {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final tempId = _nextTempId();
|
||||||
|
final msg = CachedMessage(
|
||||||
|
id: tempId,
|
||||||
|
accountId: _myId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
senderId: _myId,
|
||||||
|
time: now,
|
||||||
|
status: 'sending',
|
||||||
|
attachments: [attachment],
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_lastSentId = tempId;
|
||||||
|
_messages.add(msg);
|
||||||
|
});
|
||||||
|
Haptics.send();
|
||||||
|
_scrollToBottom();
|
||||||
|
return tempId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateFileMessageStatus(
|
||||||
|
String tempId,
|
||||||
|
String status, {
|
||||||
|
FileAttachment? attachment,
|
||||||
|
}) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final idx = _messages.indexWhere((m) => m.id == tempId);
|
||||||
|
if (idx == -1) return;
|
||||||
|
final old = _messages[idx];
|
||||||
|
setState(() {
|
||||||
|
_messages[idx] = CachedMessage(
|
||||||
|
id: tempId,
|
||||||
|
accountId: old.accountId,
|
||||||
|
chatId: old.chatId,
|
||||||
|
senderId: old.senderId,
|
||||||
|
text: old.text,
|
||||||
|
time: old.time,
|
||||||
|
status: status,
|
||||||
|
payload: old.payload,
|
||||||
|
attachments: attachment != null ? [attachment] : old.attachments,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendHistoryFile(FileHistoryEntry entry) async {
|
||||||
|
final tempId = _addOptimisticFileMessage(FileAttachment(
|
||||||
|
fileId: entry.fileId,
|
||||||
|
fileToken: entry.token,
|
||||||
|
name: entry.filename,
|
||||||
|
size: entry.size,
|
||||||
|
));
|
||||||
|
_showAttachmentPanel.value = false;
|
||||||
|
try {
|
||||||
|
final ok = await messagesModule.sendFileMessage(
|
||||||
|
widget.chatId,
|
||||||
|
entry.fileId,
|
||||||
|
token: entry.token,
|
||||||
|
);
|
||||||
|
_updateFileMessageStatus(tempId, ok ? 'sent' : 'error');
|
||||||
|
} catch (_) {
|
||||||
|
_updateFileMessageStatus(tempId, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _sendFileById(int fileId) async {
|
||||||
|
final tempId = _addOptimisticFileMessage(FileAttachment(fileId: fileId));
|
||||||
|
try {
|
||||||
|
final ok = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
||||||
|
if (!mounted) return ok;
|
||||||
|
if (ok) {
|
||||||
|
FileHistoryCache.add(FileHistoryEntry(
|
||||||
|
fileId: fileId,
|
||||||
|
sentAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
_updateFileMessageStatus(tempId, 'sent');
|
||||||
|
_showAttachmentPanel.value = false;
|
||||||
|
} else {
|
||||||
|
_updateFileMessageStatus(tempId, 'error');
|
||||||
|
showCustomNotification(context, 'Ошибка отправки');
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
} catch (e) {
|
||||||
|
_updateFileMessageStatus(tempId, 'error');
|
||||||
|
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickAndUploadFile() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles();
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.path == null) return;
|
||||||
|
|
||||||
|
_showAttachmentPanel.value = false;
|
||||||
|
_uploadStatus.value = _UploadStatus(active: true, total: file.size);
|
||||||
|
|
||||||
|
final tempId = _addOptimisticFileMessage(FileAttachment(
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
));
|
||||||
|
|
||||||
|
_uploadSub?.cancel();
|
||||||
|
_uploadSub = fileUploader
|
||||||
|
.upload(
|
||||||
|
chatId: widget.chatId,
|
||||||
|
file: File(file.path!),
|
||||||
|
filename: file.name,
|
||||||
|
totalSize: file.size,
|
||||||
|
)
|
||||||
|
.listen(
|
||||||
|
(event) {
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (event) {
|
||||||
|
case UploadProgress(:final sent, :final total):
|
||||||
|
_uploadStatus.value = _UploadStatus(active: true, sent: sent, total: total);
|
||||||
|
case UploadDone(:final fileId, :final token, :final url):
|
||||||
|
FileHistoryCache.add(FileHistoryEntry(
|
||||||
|
fileId: fileId,
|
||||||
|
url: url,
|
||||||
|
token: token,
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size,
|
||||||
|
sentAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
_updateFileMessageStatus(
|
||||||
|
tempId,
|
||||||
|
'sent',
|
||||||
|
attachment: FileAttachment(
|
||||||
|
fileId: fileId,
|
||||||
|
fileToken: token,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case UploadError(:final message):
|
||||||
|
showCustomNotification(context, 'Ошибка: $message');
|
||||||
|
_updateFileMessageStatus(tempId, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
if (!mounted) return;
|
||||||
|
final inFlight = _messages.firstWhere(
|
||||||
|
(m) => m.id == tempId,
|
||||||
|
orElse: () => CachedMessage(
|
||||||
|
id: '', accountId: 0, chatId: 0, senderId: 0, time: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (inFlight.id == tempId && inFlight.status == 'sending') {
|
||||||
|
_updateFileMessageStatus(tempId, 'error');
|
||||||
|
}
|
||||||
|
_uploadStatus.value = const _UploadStatus();
|
||||||
|
_uploadSub = null;
|
||||||
|
},
|
||||||
|
onError: (Object e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showCustomNotification(context, 'Ошибка: $e');
|
||||||
|
_updateFileMessageStatus(tempId, 'error');
|
||||||
|
_uploadStatus.value = const _UploadStatus();
|
||||||
|
_uploadSub = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AttachButton extends StatelessWidget {
|
class _AttachButton extends StatelessWidget {
|
||||||
final ValueNotifier<bool> hasText;
|
final ValueNotifier<bool> hasText;
|
||||||
final ValueNotifier<bool> panelOpen;
|
final ValueNotifier<bool> panelOpen;
|
||||||
|
final ValueNotifier<_UploadStatus> uploadStatus;
|
||||||
final Color mutedIcon;
|
final Color mutedIcon;
|
||||||
final ColorScheme cs;
|
final ColorScheme cs;
|
||||||
|
|
||||||
const _AttachButton({
|
const _AttachButton({
|
||||||
required this.hasText,
|
required this.hasText,
|
||||||
required this.panelOpen,
|
required this.panelOpen,
|
||||||
|
required this.uploadStatus,
|
||||||
required this.mutedIcon,
|
required this.mutedIcon,
|
||||||
required this.cs,
|
required this.cs,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<bool>(
|
return ListenableBuilder(
|
||||||
valueListenable: hasText,
|
listenable: Listenable.merge([hasText, panelOpen, uploadStatus]),
|
||||||
builder: (context, isText, _) {
|
builder: (context, _) {
|
||||||
|
final isText = hasText.value;
|
||||||
|
final open = panelOpen.value;
|
||||||
|
final status = uploadStatus.value;
|
||||||
|
final iconColor = status.awaitingResponse
|
||||||
|
? cs.primary
|
||||||
|
: (status.active || open
|
||||||
|
? cs.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: mutedIcon);
|
||||||
|
final onTap = (isText || status.active || open)
|
||||||
|
? null
|
||||||
|
: () => panelOpen.value = true;
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
width: isText ? 0 : 36,
|
width: isText ? 0 : 36,
|
||||||
@@ -946,34 +1245,31 @@ class _AttachButton extends StatelessWidget {
|
|||||||
opacity: isText ? 0 : 1,
|
opacity: isText ? 0 : 1,
|
||||||
child: isText
|
child: isText
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: ValueListenableBuilder<bool>(
|
: GestureDetector(
|
||||||
valueListenable: panelOpen,
|
behavior: HitTestBehavior.opaque,
|
||||||
builder: (context, open, _) => GestureDetector(
|
onTap: onTap,
|
||||||
onTap: open ? null : () => panelOpen.value = true,
|
child: Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.only(left: 12),
|
||||||
padding: const EdgeInsets.only(left: 12),
|
child: Stack(
|
||||||
child: Stack(
|
alignment: Alignment.center,
|
||||||
alignment: Alignment.center,
|
children: [
|
||||||
children: [
|
if (status.active)
|
||||||
if (open)
|
SizedBox(
|
||||||
SizedBox(
|
width: 30,
|
||||||
width: 24,
|
height: 30,
|
||||||
height: 24,
|
child: CircularProgressIndicator(
|
||||||
child: CircularProgressIndicator(
|
strokeWidth: 2,
|
||||||
strokeWidth: 2,
|
value: status.progressValue,
|
||||||
color: cs.primary,
|
color: cs.primary,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Icon(
|
|
||||||
Symbols.attachment,
|
|
||||||
color: open
|
|
||||||
? cs.onSurfaceVariant.withValues(alpha: 0.3)
|
|
||||||
: mutedIcon,
|
|
||||||
size: 24,
|
|
||||||
weight: 400,
|
|
||||||
),
|
),
|
||||||
],
|
Icon(
|
||||||
),
|
Symbols.attachment,
|
||||||
|
color: iconColor,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -984,6 +1280,214 @@ class _AttachButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HistoryStrip extends StatelessWidget {
|
||||||
|
final Animation<double> anim;
|
||||||
|
final ColorScheme cs;
|
||||||
|
final Future<void> Function(FileHistoryEntry entry) onTapEntry;
|
||||||
|
|
||||||
|
const _HistoryStrip({
|
||||||
|
required this.anim,
|
||||||
|
required this.cs,
|
||||||
|
required this.onTapEntry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<List<FileHistoryEntry>>(
|
||||||
|
valueListenable: FileHistoryCache.notifier,
|
||||||
|
builder: (context, history, _) {
|
||||||
|
if (history.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: anim,
|
||||||
|
builder: (context, _) {
|
||||||
|
final v = anim.value.clamp(0.0, 1.0);
|
||||||
|
return Opacity(
|
||||||
|
opacity: v,
|
||||||
|
child: Text(
|
||||||
|
'история пуста...',
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
itemCount: history.length,
|
||||||
|
itemBuilder: (ctx, idx) {
|
||||||
|
final e = history[idx];
|
||||||
|
final startInterval = (idx * 0.05).clamp(0.0, 0.45);
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: anim,
|
||||||
|
builder: (context, child) {
|
||||||
|
final raw = ((anim.value - startInterval) / 0.45).clamp(0.0, 1.0);
|
||||||
|
final v = Curves.easeOutCubic.transform(raw);
|
||||||
|
return Opacity(
|
||||||
|
opacity: v,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(-14 * (1 - v), 0),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 54,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Stack(children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => onTapEntry(e),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_iconForFilename(e.filename),
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
child: Text(
|
||||||
|
_labelForEntry(e),
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -2,
|
||||||
|
right: -2,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => FileHistoryCache.remove(e.fileId),
|
||||||
|
child: Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: cs.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Symbols.close,
|
||||||
|
size: 12,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _labelForEntry(FileHistoryEntry e) {
|
||||||
|
final n = e.filename;
|
||||||
|
if (n == null || n.isEmpty) return e.fileId.toString();
|
||||||
|
final lastDot = n.lastIndexOf('.');
|
||||||
|
return lastDot > 0 ? n.substring(0, lastDot) : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _iconForFilename(String? name) {
|
||||||
|
if (name == null || !name.contains('.')) return Symbols.description;
|
||||||
|
final ext = name.split('.').last.toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
case 'png':
|
||||||
|
case 'gif':
|
||||||
|
case 'webp':
|
||||||
|
case 'bmp':
|
||||||
|
case 'heic':
|
||||||
|
case 'heif':
|
||||||
|
return Symbols.image;
|
||||||
|
case 'mp4':
|
||||||
|
case 'mov':
|
||||||
|
case 'avi':
|
||||||
|
case 'mkv':
|
||||||
|
case 'webm':
|
||||||
|
case '3gp':
|
||||||
|
return Symbols.movie;
|
||||||
|
case 'mp3':
|
||||||
|
case 'wav':
|
||||||
|
case 'ogg':
|
||||||
|
case 'flac':
|
||||||
|
case 'm4a':
|
||||||
|
case 'aac':
|
||||||
|
return Symbols.audio_file;
|
||||||
|
case 'pdf':
|
||||||
|
return Symbols.picture_as_pdf;
|
||||||
|
case 'zip':
|
||||||
|
case 'rar':
|
||||||
|
case '7z':
|
||||||
|
case 'tar':
|
||||||
|
case 'gz':
|
||||||
|
return Symbols.folder_zip;
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
case 'txt':
|
||||||
|
case 'rtf':
|
||||||
|
case 'odt':
|
||||||
|
case 'md':
|
||||||
|
return Symbols.article;
|
||||||
|
case 'xls':
|
||||||
|
case 'xlsx':
|
||||||
|
case 'csv':
|
||||||
|
return Symbols.table_chart;
|
||||||
|
case 'ppt':
|
||||||
|
case 'pptx':
|
||||||
|
return Symbols.slideshow;
|
||||||
|
case 'dart':
|
||||||
|
case 'js':
|
||||||
|
case 'ts':
|
||||||
|
case 'py':
|
||||||
|
case 'java':
|
||||||
|
case 'kt':
|
||||||
|
case 'swift':
|
||||||
|
case 'cpp':
|
||||||
|
case 'c':
|
||||||
|
case 'h':
|
||||||
|
case 'rs':
|
||||||
|
case 'go':
|
||||||
|
case 'rb':
|
||||||
|
case 'php':
|
||||||
|
case 'html':
|
||||||
|
case 'css':
|
||||||
|
case 'json':
|
||||||
|
case 'xml':
|
||||||
|
case 'yaml':
|
||||||
|
case 'yml':
|
||||||
|
return Symbols.code;
|
||||||
|
default:
|
||||||
|
return Symbols.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SentMessageAnimation extends StatefulWidget {
|
class _SentMessageAnimation extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final VoidCallback onComplete;
|
final VoidCallback onComplete;
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert' show utf8;
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:komet/backend/modules/messages.dart' show FileHistoryCache, FileHistoryEntry;
|
|
||||||
import 'package:komet/core/config/proxy_config.dart';
|
|
||||||
import 'package:komet/core/protocol/opcode_map.dart';
|
|
||||||
import 'package:komet/core/protocol/packet.dart';
|
|
||||||
import 'package:komet/core/transport/proxy_connector.dart';
|
|
||||||
import 'package:komet/frontend/widgets/custom_notification.dart';
|
import 'package:komet/frontend/widgets/custom_notification.dart';
|
||||||
import 'package:komet/main.dart' show api, messagesModule;
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
class AttachmentPanel extends StatefulWidget {
|
class AttachmentPanel extends StatefulWidget {
|
||||||
final int chatId;
|
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onPickFile;
|
||||||
|
final Future<bool> Function(int fileId) onSendById;
|
||||||
|
|
||||||
const AttachmentPanel({
|
const AttachmentPanel({
|
||||||
super.key,
|
super.key,
|
||||||
required this.chatId,
|
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
|
required this.onPickFile,
|
||||||
|
required this.onSendById,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -29,259 +20,21 @@ class AttachmentPanel extends StatefulWidget {
|
|||||||
|
|
||||||
class _AttachmentPanelState extends State<AttachmentPanel> {
|
class _AttachmentPanelState extends State<AttachmentPanel> {
|
||||||
final TextEditingController _fileIdController = TextEditingController();
|
final TextEditingController _fileIdController = TextEditingController();
|
||||||
bool _isUploading = false;
|
bool _sendingById = false;
|
||||||
|
|
||||||
Future<void> _pickAndUploadFile() async {
|
Future<void> _sendById() async {
|
||||||
final result = await FilePicker.platform.pickFiles();
|
final s = _fileIdController.text.trim();
|
||||||
if (result == null || result.files.isEmpty) return;
|
if (s.isEmpty) return;
|
||||||
final file = result.files.first;
|
final id = int.tryParse(s);
|
||||||
if (file.path == null) return;
|
if (id == null) {
|
||||||
|
showCustomNotification(context, 'Неверный fileId');
|
||||||
setState(() => _isUploading = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final uploadInfo = await messagesModule.requestUploadUrl();
|
|
||||||
if (uploadInfo == null) {
|
|
||||||
if (mounted) showCustomNotification(context, 'Не удалось получить ссылку');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.sendRequest(Opcode.msgTyping, {
|
|
||||||
'chatId': widget.chatId,
|
|
||||||
'type': 'FILE',
|
|
||||||
});
|
|
||||||
|
|
||||||
final uri = Uri.parse(uploadInfo.url);
|
|
||||||
final fileBytes = await File(file.path!).readAsBytes();
|
|
||||||
final proxySettings = await ProxyConfig.load();
|
|
||||||
|
|
||||||
int statusCode;
|
|
||||||
if (proxySettings.isEnabled) {
|
|
||||||
final connector = ProxyConnector(proxySettings);
|
|
||||||
final proxySocket = await connector.connect(uri.host, uri.port);
|
|
||||||
final socket = uri.scheme == 'https'
|
|
||||||
? await RawSecureSocket.secure(
|
|
||||||
proxySocket,
|
|
||||||
host: uri.host,
|
|
||||||
onBadCertificate: (_) => true,
|
|
||||||
)
|
|
||||||
: proxySocket;
|
|
||||||
statusCode = await _rawPost(socket, uri, fileBytes, file.name);
|
|
||||||
} else {
|
|
||||||
final socket = await RawSocket.connect(uri.host, uri.port);
|
|
||||||
final secureSocket = uri.scheme == 'https'
|
|
||||||
? await RawSecureSocket.secure(
|
|
||||||
socket,
|
|
||||||
host: uri.host,
|
|
||||||
onBadCertificate: (_) => true,
|
|
||||||
)
|
|
||||||
: socket;
|
|
||||||
statusCode = await _rawPost(secureSocket, uri, fileBytes, file.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusCode != 200) {
|
|
||||||
if (mounted) showCustomNotification(context, 'Ошибка загрузки: $statusCode');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for notifAttach push
|
|
||||||
final pushCompleter = Completer<void>();
|
|
||||||
void Function(Packet)? pushHandler;
|
|
||||||
pushHandler = (Packet packet) {
|
|
||||||
final payload = packet.payload;
|
|
||||||
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
|
|
||||||
api.unregisterPushHandler(Opcode.notifAttach);
|
|
||||||
pushCompleter.complete();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
api.registerPushHandler(Opcode.notifAttach, (Packet p) => pushHandler!(p));
|
|
||||||
|
|
||||||
await pushCompleter.future.timeout(
|
|
||||||
const Duration(seconds: 30),
|
|
||||||
onTimeout: () {
|
|
||||||
api.unregisterPushHandler(Opcode.notifAttach);
|
|
||||||
throw TimeoutException('Тайм-аут подтверждения загрузки');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Retry loop: server may say "attachment in progress" (cmd=3)
|
|
||||||
for (var attempt = 0; attempt < 5; attempt++) {
|
|
||||||
final sent = await messagesModule.sendFileMessage(
|
|
||||||
widget.chatId,
|
|
||||||
uploadInfo.fileId,
|
|
||||||
token: uploadInfo.token,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Listen for push again (another notifAttach may come)
|
|
||||||
final msgCompleter = Completer<bool>();
|
|
||||||
void Function(Packet)? msgHandler;
|
|
||||||
msgHandler = (Packet packet) {
|
|
||||||
final payload = packet.payload;
|
|
||||||
if (payload is Map && payload['fileId'] == uploadInfo.fileId) {
|
|
||||||
api.unregisterPushHandler(Opcode.notifAttach);
|
|
||||||
msgCompleter.complete(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
api.registerPushHandler(Opcode.notifAttach, (Packet p) => msgHandler!(p));
|
|
||||||
|
|
||||||
final pushFuture = msgCompleter.future.timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
onTimeout: () {
|
|
||||||
api.unregisterPushHandler(Opcode.notifAttach);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final pushReceived = await pushFuture;
|
|
||||||
if (pushReceived && sent) {
|
|
||||||
FileHistoryCache.add(FileHistoryEntry(
|
|
||||||
fileId: uploadInfo.fileId,
|
|
||||||
url: uploadInfo.url,
|
|
||||||
token: uploadInfo.token,
|
|
||||||
sentAt: DateTime.now(),
|
|
||||||
));
|
|
||||||
if (mounted) {
|
|
||||||
showCustomNotification(context, 'Файл отправлен');
|
|
||||||
widget.onClose();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If push was received, check if message was sent
|
|
||||||
if (pushReceived) {
|
|
||||||
FileHistoryCache.add(FileHistoryEntry(
|
|
||||||
fileId: uploadInfo.fileId,
|
|
||||||
url: uploadInfo.url,
|
|
||||||
token: uploadInfo.token,
|
|
||||||
sentAt: DateTime.now(),
|
|
||||||
));
|
|
||||||
if (mounted) {
|
|
||||||
showCustomNotification(context, 'Файл отправлен');
|
|
||||||
widget.onClose();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sent) {
|
|
||||||
// msgSend failed, maybe server still processing — wait and retry
|
|
||||||
await Future.delayed(Duration(seconds: 1 + attempt));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sent ok, no push received (already processed earlier)
|
|
||||||
FileHistoryCache.add(FileHistoryEntry(
|
|
||||||
fileId: uploadInfo.fileId,
|
|
||||||
url: uploadInfo.url,
|
|
||||||
token: uploadInfo.token,
|
|
||||||
sentAt: DateTime.now(),
|
|
||||||
));
|
|
||||||
if (mounted) {
|
|
||||||
showCustomNotification(context, 'Файл отправлен');
|
|
||||||
widget.onClose();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) showCustomNotification(context, 'Не удалось отправить сообщение');
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isUploading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _rawPost(RawSocket socket, Uri uri, List<int> body, String filename) async {
|
|
||||||
final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}';
|
|
||||||
final host = uri.host;
|
|
||||||
final total = body.length;
|
|
||||||
|
|
||||||
final request = StringBuffer()
|
|
||||||
..write('POST $path HTTP/1.1\r\n')
|
|
||||||
..write('Host: $host\r\n')
|
|
||||||
..write('Content-Type: application/x-binary; charset=x-user-defined\r\n')
|
|
||||||
..write('Content-Disposition: attachment; filename=$filename\r\n')
|
|
||||||
..write('Connection: keep-alive\r\n')
|
|
||||||
..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n')
|
|
||||||
..write('Content-Range: bytes 0-${total - 1}/$total\r\n')
|
|
||||||
..write('Content-Length: $total\r\n')
|
|
||||||
..write('\r\n');
|
|
||||||
|
|
||||||
final requestBytes = utf8.encode(request.toString());
|
|
||||||
final allBytes = <int>[...requestBytes, ...(body is Uint8List ? body : Uint8List.fromList(body))];
|
|
||||||
socket.write(Uint8List.fromList(allBytes));
|
|
||||||
|
|
||||||
final responseBytes = <int>[];
|
|
||||||
final completer = Completer<int>();
|
|
||||||
Timer? timer;
|
|
||||||
|
|
||||||
socket.listen((event) {
|
|
||||||
if (event == RawSocketEvent.read) {
|
|
||||||
final data = socket.read();
|
|
||||||
if (data != null) responseBytes.addAll(data);
|
|
||||||
} else if (event == RawSocketEvent.readClosed || event == RawSocketEvent.closed) {
|
|
||||||
timer?.cancel();
|
|
||||||
if (responseBytes.isEmpty) {
|
|
||||||
completer.completeError(const SocketException('Пустой ответ сервера'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final headerEnd = _findHeaderEnd(responseBytes);
|
|
||||||
if (headerEnd == -1) {
|
|
||||||
completer.completeError(const SocketException('Не удалось прочитать заголовок ответа'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final headerStr = utf8.decode(responseBytes.sublist(0, headerEnd), allowMalformed: true);
|
|
||||||
final statusLine = headerStr.split('\r\n').first;
|
|
||||||
debugPrint('HTTP Response: $statusLine');
|
|
||||||
final parts = statusLine.split(' ');
|
|
||||||
completer.complete(parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0);
|
|
||||||
}
|
|
||||||
}, onError: (e) {
|
|
||||||
timer?.cancel();
|
|
||||||
completer.completeError(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
timer = Timer(const Duration(minutes: 5), () {
|
|
||||||
socket.close();
|
|
||||||
completer.completeError(TimeoutException('Тайм-аут загрузки'));
|
|
||||||
});
|
|
||||||
|
|
||||||
return completer.future;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _findHeaderEnd(List<int> bytes) {
|
|
||||||
for (var i = 0; i < bytes.length - 3; i++) {
|
|
||||||
if (bytes[i] == 0x0D && bytes[i + 1] == 0x0A &&
|
|
||||||
bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A) {
|
|
||||||
return i + 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _uploadByFileId() async {
|
|
||||||
final fileIdStr = _fileIdController.text.trim();
|
|
||||||
if (fileIdStr.isEmpty) return;
|
|
||||||
final fileId = int.tryParse(fileIdStr);
|
|
||||||
if (fileId == null) {
|
|
||||||
if (mounted) showCustomNotification(context, 'Неверный fileId');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _isUploading = true);
|
setState(() => _sendingById = true);
|
||||||
try {
|
final ok = await widget.onSendById(id);
|
||||||
final sent = await messagesModule.sendFileMessage(widget.chatId, fileId);
|
if (!mounted) return;
|
||||||
if (sent) {
|
setState(() => _sendingById = false);
|
||||||
if (mounted) {
|
if (ok) _fileIdController.clear();
|
||||||
showCustomNotification(context, 'Файл отправлен');
|
|
||||||
widget.onClose();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mounted) showCustomNotification(context, 'Ошибка отправки');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _isUploading = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -293,34 +46,23 @@ class _AttachmentPanelState extends State<AttachmentPanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
return GestureDetector(
|
return Container(
|
||||||
onVerticalDragEnd: (details) {
|
decoration: BoxDecoration(
|
||||||
if (details.velocity.pixelsPerSecond.dy > 300) widget.onClose();
|
color: cs.surfaceContainerHighest,
|
||||||
},
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Container(
|
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: cs.surfaceContainerHighest,
|
child: Stack(children: [
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
const SizedBox(height: 40),
|
||||||
),
|
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Container(
|
|
||||||
width: 36,
|
|
||||||
height: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Expanded(child: _buildButton(
|
Expanded(child: _buildButton(
|
||||||
label: 'Выбрать из файла',
|
label: 'Выбрать из файла',
|
||||||
icon: Symbols.folder_open,
|
icon: Symbols.folder_open,
|
||||||
filled: true,
|
filled: true,
|
||||||
onTap: _isUploading ? null : _pickAndUploadFile,
|
onTap: _sendingById ? null : widget.onPickFile,
|
||||||
cs: cs,
|
cs: cs,
|
||||||
)),
|
)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -328,80 +70,43 @@ class _AttachmentPanelState extends State<AttachmentPanel> {
|
|||||||
label: 'Отправить по id',
|
label: 'Отправить по id',
|
||||||
icon: null,
|
icon: null,
|
||||||
filled: false,
|
filled: false,
|
||||||
onTap: _isUploading ? null : _uploadByFileId,
|
onTap: _sendingById ? null : _sendById,
|
||||||
cs: cs,
|
cs: cs,
|
||||||
)),
|
)),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
if (_isUploading)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
||||||
child: LinearProgressIndicator(),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: TextField(
|
|
||||||
controller: _fileIdController,
|
|
||||||
style: TextStyle(color: cs.onSurface, fontSize: 14),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'fileId...',
|
|
||||||
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
|
||||||
border: InputBorder.none,
|
|
||||||
isDense: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 16),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 16, bottom: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Align(
|
child: TextField(
|
||||||
alignment: Alignment.centerLeft,
|
controller: _fileIdController,
|
||||||
child: Text('История', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w500)),
|
style: TextStyle(color: cs.onSurface, fontSize: 14),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'fileId...',
|
||||||
|
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (FileHistoryCache.isEmpty)
|
const SizedBox(height: 12),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Text('история пуста...', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14)),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
SizedBox(
|
|
||||||
height: 100,
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
itemCount: FileHistoryCache.history.length,
|
|
||||||
itemBuilder: (ctx, idx) {
|
|
||||||
final e = FileHistoryCache.history[idx];
|
|
||||||
return Container(
|
|
||||||
width: 72,
|
|
||||||
margin: const EdgeInsets.only(right: 8, bottom: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: cs.surfaceContainerLow,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
child: Center(child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.description, color: cs.onSurfaceVariant, size: 28),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: Text('${e.fileId}', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 9), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
]),
|
]),
|
||||||
),
|
Positioned(
|
||||||
|
left: 6,
|
||||||
|
top: 6,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: widget.onClose,
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(Symbols.close, color: cs.onSurfaceVariant, size: 22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1126,77 +1126,86 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final size = (file as dynamic).size as int? ?? 0;
|
final size = (file as dynamic).size as int? ?? 0;
|
||||||
final sizeStr = _formatFileSize(size);
|
final sizeStr = _formatFileSize(size);
|
||||||
|
|
||||||
return Padding(
|
return IntrinsicWidth(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
child: Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
width: 38,
|
mainAxisSize: MainAxisSize.min,
|
||||||
height: 38,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: isMe
|
Container(
|
||||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
width: 38,
|
||||||
: ctx.cs.primaryContainer,
|
height: 38,
|
||||||
borderRadius: BorderRadius.circular(10),
|
decoration: BoxDecoration(
|
||||||
),
|
color: isMe
|
||||||
child: Icon(
|
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||||
Symbols.description,
|
: ctx.cs.primaryContainer,
|
||||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
borderRadius: BorderRadius.circular(10),
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Flexible(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: ctx.text,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
child: Icon(
|
||||||
Text(
|
Symbols.description,
|
||||||
sizeStr,
|
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||||
style: TextStyle(
|
size: 20,
|
||||||
color: ctx.dim,
|
),
|
||||||
fontSize: 12,
|
),
|
||||||
height: 1.2,
|
const SizedBox(width: 10),
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: ctx.text,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
sizeStr,
|
||||||
|
style: TextStyle(
|
||||||
|
color: ctx.dim,
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
child: Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe
|
||||||
|
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
||||||
|
: ctx.cs.surfaceContainerHighest,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Symbols.download,
|
||||||
|
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
||||||
|
size: 18,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {},
|
|
||||||
child: Container(
|
|
||||||
width: 34,
|
|
||||||
height: 34,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isMe
|
|
||||||
? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12)
|
|
||||||
: ctx.cs.surfaceContainerHighest,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
),
|
||||||
child: Icon(
|
],
|
||||||
Symbols.download,
|
|
||||||
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
_buildMeta(ctx),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'core/config/app_cache_extent.dart';
|
|||||||
import 'core/config/app_fonts.dart';
|
import 'core/config/app_fonts.dart';
|
||||||
import 'backend/modules/account.dart';
|
import 'backend/modules/account.dart';
|
||||||
import 'backend/modules/contacts.dart';
|
import 'backend/modules/contacts.dart';
|
||||||
|
import 'backend/modules/file_uploader.dart';
|
||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
import 'core/push/push_service.dart';
|
import 'core/push/push_service.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
@@ -29,6 +30,7 @@ import 'frontend/widgets/custom_notification.dart';
|
|||||||
final api = Api();
|
final api = Api();
|
||||||
final accountModule = AccountModule(api);
|
final accountModule = AccountModule(api);
|
||||||
final messagesModule = MessagesModule(api);
|
final messagesModule = MessagesModule(api);
|
||||||
|
final fileUploader = FileUploader(api: api, messages: messagesModule);
|
||||||
|
|
||||||
Future<Locale> _loadInitialLocale() async {
|
Future<Locale> _loadInitialLocale() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -62,6 +64,7 @@ void main() async {
|
|||||||
await Haptics.load();
|
await Haptics.load();
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await FileHistoryCache.load(prefs);
|
||||||
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
||||||
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
||||||
final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false;
|
final initialTlsInsecure = prefs.getBool(TlsConfig.prefKey) ?? false;
|
||||||
|
|||||||
Reference in New Issue
Block a user