отправка файлов, анимации с реанимации
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 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
@@ -55,27 +56,93 @@ class FileHistoryEntry {
|
||||
final int fileId;
|
||||
final String? url;
|
||||
final String? token;
|
||||
final String? filename;
|
||||
final int? size;
|
||||
final DateTime sentAt;
|
||||
|
||||
FileHistoryEntry({
|
||||
required this.fileId,
|
||||
this.url,
|
||||
this.token,
|
||||
this.filename,
|
||||
this.size,
|
||||
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 {
|
||||
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) {
|
||||
_history.insert(0, entry);
|
||||
if (_history.length > 50) _history.removeLast();
|
||||
static List<FileHistoryEntry> get history => notifier.value;
|
||||
static bool get isEmpty => notifier.value.isEmpty;
|
||||
|
||||
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 {
|
||||
@@ -393,6 +460,8 @@ class MessagesModule {
|
||||
int fileId, {
|
||||
String? token,
|
||||
bool notify = true,
|
||||
int maxAttempts = 5,
|
||||
Duration retryDelay = const Duration(seconds: 1),
|
||||
}) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
@@ -411,8 +480,16 @@ class MessagesModule {
|
||||
'notify': notify,
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.msgSend, payload);
|
||||
return response.isOk;
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user