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

548 lines
14 KiB
Dart

import 'dart:async';
import 'dart:io';
import '../../core/media/gallery_source.dart';
import '../../core/media/share_thumbnail.dart';
import '../../core/media/video_transcoder.dart';
import '../../core/storage/app_database.dart';
import '../../core/utils/logger.dart';
import '../../models/attachment.dart';
import '../../models/chat_preview_media.dart';
import '../../models/shared_payload.dart';
import '../../main.dart';
import 'chats.dart';
import 'messages.dart';
import 'upload_service.dart';
class PreparedShareFile {
final SharedFile source;
final String? thumbDataUri;
final int? width;
final int? height;
final int? durationMs;
const PreparedShareFile({
required this.source,
this.thumbDataUri,
this.width,
this.height,
this.durationMs,
});
SharedFileKind get kind => source.kind;
File get file => source.file;
}
class PreparedShare {
final List<PreparedShareFile> files;
final String? text;
const PreparedShare({required this.files, this.text});
List<PreparedShareFile> get photos =>
files.where((f) => f.kind == SharedFileKind.photo).toList();
List<PreparedShareFile> get videos =>
files.where((f) => f.kind == SharedFileKind.video).toList();
List<PreparedShareFile> get documents =>
files.where((f) => f.kind == SharedFileKind.file).toList();
bool get isTextOnly => files.isEmpty;
static Future<PreparedShare> prepare(SharedPayload payload) async {
final prepared = <PreparedShareFile>[];
for (final source in payload.files) {
prepared.add(await _prepareOne(source));
}
return PreparedShare(files: prepared, text: payload.text);
}
static Future<PreparedShareFile> _prepareOne(SharedFile source) async {
final thumb = await sharedThumbnailDataUri(source);
switch (source.kind) {
case SharedFileKind.photo:
final dim = await imageFileDimensions(source.file);
return PreparedShareFile(
source: source,
thumbDataUri: thumb,
width: dim?.$1,
height: dim?.$2,
);
case SharedFileKind.video:
VideoInfo? info;
try {
info = await VideoTranscoder.probe(source.path);
} catch (e) {
logger.w('Поделиться: probe ${source.path}: $e');
}
return PreparedShareFile(
source: source,
thumbDataUri: thumb,
width: (info?.width ?? 0) > 0 ? info!.width : null,
height: (info?.height ?? 0) > 0 ? info!.height : null,
durationMs: (info?.durationMs ?? 0) > 0 ? info!.durationMs : null,
);
case SharedFileKind.file:
return PreparedShareFile(source: source);
}
}
}
class ShareSendResult {
final int chatCount;
final int messageCount;
const ShareSendResult({required this.chatCount, required this.messageCount});
}
class ShareSender {
ShareSender._();
static final Map<
String,
({int accountId, int chatId, String text, String? preview, int time})
>
_tracked = {};
static StreamSubscription<UploadJobEvent>? _sub;
static void _track(
String tempId, {
required int accountId,
required int chatId,
required String text,
required String? preview,
required int time,
}) {
_sub ??= UploadService.instance.events.listen(_onUploadEvent);
_tracked[tempId] = (
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: time,
);
}
static void _onUploadEvent(UploadJobEvent event) {
final entry = _tracked.remove(event.tempId);
if (entry == null) return;
final status = event is UploadJobDone ? 'sent' : 'error';
final messageId = event is UploadJobDone
? (event.message?.id ?? event.tempId)
: event.tempId;
unawaited(
chats
.applyOutgoing(
entry.accountId,
entry.chatId,
messageId: messageId,
time: entry.time,
text: entry.text,
status: status,
preview: entry.preview,
)
.catchError((Object e) {
logger.w('Поделиться: не обновить превью чата ${entry.chatId}: $e');
}),
);
}
static Future<ShareSendResult> send({
required int accountId,
required List<int> chatIds,
required PreparedShare share,
required String caption,
}) async {
var messages = 0;
for (final chatId in chatIds) {
messages += await _sendToChat(
accountId: accountId,
chatId: chatId,
share: share,
caption: caption,
);
}
logger.i(
'Поделиться: отправлено $messages сообщений в ${chatIds.length} чатов',
);
return ShareSendResult(chatCount: chatIds.length, messageCount: messages);
}
static Future<int> _sendToChat({
required int accountId,
required int chatId,
required PreparedShare share,
required String caption,
}) async {
if (share.isTextOnly) {
final text = caption.trim().isNotEmpty
? caption
: (share.text ?? '').trim();
if (text.isEmpty) return 0;
await _sendText(accountId: accountId, chatId: chatId, text: text);
return 1;
}
final photos = share.photos;
final videos = share.videos;
final documents = share.documents;
var used = false;
String take() {
if (used || caption.isEmpty) return '';
used = true;
return caption;
}
var count = 0;
if (photos.isNotEmpty) {
await _sendPhotos(
accountId: accountId,
chatId: chatId,
photos: photos,
caption: take(),
);
count++;
}
for (final video in videos) {
await _sendVideo(
accountId: accountId,
chatId: chatId,
video: video,
caption: take(),
);
count++;
}
for (final document in documents) {
await _sendDocument(
accountId: accountId,
chatId: chatId,
document: document,
caption: take(),
);
count++;
}
return count;
}
static Future<void> _sendText({
required int accountId,
required int chatId,
required String text,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: text,
time: now,
status: 'sending',
);
await _persist(placeholder);
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: null,
status: 'sending',
);
String realId = tempId;
var status = 'sent';
try {
final sent = await messagesModule.sendMessage(accountId, chatId, text);
if (sent.isNotEmpty) realId = sent;
} catch (e) {
logger.w('Поделиться: текст в $chatId не ушёл: $e');
status = 'error';
}
final settled = CachedMessage(
id: realId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: text,
time: now,
status: status,
);
await _persist(settled, removeId: realId == tempId ? null : tempId);
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: realId,
time: now,
text: text,
preview: null,
status: status,
);
}
static Future<void> _sendPhotos({
required int accountId,
required int chatId,
required List<PreparedShareFile> photos,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final jobs = <({File file, GalleryItem? item})>[];
final attachments = <PhotoAttachment>[];
for (final photo in photos) {
jobs.add((file: photo.file, item: null));
attachments.add(
PhotoAttachment(
localPath: photo.file.path,
previewData: photo.thumbDataUri,
width: photo.width,
height: photo.height,
),
);
}
if (jobs.isEmpty) return;
final label = photos.length > 1 ? 'Изображения' : 'Изображение';
final preview = _preview(
kind: ChatPreviewKind.photo,
files: photos,
label: caption.isEmpty ? label : null,
);
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: attachments,
);
await _persist(placeholder);
final text = caption.isEmpty ? label : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
UploadService.instance.sendPhotos(
accountId: accountId,
chatId: chatId,
tempId: tempId,
jobs: jobs,
caption: caption,
placeholder: placeholder,
),
);
}
static Future<void> _sendVideo({
required int accountId,
required int chatId,
required PreparedShareFile video,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final preview = _preview(
kind: ChatPreviewKind.video,
files: [video],
label: caption.isEmpty ? 'Видео' : null,
);
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: [
VideoAttachment(
localPath: video.file.path,
previewData: video.thumbDataUri,
width: video.width,
height: video.height,
duration: video.durationMs,
),
],
);
await _persist(placeholder);
final text = caption.isEmpty ? 'Видео' : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
UploadService.instance.sendVideo(
accountId: accountId,
chatId: chatId,
tempId: tempId,
file: video.file,
caption: caption,
placeholder: placeholder,
),
);
}
static Future<void> _sendDocument({
required int accountId,
required int chatId,
required PreparedShareFile document,
required String caption,
}) async {
final now = DateTime.now().millisecondsSinceEpoch;
final tempId = UploadService.instance.newTempId();
final name = document.source.name;
final size = document.source.size;
final preview = ChatPreviewMedia(
kind: ChatPreviewKind.file,
label: caption.isEmpty ? 'Файл' : null,
detail: caption.isEmpty ? name : null,
).encode();
final placeholder = CachedMessage(
id: tempId,
accountId: accountId,
chatId: chatId,
senderId: accountId,
text: caption.isEmpty ? null : caption,
time: now,
status: 'sending',
attachments: [FileAttachment(name: name, size: size)],
);
await _persist(placeholder);
final text = caption.isEmpty ? 'Файл: $name' : caption;
await _bumpChat(
accountId: accountId,
chatId: chatId,
messageId: tempId,
time: now,
text: text,
preview: preview,
status: 'sending',
);
_track(
tempId,
accountId: accountId,
chatId: chatId,
text: text,
preview: preview,
time: now,
);
unawaited(
UploadService.instance.sendFile(
accountId: accountId,
chatId: chatId,
tempId: tempId,
source: document.file,
filename: name,
size: size,
placeholder: placeholder,
),
);
}
static String? _preview({
required ChatPreviewKind kind,
required List<PreparedShareFile> files,
String? label,
}) {
final thumbs = <ChatPreviewThumb>[];
for (final file in files) {
final data = file.thumbDataUri;
if (data == null) continue;
thumbs.add(
ChatPreviewThumb(
source: data,
video: file.kind == SharedFileKind.video,
),
);
if (thumbs.length >= 3) break;
}
if (thumbs.isEmpty && label == null) return null;
return ChatPreviewMedia(kind: kind, thumbs: thumbs, label: label).encode();
}
static Future<void> _persist(
CachedMessage message, {
String? removeId,
}) async {
try {
if (removeId != null && removeId != message.id) {
await AppDatabase.deleteMessage(
message.accountId,
message.chatId,
removeId,
);
}
await AppDatabase.saveMessages([message.toDbRow()]);
} catch (e) {
logger.w('Поделиться: не сохранить плейсхолдер: $e');
}
}
static Future<void> _bumpChat({
required int accountId,
required int chatId,
required String messageId,
required int time,
required String text,
required String? preview,
required String status,
}) async {
try {
await chats.applyOutgoing(
accountId,
chatId,
messageId: messageId,
time: time,
text: text,
status: status,
preview: preview,
);
} catch (e) {
logger.w('Поделиться: не обновить строку чата $chatId: $e');
}
}
}