import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import '../../core/cache/message_session_cache.dart'; import '../../core/media/gallery_source.dart'; import '../../core/media/image_optimizer.dart'; import '../../core/storage/app_database.dart'; import '../../core/utils/logger.dart'; import '../../main.dart' show fileUploader, messagesModule; import '../../models/attachment.dart'; import 'file_uploader.dart'; import 'messages.dart'; import 'upload_notification_service.dart'; export 'upload_notification_service.dart' show UploadKind; sealed class UploadJobEvent { const UploadJobEvent({ required this.chatId, required this.tempId, required this.kind, required this.scheduled, }); final int chatId; final String tempId; final UploadKind kind; final bool scheduled; } class UploadJobDone extends UploadJobEvent { const UploadJobDone({ required super.chatId, required super.tempId, required super.kind, required super.scheduled, this.message, this.scheduledTime, this.fileId, this.fileToken, }); final CachedMessage? message; final int? scheduledTime; final int? fileId; final String? fileToken; } class UploadJobFailed extends UploadJobEvent { const UploadJobFailed({ required super.chatId, required super.tempId, required super.kind, required super.scheduled, required this.reason, }); final String reason; } class UploadFailure implements Exception { const UploadFailure(this.message); final String message; @override String toString() => 'UploadFailure($message)'; } class UploadBytes { const UploadBytes(this.sent, this.total); final int sent; final int total; } class UploadJob { UploadJob._({ required this.id, required this.accountId, required this.chatId, required this.kind, required int slots, this.filename, this.totalBytes = 0, this.placeholder, this.scheduledTime, }) : _slots = slots < 1 ? 1 : slots, _sent = List.filled(slots < 1 ? 1 : slots, 0), _total = List.filled(slots < 1 ? 1 : slots, 0), progress = ValueNotifier>( List.filled(slots < 1 ? 1 : slots, 0), ), bytes = ValueNotifier(UploadBytes(0, totalBytes)) { if (_slots == 1 && totalBytes > 0) _total[0] = totalBytes; } final String id; final int accountId; final int chatId; final UploadKind kind; final String? filename; final int totalBytes; final CachedMessage? placeholder; final int? scheduledTime; final ValueNotifier> progress; final ValueNotifier bytes; int? resultFileId; String? resultFileToken; final int _slots; final List _sent; final List _total; bool get scheduled => scheduledTime != null; int get slots => _slots; void report(int slot, int sent, int total) { if (slot < 0 || slot >= _slots) return; _sent[slot] = sent; if (total > 0) _total[slot] = total; _publish(); } void resetSlot(int slot) { if (slot < 0 || slot >= _slots) return; _sent[slot] = 0; _publish(); } void markUploaded() { for (var i = 0; i < _slots; i++) { if (_total[i] <= 0) _total[i] = _sent[i] > 0 ? _sent[i] : 1; _sent[i] = _total[i]; } _publish(); } void _publish() { final fractions = List.generate(_slots, (i) { if (_total[i] <= 0) return 0.0; return (_sent[i] / _total[i]).clamp(0.0, 1.0); }); var sumSent = 0; var sumTotal = 0; var fractionSum = 0.0; for (var i = 0; i < _slots; i++) { sumSent += _sent[i]; sumTotal += _total[i]; fractionSum += fractions[i]; } progress.value = fractions; bytes.value = UploadBytes(sumSent, sumTotal); UploadNotificationService.report( id, sent: sumSent, total: sumTotal, fraction: fractionSum / _slots, ); } } class UploadService { UploadService._(); static final UploadService instance = UploadService._(); static const int _historyLimit = 60; static const int _photoConcurrency = 3; static const int _photoAttempts = 3; final StreamController _events = StreamController.broadcast(); final Map _jobs = {}; final Map _completed = {}; final Set _failed = {}; int _tempIdCounter = 0; Stream get events => _events.stream; String newTempId() => 'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}'; UploadJob? job(String tempId) => _jobs[tempId]; ValueListenable>? progressFor(String tempId) => _jobs[tempId]?.progress; UploadJob? activeFileJob(int chatId) { for (final job in _jobs.values) { if (job.chatId == chatId && job.kind == UploadKind.file) return job; } return null; } List pendingFor(int chatId) { final pending = []; for (final job in _jobs.values) { final placeholder = job.placeholder; if (job.chatId == chatId && placeholder != null) pending.add(placeholder); } return List.unmodifiable(pending); } CachedMessage? completedFor(String tempId) => _completed[tempId]; bool didFail(String tempId) => _failed.contains(tempId); Future sendPhotos({ required int accountId, required int chatId, required String tempId, required List<({File file, GalleryItem? item})> jobs, required String caption, CachedMessage? placeholder, int? scheduledTime, }) { return _run( UploadJob._( id: tempId, accountId: accountId, chatId: chatId, kind: UploadKind.photo, slots: jobs.length, placeholder: placeholder, scheduledTime: scheduledTime, ), (job) async { final tokens = await _uploadPhotos(jobs, job); if (tokens.any((token) => token == null)) { throw const UploadFailure('upload_failed'); } final sent = await messagesModule.sendPhotoMessage( chatId, tokens.cast(), caption: caption.isEmpty ? null : caption, scheduledTime: scheduledTime, ); if (sent == null) return null; return CachedMessage.fromPushPayload(accountId, chatId, sent); }, ); } Future sendVideo({ required int accountId, required int chatId, required String tempId, required File file, required String caption, CachedMessage? placeholder, int? scheduledTime, }) { return _run( UploadJob._( id: tempId, accountId: accountId, chatId: chatId, kind: UploadKind.video, slots: 1, placeholder: placeholder, scheduledTime: scheduledTime, ), (job) async { final info = await messagesModule.requestVideoUploadUrl(); if (info == null || info.url.isEmpty) { throw const UploadFailure('no_upload_url'); } final ok = await fileUploader.uploadVideoFile( Uri.parse(info.url), file, onProgress: (sent, total) => job.report(0, sent, total), ); if (!ok) throw const UploadFailure('upload_failed'); job.markUploaded(); final sent = await messagesModule.sendVideoMessage( chatId, info.token, caption: caption.isEmpty ? null : caption, scheduledTime: scheduledTime, ); if (sent == null) return null; return CachedMessage.fromPushPayload(accountId, chatId, sent); }, ); } Future sendVoice({ required int accountId, required int chatId, required String tempId, required File file, required int durationMs, required Uint8List wave, CachedMessage? placeholder, }) { return _sendRecording( accountId: accountId, chatId: chatId, tempId: tempId, file: file, kind: UploadKind.voice, placeholder: placeholder, requestUpload: () async { final info = await messagesModule.requestAudioUploadUrl(); return info == null ? null : (url: info.url, token: info.token); }, send: (token) => messagesModule.sendAudioMessage( chatId, token, duration: durationMs, wave: wave, ), ); } Future sendVideoNote({ required int accountId, required int chatId, required String tempId, required File file, required int durationMs, CachedMessage? placeholder, }) { return _sendRecording( accountId: accountId, chatId: chatId, tempId: tempId, file: file, kind: UploadKind.videoNote, placeholder: placeholder, requestUpload: () async { final info = await messagesModule.requestVideoNoteUploadUrl(); return info == null ? null : (url: info.url, token: info.token); }, send: (token) => messagesModule.sendVideoNoteMessage( chatId, token, duration: durationMs, ), ); } Future _sendRecording({ required int accountId, required int chatId, required String tempId, required File file, required UploadKind kind, required Future<({String url, String token})?> Function() requestUpload, required Future?> Function(String token) send, CachedMessage? placeholder, }) { return _run( UploadJob._( id: tempId, accountId: accountId, chatId: chatId, kind: kind, slots: 1, placeholder: placeholder, ), (job) async { try { final info = await requestUpload(); if (info == null || info.url.isEmpty) { throw const UploadFailure('no_upload_url'); } final ok = await fileUploader.uploadMediaFile( Uri.parse(info.url), file, onProgress: (sent, total) => job.report(0, sent, total), ); if (!ok) throw const UploadFailure('upload_failed'); job.markUploaded(); final sent = await send(info.token); if (sent == null) return null; return CachedMessage.fromPushPayload(accountId, chatId, sent); } finally { try { await file.delete(); } catch (_) {} } }, ); } Future sendFile({ required int accountId, required int chatId, required String tempId, required File source, required String filename, required int size, CachedMessage? placeholder, int? scheduledTime, }) { return _run( UploadJob._( id: tempId, accountId: accountId, chatId: chatId, kind: UploadKind.file, slots: 1, filename: filename, totalBytes: size, placeholder: placeholder, scheduledTime: scheduledTime, ), (job) async { final done = await _uploadFile( chatId: chatId, job: job, source: source, filename: filename, size: size, scheduledTime: scheduledTime, ); FileHistoryCache.add( FileHistoryEntry( fileId: done.fileId, url: done.url, token: done.token, filename: done.filename, size: done.size, sentAt: DateTime.now(), ), ); job.resultFileId = done.fileId; job.resultFileToken = done.token; if (scheduledTime != null) return null; final base = placeholder; return CachedMessage( id: done.messageId == null || done.messageId!.isEmpty ? tempId : done.messageId!, accountId: accountId, chatId: chatId, senderId: accountId, text: base?.text, time: base?.time ?? DateTime.now().millisecondsSinceEpoch, status: 'sent', attachments: [ FileAttachment( fileId: done.fileId, fileToken: done.token, name: filename, size: size, ), ], ); }, ); } Future _uploadFile({ required int chatId, required UploadJob job, required File source, required String filename, required int size, int? scheduledTime, }) async { final result = Completer(); late final StreamSubscription sub; sub = fileUploader .upload( chatId: chatId, file: source, filename: filename, totalSize: size, scheduledTime: scheduledTime, ) .listen( (event) { switch (event) { case UploadProgress(:final sent, :final total): job.report(0, sent, total); case UploadDone(): if (!result.isCompleted) result.complete(event); case UploadError(:final message): if (!result.isCompleted) { result.completeError(UploadFailure(message)); } } }, onError: (Object e) { if (!result.isCompleted) result.completeError(e); }, onDone: () { if (!result.isCompleted) { result.completeError(const UploadFailure('upload_failed')); } }, ); try { return await result.future; } finally { await sub.cancel(); } } Future _run( UploadJob job, Future Function(UploadJob job) upload, ) async { _jobs[job.id] = job; UploadNotificationService.begin( job.id, kind: job.kind, count: job.slots, filename: job.filename, ); CachedMessage? real; String? failure; try { real = await upload(job); if (real == null && !job.scheduled) failure = 'send_failed'; } catch (e) { failure = e is UploadFailure ? e.message : e.toString(); } UploadNotificationService.end(job.id); _jobs.remove(job.id); if (failure != null) { logger.w('UploadService: ${job.id} — $failure'); if (!job.scheduled) { _replaceInSessionCache(job.accountId, job.chatId, job.id, null); _remember(job.id, null); } _events.add( UploadJobFailed( chatId: job.chatId, tempId: job.id, kind: job.kind, scheduled: job.scheduled, reason: failure, ), ); return; } if (job.scheduled) { _events.add( UploadJobDone( chatId: job.chatId, tempId: job.id, kind: job.kind, scheduled: true, scheduledTime: job.scheduledTime, fileId: job.resultFileId, fileToken: job.resultFileToken, ), ); return; } final message = real!; try { await AppDatabase.saveMessages([message.toDbRow()]); if (message.id != job.id) { await AppDatabase.deleteMessage(job.accountId, job.chatId, job.id); } } catch (e) { logger.w('UploadService: не удалось сохранить сообщение: $e'); } _replaceInSessionCache(job.accountId, job.chatId, job.id, message); _remember(job.id, message); _events.add( UploadJobDone( chatId: job.chatId, tempId: job.id, kind: job.kind, scheduled: false, message: message, fileId: job.resultFileId, fileToken: job.resultFileToken, ), ); } void _remember(String tempId, CachedMessage? real) { if (_completed.length + _failed.length > _historyLimit) { _completed.clear(); _failed.clear(); } if (real == null) { _failed.add(tempId); } else { _completed[tempId] = real; } } Future> _uploadPhotos( List<({File file, GalleryItem? item})> jobs, UploadJob job, ) async { final tokens = List.filled(jobs.length, null); var nextIndex = 0; var failed = false; Future worker() async { while (!failed) { final i = nextIndex++; if (i >= jobs.length) return; final token = await _uploadOnePhoto(jobs[i], i, job); if (token == null) { failed = true; return; } tokens[i] = token; } } final workerCount = jobs.length < _photoConcurrency ? jobs.length : _photoConcurrency; await Future.wait(List.generate(workerCount, (_) => worker())); return tokens; } Future _uploadOnePhoto( ({File file, GalleryItem? item}) photo, int index, UploadJob job, ) async { File file; try { file = await optimizePhotoForUpload(photo.file, item: photo.item); } catch (e) { logger.w('optimize photo: $e'); file = photo.file; } for (var attempt = 0; attempt < _photoAttempts; attempt++) { if (attempt > 0) { await Future.delayed(Duration(seconds: attempt)); job.resetSlot(index); } try { final url = await messagesModule.requestPhotoUploadUrl(); if (url == null || url.isEmpty) continue; final token = await fileUploader.uploadPhoto( Uri.parse(url), file, filename: _photoFilename(file), onProgress: (sent, total) => job.report(index, sent, total), ); if (token != null) return token; } catch (e) { logger.w('uploadOnePhoto attempt ${attempt + 1}: $e'); } } return null; } String _photoFilename(File file) { final segments = file.uri.pathSegments; final name = segments.isNotEmpty ? segments.last : ''; return name.isNotEmpty ? name : 'photo.jpg'; } void _replaceInSessionCache( int accountId, int chatId, String tempId, CachedMessage? real, ) { final cached = MessageSessionCache.get(accountId, chatId); if (cached == null) return; final list = List.of(cached.messages); final idx = list.indexWhere((m) => m.id == tempId); if (idx == -1) return; list[idx] = real ?? list[idx].copyWith(status: 'error'); MessageSessionCache.save( accountId, chatId, list, reachedStart: cached.reachedStart, ); } }