twonly-app/lib/src/services/stickers/sticker.service.dart
2026-10-06 22:52:57 +02:00

154 lines
4.8 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
class StickerService {
const StickerService._();
static const fallbackText = '🖼️';
static StickerData? decodeAdditional(Uint8List? bytes) {
if (bytes == null) return null;
try {
final additional = AdditionalMessageData.fromBuffer(bytes);
if (additional.type != AdditionalMessageData_Type.STICKER ||
!additional.hasSticker()) {
return null;
}
final sticker = additional.sticker;
if (sticker.version != 1 ||
sticker.webp.isEmpty ||
sticker.webp.length > 128 * 1024 ||
sticker.sha256.length != 32 ||
sticker.width < 1 ||
sticker.width > 300 ||
sticker.height < 1 ||
sticker.height > 300) {
return null;
}
final digest = sha256.convert(sticker.webp).bytes;
for (var index = 0; index < digest.length; index++) {
if (digest[index] != sticker.sha256[index]) return null;
}
return sticker;
} on Object {
return null;
}
}
static StickerData fromLocal(LocalSticker sticker) {
return StickerData(
version: 1,
webp: sticker.webp,
sha256: hex.decode(sticker.contentHash),
width: sticker.width,
height: sticker.height,
);
}
static Uint8List encodeAdditional(LocalSticker sticker) {
return AdditionalMessageData(
type: AdditionalMessageData_Type.STICKER,
sticker: fromLocal(sticker),
).writeToBuffer();
}
static Future<LocalSticker> createFromPath(String imagePath) async {
final output = await RustApi.createSticker(imagePath: imagePath);
await twonlyDB.stickersDao.save(
webp: output.webp,
width: output.width,
height: output.height,
expectedHash: output.contentHash,
);
final sticker = await twonlyDB.stickersDao.getByHash(output.contentHash);
if (sticker == null) {
throw StateError('created sticker was not stored');
}
return sticker;
}
/// Creates a sticker from encoded image bytes without making the caller
/// manage the temporary source file required by the native sticker model.
static Future<LocalSticker> createFromBytes(Uint8List imageBytes) async {
final source = File(
'${AppEnvironment.cacheDir}/sticker-source-'
'${DateTime.now().microsecondsSinceEpoch}.png',
);
try {
await source.parent.create(recursive: true);
await source.writeAsBytes(imageBytes, flush: true);
return await createFromPath(source.path);
} finally {
if (source.existsSync()) source.deleteSync();
}
}
/// Finds the best full-resolution local source for a stored memory. A
/// cloud-only memory is downloaded before its path is returned.
static Future<String?> resolveMediaPath(MediaFileService media) async {
File? readableSource() {
for (final file in [
media.storedPath,
media.tempPath,
media.originalPath,
]) {
final stat = file.statSync();
if (stat.type != FileSystemEntityType.notFound && stat.size > 0) {
return file;
}
}
return null;
}
var source = readableSource();
if (source == null && media.mediaFile.cloudState == CloudState.uploaded) {
final downloaded = await MemoriesCloudService.downloadFromCloud(
media,
isThumbnail: false,
);
if (downloaded) source = readableSource();
}
return source?.path;
}
static Future<LocalSticker> createFromMedia(MediaFileService media) async {
final path = await resolveMediaPath(media);
if (path == null) {
throw StateError('sticker source media is unavailable');
}
return createFromPath(path);
}
static Future<bool> saveReceived(StickerData sticker) {
return twonlyDB.stickersDao.save(
webp: Uint8List.fromList(sticker.webp),
width: sticker.width,
height: sticker.height,
expectedHash: hex.encode(sticker.sha256),
);
}
static Future<void> send({
required String groupId,
required LocalSticker sticker,
String? quoteMessageId,
}) async {
await RustApi.insertAndSendText(
groupId: groupId,
text: fallbackText,
quoteMessageId: quoteMessageId,
additionalMessageData: encodeAdditional(sticker),
);
await twonlyDB.stickersDao.recordUse(sticker.contentHash);
}
}