Files
Qlyra/lib/backend/modules/upload_service.dart
T

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,
);
}
}