Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEefQULnwjVZt2yzvK8CWn
691 lines
18 KiB
Dart
691 lines
18 KiB
Dart
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<int>.filled(slots < 1 ? 1 : slots, 0),
|
|
_total = List<int>.filled(slots < 1 ? 1 : slots, 0),
|
|
progress = ValueNotifier<List<double>>(
|
|
List<double>.filled(slots < 1 ? 1 : slots, 0),
|
|
),
|
|
bytes = ValueNotifier<UploadBytes>(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<List<double>> progress;
|
|
final ValueNotifier<UploadBytes> bytes;
|
|
|
|
int? resultFileId;
|
|
String? resultFileToken;
|
|
|
|
final int _slots;
|
|
final List<int> _sent;
|
|
final List<int> _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<double>.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<UploadJobEvent> _events =
|
|
StreamController<UploadJobEvent>.broadcast();
|
|
|
|
final Map<String, UploadJob> _jobs = {};
|
|
final Map<String, CachedMessage> _completed = {};
|
|
final Set<String> _failed = {};
|
|
|
|
int _tempIdCounter = 0;
|
|
|
|
Stream<UploadJobEvent> get events => _events.stream;
|
|
|
|
String newTempId() =>
|
|
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
|
|
|
UploadJob? job(String tempId) => _jobs[tempId];
|
|
|
|
ValueListenable<List<double>>? 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<CachedMessage> pendingFor(int chatId) {
|
|
final pending = <CachedMessage>[];
|
|
for (final job in _jobs.values) {
|
|
final placeholder = job.placeholder;
|
|
if (job.chatId == chatId && placeholder != null) pending.add(placeholder);
|
|
}
|
|
return List<CachedMessage>.unmodifiable(pending);
|
|
}
|
|
|
|
CachedMessage? completedFor(String tempId) => _completed[tempId];
|
|
|
|
bool didFail(String tempId) => _failed.contains(tempId);
|
|
|
|
Future<void> 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<String>(),
|
|
caption: caption.isEmpty ? null : caption,
|
|
scheduledTime: scheduledTime,
|
|
);
|
|
if (sent == null) return null;
|
|
return CachedMessage.fromPushPayload(accountId, chatId, sent);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> 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<void> 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<void> 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<void> _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<Map<String, dynamic>?> 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<void> 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<UploadDone> _uploadFile({
|
|
required int chatId,
|
|
required UploadJob job,
|
|
required File source,
|
|
required String filename,
|
|
required int size,
|
|
int? scheduledTime,
|
|
}) async {
|
|
final result = Completer<UploadDone>();
|
|
late final StreamSubscription<UploadEvent> 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<void> _run(
|
|
UploadJob job,
|
|
Future<CachedMessage?> 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<List<String?>> _uploadPhotos(
|
|
List<({File file, GalleryItem? item})> jobs,
|
|
UploadJob job,
|
|
) async {
|
|
final tokens = List<String?>.filled(jobs.length, null);
|
|
var nextIndex = 0;
|
|
var failed = false;
|
|
|
|
Future<void> 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<String?> _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<CachedMessage>.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,
|
|
);
|
|
}
|
|
}
|