feat: анимации с реанимации

This commit is contained in:
Jganenokk
2026-08-06 19:21:45 +07:00
parent 81159bac60
commit 79c743e86a
50 changed files with 3247 additions and 925 deletions
+48 -1
View File
@@ -10,6 +10,7 @@ import '../../core/cache/info_cache.dart';
import '../../core/cache/message_session_cache.dart';
import 'shared_content.dart';
import '../../core/storage/app_database.dart';
import '../../core/storage/chat_members_store.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/text_format.dart';
@@ -559,6 +560,41 @@ class ChatsModule {
final ValueNotifier<int> chatsChanged = ValueNotifier(0);
void _bump() => chatsChanged.value = chatsChanged.value + 1;
static const Set<String> _membershipEvents = {
'add',
'joinByLink',
'leave',
'remove',
};
void _applyMembershipControl(
int accountId,
int chatId,
CachedMessage message,
) {
final control = message.controlAttachment;
final event = control?.event;
if (control == null || event == null) return;
if (!_membershipEvents.contains(event)) return;
if (message.senderId != accountId) {
final affected = control.userIds?.length ?? 1;
ChatMembersStore.instance.adjust(chatId, switch (event) {
'add' => affected,
'joinByLink' => 1,
'leave' => -1,
'remove' => -affected,
_ => 0,
});
}
_refreshChatInfo(chatId);
}
void _refreshChatInfo(int chatId) {
ChatInfoFetch.invalidate(chatId);
unawaited(ChatInfoFetch.get(chatId));
}
Future<bool> _updateChat(
int accountId,
int chatId,
@@ -819,6 +855,7 @@ class ChatsModule {
final cached = CachedMessage.fromPushPayload(accountId, chatId, msg);
await AppDatabase.saveMessages([cached.toDbRow()]);
emittedMessage = cached;
_applyMembershipControl(accountId, chatId, cached);
_messageEventsController.add(MessageAddedEvent(chatId, cached));
}
}
@@ -1160,6 +1197,7 @@ class ChatsModule {
}) async {
final cachedAt = DateTime.now().millisecondsSinceEpoch;
final id = chat['id'];
ChatMembersStore.instance.applyChatPayload(chat);
Map<int, CachedChat> existing = const {};
Map<String, dynamic>? existingRow;
if (preloadedExisting != null) {
@@ -1260,6 +1298,7 @@ class ChatsModule {
final rows = <Map<String, dynamic>>[];
for (final c in chats.whereType<Map>()) {
final map = c.cast<dynamic, dynamic>();
ChatMembersStore.instance.applyChatPayload(map);
final parsed = parseChatRow(
map,
accountId,
@@ -1360,7 +1399,9 @@ class ChatsModule {
final payload = packet.payload as Map?;
final chats = payload?['chats'] as List?;
if (chats == null || chats.isEmpty) return null;
return Map<String, dynamic>.from(chats.first as Map);
final info = Map<String, dynamic>.from(chats.first as Map);
ChatMembersStore.instance.applyChatPayload(info);
return info;
}
Future<Map<int, int>> getReadMarks(Api api, int accountId, int chatId) async {
@@ -1461,6 +1502,8 @@ class ChatsModule {
throw const PacketError('Не удалось подписаться');
}
final count = chatMap['participantsCount'];
if (count is! int) ChatMembersStore.instance.adjust(cached.id, 1);
_refreshChatInfo(cached.id);
return (chat: cached, subscribersCount: count is int ? count : null);
}
@@ -1850,6 +1893,10 @@ class ChatsModule {
await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
}
}
if (chat is! Map || chat['participantsCount'] is! int) {
ChatMembersStore.instance.adjust(chatId, userIds.length);
}
_refreshChatInfo(chatId);
return true;
} on PacketError catch (e) {
logger.w('addMembers $chatId: ${e.message}');
-338
View File
@@ -1,338 +0,0 @@
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 'messages.dart';
sealed class MediaSendEvent {
final int chatId;
final String tempId;
final bool scheduled;
const MediaSendEvent({
required this.chatId,
required this.tempId,
required this.scheduled,
});
}
class MediaSendDone extends MediaSendEvent {
final CachedMessage? message;
final int? scheduledTime;
const MediaSendDone({
required super.chatId,
required super.tempId,
required super.scheduled,
this.message,
this.scheduledTime,
});
}
class MediaSendFailed extends MediaSendEvent {
const MediaSendFailed({
required super.chatId,
required super.tempId,
required super.scheduled,
});
}
class MediaSendService {
MediaSendService._();
static final MediaSendService instance = MediaSendService._();
final StreamController<MediaSendEvent> _events =
StreamController<MediaSendEvent>.broadcast();
static const int _historyLimit = 60;
static const int _photoConcurrency = 3;
static const int _photoAttempts = 3;
final Map<String, ValueNotifier<List<double>>> _progress = {};
final Map<int, List<CachedMessage>> _pending = {};
final Map<String, CachedMessage> _completed = {};
final Set<String> _failed = {};
Stream<MediaSendEvent> get events => _events.stream;
ValueListenable<List<double>>? progressFor(String tempId) =>
_progress[tempId];
List<CachedMessage> pendingFor(int chatId) =>
List<CachedMessage>.unmodifiable(_pending[chatId] ?? const []);
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(
accountId: accountId,
chatId: chatId,
tempId: tempId,
placeholder: placeholder,
scheduledTime: scheduledTime,
slots: jobs.length,
upload: (progress) async {
final tokens = await _uploadPhotos(jobs, progress);
if (tokens.any((t) => t == null)) return null;
return messagesModule.sendPhotoMessage(
chatId,
tokens.cast<String>(),
caption: caption.isEmpty ? null : caption,
scheduledTime: scheduledTime,
);
},
);
}
Future<void> sendVideo({
required int accountId,
required int chatId,
required String tempId,
required File file,
required String caption,
CachedMessage? placeholder,
int? scheduledTime,
}) {
return _run(
accountId: accountId,
chatId: chatId,
tempId: tempId,
placeholder: placeholder,
scheduledTime: scheduledTime,
slots: 1,
upload: (progress) async {
final info = await messagesModule.requestVideoUploadUrl();
if (info == null || info.url.isEmpty) return null;
final ok = await fileUploader.uploadVideoFile(
Uri.parse(info.url),
file,
onProgress: (sent, total) {
if (total > 0) {
progress.value = [(sent / total).clamp(0.0, 1.0)];
}
},
);
if (!ok) return null;
progress.value = const [1];
return messagesModule.sendVideoMessage(
chatId,
info.token,
caption: caption.isEmpty ? null : caption,
scheduledTime: scheduledTime,
);
},
);
}
Future<void> _run({
required int accountId,
required int chatId,
required String tempId,
required int slots,
required Future<Map<String, dynamic>?> Function(
ValueNotifier<List<double>> progress,
)
upload,
CachedMessage? placeholder,
int? scheduledTime,
}) async {
final scheduled = scheduledTime != null;
final progress = ValueNotifier<List<double>>(
List<double>.filled(slots < 1 ? 1 : slots, 0),
);
_progress[tempId] = progress;
if (placeholder != null) {
(_pending[chatId] ??= <CachedMessage>[]).add(placeholder);
}
try {
final serverMsg = await upload(progress);
if (serverMsg == null) throw Exception('send_failed');
if (scheduled) {
_finish(chatId, tempId);
_events.add(
MediaSendDone(
chatId: chatId,
tempId: tempId,
scheduled: true,
scheduledTime: scheduledTime,
),
);
return;
}
final real = CachedMessage.fromPushPayload(accountId, chatId, serverMsg);
try {
await AppDatabase.saveMessages([real.toDbRow()]);
if (real.id != tempId) {
await AppDatabase.deleteMessage(accountId, chatId, tempId);
}
} catch (e) {
logger.w('MediaSendService: не удалось сохранить сообщение: $e');
}
_replaceInSessionCache(accountId, chatId, tempId, real);
_remember(tempId, real);
_finish(chatId, tempId);
_events.add(
MediaSendDone(
chatId: chatId,
tempId: tempId,
scheduled: false,
message: real,
),
);
} catch (e) {
logger.w('MediaSendService: $e');
if (!scheduled) {
_replaceInSessionCache(accountId, chatId, tempId, null);
_remember(tempId, null);
}
_finish(chatId, tempId);
_events.add(
MediaSendFailed(chatId: chatId, tempId: tempId, scheduled: scheduled),
);
}
}
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,
ValueNotifier<List<double>> progress,
) 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, progress);
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}) job,
int index,
ValueNotifier<List<double>> progress,
) async {
File file;
try {
file = await optimizePhotoForUpload(job.file, item: job.item);
} catch (e) {
logger.w('optimize photo: $e');
file = job.file;
}
for (var attempt = 0; attempt < _photoAttempts; attempt++) {
if (attempt > 0) {
await Future.delayed(Duration(seconds: attempt));
_setSlot(progress, index, 0);
}
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) {
if (total <= 0) return;
_setSlot(progress, index, (sent / total).clamp(0.0, 1.0));
},
);
if (token != null) return token;
} catch (e) {
logger.w('uploadOnePhoto attempt ${attempt + 1}: $e');
}
}
return null;
}
void _setSlot(
ValueNotifier<List<double>> progress,
int index,
double value,
) {
final next = List<double>.from(progress.value);
if (index < next.length) {
next[index] = value;
progress.value = next;
}
}
String _photoFilename(File file) {
final segments = file.uri.pathSegments;
final name = segments.isNotEmpty ? segments.last : '';
return name.isNotEmpty ? name : 'photo.jpg';
}
void _finish(int chatId, String tempId) {
_progress.remove(tempId);
final list = _pending[chatId];
if (list == null) return;
list.removeWhere((m) => m.id == tempId);
if (list.isEmpty) _pending.remove(chatId);
}
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,
);
}
}
-103
View File
@@ -1,103 +0,0 @@
import 'dart:async';
import 'dart:io';
import '../../main.dart';
import 'cloud_storage.dart';
import 'file_uploader.dart';
import 'upload_notification_service.dart';
class UploadManager {
UploadManager._();
static final instance = UploadManager._();
StreamSubscription<UploadEvent>? _sub;
bool get isActive => _sub != null;
// UI callbacks — registered by the screen while it is mounted
void Function(double progress, int speedBps)? onProgress;
void Function(CloudFile file)? onDone;
void Function(String error)? onError;
Future<void> start({
required int chatId,
required int accountId,
required File file,
required String filename,
required int totalSize,
}) async {
await cancel(); // cancel any previous upload
await UploadNotificationService.start(filename);
var lastSentBytes = 0;
var lastSpeedMs = DateTime.now().millisecondsSinceEpoch;
var speedBps = 0;
var lastNotifPercent = -1;
_sub = fileUploader
.upload(
chatId: chatId,
file: file,
filename: filename,
totalSize: totalSize,
)
.listen(
(event) async {
switch (event) {
case UploadProgress(:final sent, :final total):
final progress = total > 0 ? sent / total : 0.0;
// Speed: recompute every 500 ms
final nowMs = DateTime.now().millisecondsSinceEpoch;
final elapsed = nowMs - lastSpeedMs;
if (elapsed >= 500) {
speedBps = ((sent - lastSentBytes) * 1000 / elapsed).round();
lastSentBytes = sent;
lastSpeedMs = nowMs;
}
onProgress?.call(progress, speedBps);
// Throttle notification to once per 1% change
final percent = total > 0 ? (sent * 100 ~/ total) : 0;
if (percent != lastNotifPercent) {
lastNotifPercent = percent;
UploadNotificationService.update(
filename: filename,
progressPercent: percent,
speedBps: speedBps,
);
}
case UploadDone(:final fileId):
_sub = null;
UploadNotificationService.stop();
final newest = await CloudStorageModule.fetchLatestFile(
messagesModule,
accountId,
chatId,
expectedFileId: fileId,
);
if (newest != null) {
onDone?.call(newest);
}
case UploadError(:final message):
_sub = null;
UploadNotificationService.stop();
onError?.call(message);
}
},
onError: (_) {
_sub = null;
UploadNotificationService.stop();
},
);
}
Future<void> cancel() async {
await _sub?.cancel();
_sub = null;
await UploadNotificationService.stop();
}
}
@@ -1,31 +1,192 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class UploadNotificationService {
static const _ch = MethodChannel('ru.komet.app/upload_service');
import '../../l10n/app_localizations.dart';
import '../../main.dart' show KometApp;
static Future<void> start(String filename) async {
if (!Platform.isAndroid) return;
try { await _ch.invokeMethod('start', {'filename': filename}); } catch (_) {}
enum UploadKind { photo, video, file }
class _NotificationJob {
_NotificationJob({required this.kind, required this.count, this.filename});
final UploadKind kind;
final int count;
final String? filename;
int sent = 0;
int total = 0;
double fraction = 0;
int speedBps = 0;
int _windowSent = 0;
int _windowAt = DateTime.now().millisecondsSinceEpoch;
void report(int sentBytes, int totalBytes, double jobFraction) {
sent = sentBytes;
total = totalBytes;
fraction = jobFraction.clamp(0.0, 1.0);
final now = DateTime.now().millisecondsSinceEpoch;
final elapsed = now - _windowAt;
if (elapsed < 500) return;
final delta = sent - _windowSent;
speedBps = delta <= 0 ? 0 : (delta * 1000 / elapsed).round();
_windowSent = sent;
_windowAt = now;
}
static Future<void> update({
required String filename,
required int progressPercent,
required int speedBps,
}) async {
if (!Platform.isAndroid) return;
String label(AppLocalizations l10n) {
final name = filename;
return switch (kind) {
UploadKind.photo => l10n.uploadNotificationPhotos(count),
UploadKind.video => l10n.uploadNotificationVideo,
UploadKind.file =>
name == null || name.isEmpty ? l10n.uploadNotificationFile : name,
};
}
}
class UploadNotificationService {
static const MethodChannel _channel = MethodChannel(
'ru.komet.app/upload_service',
);
static const int _minIntervalMs = 350;
static final Map<String, _NotificationJob> _jobs = {};
static bool _running = false;
static String? _lastTitle;
static String? _lastBody;
static int _lastPercent = -1;
static int _lastPushAt = 0;
static bool get _enabled =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
static void begin(
String id, {
required UploadKind kind,
int count = 1,
String? filename,
}) {
if (!_enabled) return;
_jobs[id] = _NotificationJob(
kind: kind,
count: count < 1 ? 1 : count,
filename: filename,
);
_push(force: true);
}
static void report(
String id, {
required int sent,
required int total,
required double fraction,
}) {
if (!_enabled) return;
final job = _jobs[id];
if (job == null) return;
job.report(sent, total, fraction);
_push();
}
static void end(String id) {
if (!_enabled) return;
if (_jobs.remove(id) == null) return;
if (_jobs.isEmpty) {
_stop();
return;
}
_push(force: true);
}
static void _stop() {
_running = false;
_lastTitle = null;
_lastBody = null;
_lastPercent = -1;
_lastPushAt = 0;
_invoke('stop', const <String, dynamic>{});
}
static void _push({bool force = false}) {
if (_jobs.isEmpty) return;
var sumSent = 0;
var sumTotal = 0;
var sumSpeed = 0;
var fractionSum = 0.0;
var sizesKnown = true;
for (final job in _jobs.values) {
sumSent += job.sent;
sumTotal += job.total;
sumSpeed += job.speedBps;
fractionSum += job.fraction;
if (job.total <= 0) sizesKnown = false;
}
final fraction = sizesKnown && sumTotal > 0
? sumSent / sumTotal
: fractionSum / _jobs.length;
final percent = (fraction * 100).round().clamp(0, 100);
final l10n = _localizations();
final title = _jobs.length == 1
? _jobs.values.first.label(l10n)
: l10n.uploadNotificationMultiple(_jobs.length);
final body = percent <= 0 && sumSpeed <= 0
? l10n.uploadNotificationPreparing
: sumSpeed > 0
? '$percent% · ${_formatSpeed(l10n, sumSpeed)}'
: '$percent%';
final now = DateTime.now().millisecondsSinceEpoch;
final changed =
title != _lastTitle || body != _lastBody || percent != _lastPercent;
if (!force && (!changed || now - _lastPushAt < _minIntervalMs)) return;
_lastTitle = title;
_lastBody = body;
_lastPercent = percent;
_lastPushAt = now;
final args = <String, dynamic>{
'title': title,
'body': body,
'progress': percent,
'indeterminate': percent <= 0 && sumSpeed <= 0,
};
if (_running) {
_invoke('update', args);
return;
}
_running = true;
_invoke('start', args);
}
static String _formatSpeed(AppLocalizations l10n, int bps) {
if (bps < 1024) return l10n.uploadSpeedBytes('$bps');
if (bps < 1024 * 1024) return l10n.uploadSpeedKb('${(bps / 1024).round()}');
return l10n.uploadSpeedMb((bps / (1024 * 1024)).toStringAsFixed(1));
}
static AppLocalizations _localizations() {
final context = KometApp.navigatorKey.currentContext;
if (context != null) {
final scoped = Localizations.of<AppLocalizations>(
context,
AppLocalizations,
);
if (scoped != null) return scoped;
}
final code = WidgetsBinding.instance.platformDispatcher.locale.languageCode;
return lookupAppLocalizations(Locale(code == 'ru' ? 'ru' : 'en'));
}
static Future<void> _invoke(String method, Map<String, dynamic> args) async {
try {
await _ch.invokeMethod('update', {
'filename': filename,
'progress': progressPercent,
'speed': speedBps,
});
await _channel.invokeMethod(method, args);
} catch (_) {}
}
static Future<void> stop() async {
if (!Platform.isAndroid) return;
try { await _ch.invokeMethod('stop'); } catch (_) {}
}
}
+590
View File
@@ -0,0 +1,590 @@
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> 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,
);
}
}