sticker feature with onnx model

This commit is contained in:
otsmr 2026-10-05 10:35:48 +02:00
parent ce14681276
commit b339bbf923
64 changed files with 4445 additions and 396 deletions

201
assets/licenses/u2net.txt Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -6,6 +6,7 @@
import '../api/server/prekeys.dart';
import '../frb_generated.dart';
import '../services/media_upload.dart';
import '../services/stickers.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'stories.dart';
@ -346,6 +347,14 @@ class RustApi {
static Future<ApiConnectionState> connectionState() =>
RustLib.instance.api.crateBridgeApiRustApiConnectionState();
/// Creates a transparent, message-sized sticker from a local image. Model
/// inference and image encoding are CPU-heavy, so neither runs on Tokio's
/// async worker threads.
static Future<StickerOutput> createSticker({required String imagePath}) =>
RustLib.instance.api.crateBridgeApiRustApiCreateSticker(
imagePath: imagePath,
);
/// Trims fully transparent borders an editor left around a stored image and
/// refreshes the preview and content hash derived from it.
static Future<void> cropMediaTransparentBorders({required String mediaId}) =>

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@ import 'keys/backup_password_keys.dart';
import 'lib.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
import 'services/media_upload.dart';
import 'services/stickers.dart';
import 'signal/engine.dart';
import 'user_config.dart';
@ -383,6 +384,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
SqlValue dco_decode_sql_value(dynamic raw);
@protected
StickerOutput dco_decode_sticker_output(dynamic raw);
@protected
StoryAudience dco_decode_story_audience(dynamic raw);
@ -847,6 +851,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
@protected
StickerOutput sse_decode_sticker_output(SseDeserializer deserializer);
@protected
StoryAudience sse_decode_story_audience(SseDeserializer deserializer);
@ -1432,6 +1439,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
@protected
void sse_encode_sticker_output(StickerOutput self, SseSerializer serializer);
@protected
void sse_encode_story_audience(StoryAudience self, SseSerializer serializer);

View file

@ -29,6 +29,7 @@ import 'keys/backup_password_keys.dart';
import 'lib.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
import 'services/media_upload.dart';
import 'services/stickers.dart';
import 'signal/engine.dart';
import 'user_config.dart';
@ -385,6 +386,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
SqlValue dco_decode_sql_value(dynamic raw);
@protected
StickerOutput dco_decode_sticker_output(dynamic raw);
@protected
StoryAudience dco_decode_story_audience(dynamic raw);
@ -849,6 +853,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
@protected
StickerOutput sse_decode_sticker_output(SseDeserializer deserializer);
@protected
StoryAudience sse_decode_story_audience(SseDeserializer deserializer);
@ -1434,6 +1441,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
@protected
void sse_encode_sticker_output(StickerOutput self, SseSerializer serializer);
@protected
void sse_encode_story_audience(StoryAudience self, SseSerializer serializer);

View file

@ -0,0 +1,42 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
class StickerOutput {
final Uint8List webp;
final Uint8List sha256;
final String contentHash;
final int width;
final int height;
const StickerOutput({
required this.webp,
required this.sha256,
required this.contentHash,
required this.width,
required this.height,
});
@override
int get hashCode =>
webp.hashCode ^
sha256.hashCode ^
contentHash.hashCode ^
width.hashCode ^
height.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is StickerOutput &&
runtimeType == other.runtimeType &&
webp == other.webp &&
sha256 == other.sha256 &&
contentHash == other.contentHash &&
width == other.width &&
height == other.height;
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mutex/mutex.dart';
@ -94,6 +95,12 @@ class StartupResult {
void main() {
SentryWidgetsFlutterBinding.ensureInitialized();
LicenseRegistry.addLicense(() async* {
yield LicenseEntryWithLineBreaks(
['U²-Net-P'],
await rootBundle.loadString('assets/licenses/u2net.txt'),
);
});
// `App` used to set this as it mounted, which was at the same moment the
// engine started. It now mounts only once startup finishes, and the API can

View file

@ -401,6 +401,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
isDeletedFromSender: Value(true),
content: Value(null),
mediaId: Value(null),
additionalMessageData: Value(null),
),
);
}

View file

@ -0,0 +1,81 @@
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:drift/drift.dart';
import 'package:twonly/src/database/tables/stickers.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
part 'stickers.dao.g.dart';
@DriftAccessor(tables: [Stickers])
class StickersDao extends DatabaseAccessor<TwonlyDB> with _$StickersDaoMixin {
// ignore: matching_super_parameters
StickersDao(super.db);
Stream<List<LocalSticker>> watchAll() {
return (select(stickers)..orderBy([
(sticker) => OrderingTerm.desc(sticker.usageCount),
(sticker) => OrderingTerm.desc(sticker.lastUsedAt),
(sticker) => OrderingTerm.desc(sticker.createdAt),
]))
.watch();
}
Future<LocalSticker?> getByHash(String contentHash) {
return (select(stickers)
..where((sticker) => sticker.contentHash.equals(contentHash)))
.getSingleOrNull();
}
/// Saves verified sticker bytes. The digest is always recomputed here so a
/// malformed received message can never create a duplicate under a false id.
Future<bool> save({
required Uint8List webp,
required int width,
required int height,
String? expectedHash,
}) async {
if (webp.isEmpty || webp.length > 128 * 1024) {
throw ArgumentError.value(webp.length, 'webp', 'invalid sticker size');
}
if (width < 1 || width > 300 || height < 1 || height > 300) {
throw ArgumentError('invalid sticker dimensions: ${width}x$height');
}
final contentHash = hex.encode(sha256.convert(webp).bytes);
if (expectedHash != null && expectedHash != contentHash) {
throw ArgumentError('sticker digest does not match its bytes');
}
final now = DateTime.now();
return transaction(() async {
if (await getByHash(contentHash) != null) return false;
await into(stickers).insert(
StickersCompanion.insert(
contentHash: contentHash,
webp: webp,
width: width,
height: height,
createdAt: Value(now),
lastUsedAt: Value(now),
),
);
return true;
});
}
Future<void> recordUse(String contentHash) async {
await customUpdate(
'UPDATE stickers SET usage_count = usage_count + 1, '
'last_used_at = ? WHERE content_hash = ?',
variables: [
Variable<DateTime>(DateTime.now()),
Variable<String>(contentHash),
],
updates: {stickers},
);
}
Future<void> remove(String contentHash) {
return (delete(
stickers,
)..where((sticker) => sticker.contentHash.equals(contentHash))).go();
}
}

View file

@ -0,0 +1,16 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'stickers.dao.dart';
// ignore_for_file: type=lint
mixin _$StickersDaoMixin on DatabaseAccessor<TwonlyDB> {
$StickersTable get stickers => attachedDatabase.stickers;
StickersDaoManager get managers => StickersDaoManager(this);
}
class StickersDaoManager {
final _$StickersDaoMixin _db;
StickersDaoManager(this._db);
$$StickersTableTableManager get stickers =>
$$StickersTableTableManager(_db.attachedDatabase, _db.stickers);
}

View file

@ -0,0 +1,15 @@
import 'package:drift/drift.dart';
@DataClassName('LocalSticker')
class Stickers extends Table {
TextColumn get contentHash => text().withLength(min: 64, max: 64)();
BlobColumn get webp => blob()();
IntColumn get width => integer()();
IntColumn get height => integer()();
IntColumn get usageCount => integer().withDefault(const Constant(0))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get lastUsedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {contentHash};
}

View file

@ -9,6 +9,7 @@ import 'package:twonly/src/database/daos/mediafiles.dao.dart';
import 'package:twonly/src/database/daos/messages.dao.dart';
import 'package:twonly/src/database/daos/reactions.dao.dart';
import 'package:twonly/src/database/daos/receipts.dao.dart';
import 'package:twonly/src/database/daos/stickers.dao.dart';
import 'package:twonly/src/database/daos/stories.dao.dart';
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/rust_change_notifier.dart';
@ -20,6 +21,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/tables/reactions.table.dart';
import 'package:twonly/src/database/tables/receipts.table.dart';
import 'package:twonly/src/database/tables/stickers.table.dart';
import 'package:twonly/src/database/tables/user_discovery.table.dart';
import 'package:twonly/src/database/tables/webxdc.table.dart';
import 'package:twonly/src/database/twonly.db.steps.dart';
@ -53,6 +55,7 @@ part 'twonly.db.g.dart';
WebxdcApps,
WebxdcInstances,
WebxdcUpdates,
Stickers,
],
daos: [
MessagesDao,
@ -65,6 +68,7 @@ part 'twonly.db.g.dart';
KeyVerificationDao,
ContactGroupsDao,
StoriesDao,
StickersDao,
],
)
class TwonlyDB extends _$TwonlyDB {

View file

@ -14969,6 +14969,479 @@ class WebxdcUpdatesCompanion extends UpdateCompanion<WebxdcUpdate> {
}
}
class $StickersTable extends Stickers
with TableInfo<$StickersTable, LocalSticker> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$StickersTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _contentHashMeta = const VerificationMeta(
'contentHash',
);
@override
late final GeneratedColumn<String> contentHash = GeneratedColumn<String>(
'content_hash',
aliasedName,
false,
additionalChecks: GeneratedColumn.checkTextLength(
minTextLength: 64,
maxTextLength: 64,
),
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _webpMeta = const VerificationMeta('webp');
@override
late final GeneratedColumn<Uint8List> webp = GeneratedColumn<Uint8List>(
'webp',
aliasedName,
false,
type: DriftSqlType.blob,
requiredDuringInsert: true,
);
static const VerificationMeta _widthMeta = const VerificationMeta('width');
@override
late final GeneratedColumn<int> width = GeneratedColumn<int>(
'width',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _heightMeta = const VerificationMeta('height');
@override
late final GeneratedColumn<int> height = GeneratedColumn<int>(
'height',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _usageCountMeta = const VerificationMeta(
'usageCount',
);
@override
late final GeneratedColumn<int> usageCount = GeneratedColumn<int>(
'usage_count',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime,
);
static const VerificationMeta _lastUsedAtMeta = const VerificationMeta(
'lastUsedAt',
);
@override
late final GeneratedColumn<DateTime> lastUsedAt = GeneratedColumn<DateTime>(
'last_used_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime,
);
@override
List<GeneratedColumn> get $columns => [
contentHash,
webp,
width,
height,
usageCount,
createdAt,
lastUsedAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'stickers';
@override
VerificationContext validateIntegrity(
Insertable<LocalSticker> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('content_hash')) {
context.handle(
_contentHashMeta,
contentHash.isAcceptableOrUnknown(
data['content_hash']!,
_contentHashMeta,
),
);
} else if (isInserting) {
context.missing(_contentHashMeta);
}
if (data.containsKey('webp')) {
context.handle(
_webpMeta,
webp.isAcceptableOrUnknown(data['webp']!, _webpMeta),
);
} else if (isInserting) {
context.missing(_webpMeta);
}
if (data.containsKey('width')) {
context.handle(
_widthMeta,
width.isAcceptableOrUnknown(data['width']!, _widthMeta),
);
} else if (isInserting) {
context.missing(_widthMeta);
}
if (data.containsKey('height')) {
context.handle(
_heightMeta,
height.isAcceptableOrUnknown(data['height']!, _heightMeta),
);
} else if (isInserting) {
context.missing(_heightMeta);
}
if (data.containsKey('usage_count')) {
context.handle(
_usageCountMeta,
usageCount.isAcceptableOrUnknown(data['usage_count']!, _usageCountMeta),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
}
if (data.containsKey('last_used_at')) {
context.handle(
_lastUsedAtMeta,
lastUsedAt.isAcceptableOrUnknown(
data['last_used_at']!,
_lastUsedAtMeta,
),
);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {contentHash};
@override
LocalSticker map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return LocalSticker(
contentHash: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}content_hash'],
)!,
webp: attachedDatabase.typeMapping.read(
DriftSqlType.blob,
data['${effectivePrefix}webp'],
)!,
width: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}width'],
)!,
height: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}height'],
)!,
usageCount: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}usage_count'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
)!,
lastUsedAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}last_used_at'],
)!,
);
}
@override
$StickersTable createAlias(String alias) {
return $StickersTable(attachedDatabase, alias);
}
}
class LocalSticker extends DataClass implements Insertable<LocalSticker> {
final String contentHash;
final Uint8List webp;
final int width;
final int height;
final int usageCount;
final DateTime createdAt;
final DateTime lastUsedAt;
const LocalSticker({
required this.contentHash,
required this.webp,
required this.width,
required this.height,
required this.usageCount,
required this.createdAt,
required this.lastUsedAt,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['content_hash'] = Variable<String>(contentHash);
map['webp'] = Variable<Uint8List>(webp);
map['width'] = Variable<int>(width);
map['height'] = Variable<int>(height);
map['usage_count'] = Variable<int>(usageCount);
map['created_at'] = Variable<DateTime>(createdAt);
map['last_used_at'] = Variable<DateTime>(lastUsedAt);
return map;
}
StickersCompanion toCompanion(bool nullToAbsent) {
return StickersCompanion(
contentHash: Value(contentHash),
webp: Value(webp),
width: Value(width),
height: Value(height),
usageCount: Value(usageCount),
createdAt: Value(createdAt),
lastUsedAt: Value(lastUsedAt),
);
}
factory LocalSticker.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return LocalSticker(
contentHash: serializer.fromJson<String>(json['contentHash']),
webp: serializer.fromJson<Uint8List>(json['webp']),
width: serializer.fromJson<int>(json['width']),
height: serializer.fromJson<int>(json['height']),
usageCount: serializer.fromJson<int>(json['usageCount']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
lastUsedAt: serializer.fromJson<DateTime>(json['lastUsedAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'contentHash': serializer.toJson<String>(contentHash),
'webp': serializer.toJson<Uint8List>(webp),
'width': serializer.toJson<int>(width),
'height': serializer.toJson<int>(height),
'usageCount': serializer.toJson<int>(usageCount),
'createdAt': serializer.toJson<DateTime>(createdAt),
'lastUsedAt': serializer.toJson<DateTime>(lastUsedAt),
};
}
LocalSticker copyWith({
String? contentHash,
Uint8List? webp,
int? width,
int? height,
int? usageCount,
DateTime? createdAt,
DateTime? lastUsedAt,
}) => LocalSticker(
contentHash: contentHash ?? this.contentHash,
webp: webp ?? this.webp,
width: width ?? this.width,
height: height ?? this.height,
usageCount: usageCount ?? this.usageCount,
createdAt: createdAt ?? this.createdAt,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
);
LocalSticker copyWithCompanion(StickersCompanion data) {
return LocalSticker(
contentHash: data.contentHash.present
? data.contentHash.value
: this.contentHash,
webp: data.webp.present ? data.webp.value : this.webp,
width: data.width.present ? data.width.value : this.width,
height: data.height.present ? data.height.value : this.height,
usageCount: data.usageCount.present
? data.usageCount.value
: this.usageCount,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
lastUsedAt: data.lastUsedAt.present
? data.lastUsedAt.value
: this.lastUsedAt,
);
}
@override
String toString() {
return (StringBuffer('LocalSticker(')
..write('contentHash: $contentHash, ')
..write('webp: $webp, ')
..write('width: $width, ')
..write('height: $height, ')
..write('usageCount: $usageCount, ')
..write('createdAt: $createdAt, ')
..write('lastUsedAt: $lastUsedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
contentHash,
$driftBlobEquality.hash(webp),
width,
height,
usageCount,
createdAt,
lastUsedAt,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is LocalSticker &&
other.contentHash == this.contentHash &&
$driftBlobEquality.equals(other.webp, this.webp) &&
other.width == this.width &&
other.height == this.height &&
other.usageCount == this.usageCount &&
other.createdAt == this.createdAt &&
other.lastUsedAt == this.lastUsedAt);
}
class StickersCompanion extends UpdateCompanion<LocalSticker> {
final Value<String> contentHash;
final Value<Uint8List> webp;
final Value<int> width;
final Value<int> height;
final Value<int> usageCount;
final Value<DateTime> createdAt;
final Value<DateTime> lastUsedAt;
final Value<int> rowid;
const StickersCompanion({
this.contentHash = const Value.absent(),
this.webp = const Value.absent(),
this.width = const Value.absent(),
this.height = const Value.absent(),
this.usageCount = const Value.absent(),
this.createdAt = const Value.absent(),
this.lastUsedAt = const Value.absent(),
this.rowid = const Value.absent(),
});
StickersCompanion.insert({
required String contentHash,
required Uint8List webp,
required int width,
required int height,
this.usageCount = const Value.absent(),
this.createdAt = const Value.absent(),
this.lastUsedAt = const Value.absent(),
this.rowid = const Value.absent(),
}) : contentHash = Value(contentHash),
webp = Value(webp),
width = Value(width),
height = Value(height);
static Insertable<LocalSticker> custom({
Expression<String>? contentHash,
Expression<Uint8List>? webp,
Expression<int>? width,
Expression<int>? height,
Expression<int>? usageCount,
Expression<DateTime>? createdAt,
Expression<DateTime>? lastUsedAt,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (contentHash != null) 'content_hash': contentHash,
if (webp != null) 'webp': webp,
if (width != null) 'width': width,
if (height != null) 'height': height,
if (usageCount != null) 'usage_count': usageCount,
if (createdAt != null) 'created_at': createdAt,
if (lastUsedAt != null) 'last_used_at': lastUsedAt,
if (rowid != null) 'rowid': rowid,
});
}
StickersCompanion copyWith({
Value<String>? contentHash,
Value<Uint8List>? webp,
Value<int>? width,
Value<int>? height,
Value<int>? usageCount,
Value<DateTime>? createdAt,
Value<DateTime>? lastUsedAt,
Value<int>? rowid,
}) {
return StickersCompanion(
contentHash: contentHash ?? this.contentHash,
webp: webp ?? this.webp,
width: width ?? this.width,
height: height ?? this.height,
usageCount: usageCount ?? this.usageCount,
createdAt: createdAt ?? this.createdAt,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (contentHash.present) {
map['content_hash'] = Variable<String>(contentHash.value);
}
if (webp.present) {
map['webp'] = Variable<Uint8List>(webp.value);
}
if (width.present) {
map['width'] = Variable<int>(width.value);
}
if (height.present) {
map['height'] = Variable<int>(height.value);
}
if (usageCount.present) {
map['usage_count'] = Variable<int>(usageCount.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (lastUsedAt.present) {
map['last_used_at'] = Variable<DateTime>(lastUsedAt.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('StickersCompanion(')
..write('contentHash: $contentHash, ')
..write('webp: $webp, ')
..write('width: $width, ')
..write('height: $height, ')
..write('usageCount: $usageCount, ')
..write('createdAt: $createdAt, ')
..write('lastUsedAt: $lastUsedAt, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
abstract class _$TwonlyDB extends GeneratedDatabase {
_$TwonlyDB(QueryExecutor e) : super(e);
$TwonlyDBManager get managers => $TwonlyDBManager(this);
@ -15010,6 +15483,7 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
this,
);
late final $WebxdcUpdatesTable webxdcUpdates = $WebxdcUpdatesTable(this);
late final $StickersTable stickers = $StickersTable(this);
late final Index idxMessagesGroupIdCreatedAt = Index(
'idx_messages_group_id_created_at',
'CREATE INDEX idx_messages_group_id_created_at ON messages (group_id, created_at)',
@ -15034,6 +15508,7 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
this as TwonlyDB,
);
late final StoriesDao storiesDao = StoriesDao(this as TwonlyDB);
late final StickersDao stickersDao = StickersDao(this as TwonlyDB);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@ -15062,6 +15537,7 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
webxdcApps,
webxdcInstances,
webxdcUpdates,
stickers,
idxMessagesGroupIdCreatedAt,
idxReceiptsMessageId,
];
@ -27736,6 +28212,256 @@ typedef $$WebxdcUpdatesTableProcessedTableManager =
WebxdcUpdate,
PrefetchHooks Function()
>;
typedef $$StickersTableCreateCompanionBuilder =
StickersCompanion Function({
required String contentHash,
required Uint8List webp,
required int width,
required int height,
Value<int> usageCount,
Value<DateTime> createdAt,
Value<DateTime> lastUsedAt,
Value<int> rowid,
});
typedef $$StickersTableUpdateCompanionBuilder =
StickersCompanion Function({
Value<String> contentHash,
Value<Uint8List> webp,
Value<int> width,
Value<int> height,
Value<int> usageCount,
Value<DateTime> createdAt,
Value<DateTime> lastUsedAt,
Value<int> rowid,
});
class $$StickersTableFilterComposer
extends Composer<_$TwonlyDB, $StickersTable> {
$$StickersTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get contentHash => $composableBuilder(
column: $table.contentHash,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<Uint8List> get webp => $composableBuilder(
column: $table.webp,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get width => $composableBuilder(
column: $table.width,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get height => $composableBuilder(
column: $table.height,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get usageCount => $composableBuilder(
column: $table.usageCount,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get lastUsedAt => $composableBuilder(
column: $table.lastUsedAt,
builder: (column) => ColumnFilters(column),
);
}
class $$StickersTableOrderingComposer
extends Composer<_$TwonlyDB, $StickersTable> {
$$StickersTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get contentHash => $composableBuilder(
column: $table.contentHash,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<Uint8List> get webp => $composableBuilder(
column: $table.webp,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get width => $composableBuilder(
column: $table.width,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get height => $composableBuilder(
column: $table.height,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get usageCount => $composableBuilder(
column: $table.usageCount,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get lastUsedAt => $composableBuilder(
column: $table.lastUsedAt,
builder: (column) => ColumnOrderings(column),
);
}
class $$StickersTableAnnotationComposer
extends Composer<_$TwonlyDB, $StickersTable> {
$$StickersTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get contentHash => $composableBuilder(
column: $table.contentHash,
builder: (column) => column,
);
GeneratedColumn<Uint8List> get webp =>
$composableBuilder(column: $table.webp, builder: (column) => column);
GeneratedColumn<int> get width =>
$composableBuilder(column: $table.width, builder: (column) => column);
GeneratedColumn<int> get height =>
$composableBuilder(column: $table.height, builder: (column) => column);
GeneratedColumn<int> get usageCount => $composableBuilder(
column: $table.usageCount,
builder: (column) => column,
);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<DateTime> get lastUsedAt => $composableBuilder(
column: $table.lastUsedAt,
builder: (column) => column,
);
}
class $$StickersTableTableManager
extends
RootTableManager<
_$TwonlyDB,
$StickersTable,
LocalSticker,
$$StickersTableFilterComposer,
$$StickersTableOrderingComposer,
$$StickersTableAnnotationComposer,
$$StickersTableCreateCompanionBuilder,
$$StickersTableUpdateCompanionBuilder,
(
LocalSticker,
BaseReferences<_$TwonlyDB, $StickersTable, LocalSticker>,
),
LocalSticker,
PrefetchHooks Function()
> {
$$StickersTableTableManager(_$TwonlyDB db, $StickersTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$StickersTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$StickersTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$StickersTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> contentHash = const Value.absent(),
Value<Uint8List> webp = const Value.absent(),
Value<int> width = const Value.absent(),
Value<int> height = const Value.absent(),
Value<int> usageCount = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> lastUsedAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => StickersCompanion(
contentHash: contentHash,
webp: webp,
width: width,
height: height,
usageCount: usageCount,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
rowid: rowid,
),
createCompanionCallback:
({
required String contentHash,
required Uint8List webp,
required int width,
required int height,
Value<int> usageCount = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> lastUsedAt = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => StickersCompanion.insert(
contentHash: contentHash,
webp: webp,
width: width,
height: height,
usageCount: usageCount,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map(
(e) => (
e.readTable<$StickersTable, LocalSticker>(table),
BaseReferences<_$TwonlyDB, $StickersTable, LocalSticker>(
db,
table,
e,
),
),
)
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$StickersTableProcessedTableManager =
ProcessedTableManager<
_$TwonlyDB,
$StickersTable,
LocalSticker,
$$StickersTableFilterComposer,
$$StickersTableOrderingComposer,
$$StickersTableAnnotationComposer,
$$StickersTableCreateCompanionBuilder,
$$StickersTableUpdateCompanionBuilder,
(LocalSticker, BaseReferences<_$TwonlyDB, $StickersTable, LocalSticker>),
LocalSticker,
PrefetchHooks Function()
>;
class $TwonlyDBManager {
final _$TwonlyDB _db;
@ -27802,4 +28528,6 @@ class $TwonlyDBManager {
$$WebxdcInstancesTableTableManager(_db, _db.webxdcInstances);
$$WebxdcUpdatesTableTableManager get webxdcUpdates =>
$$WebxdcUpdatesTableTableManager(_db, _db.webxdcUpdates);
$$StickersTableTableManager get stickers =>
$$StickersTableTableManager(_db, _db.stickers);
}

View file

@ -4995,6 +4995,78 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{count, plural, =0{No views yet} =1{1 view} other{{count} views}}'**
String storyViews(int count);
/// No description provided for @sticker.
///
/// In en, this message translates to:
/// **'Sticker'**
String get sticker;
/// No description provided for @stickers.
///
/// In en, this message translates to:
/// **'Stickers'**
String get stickers;
/// No description provided for @createSticker.
///
/// In en, this message translates to:
/// **'Create sticker'**
String get createSticker;
/// No description provided for @noStickersYet.
///
/// In en, this message translates to:
/// **'Create your first sticker from a photo.'**
String get noStickersYet;
/// No description provided for @stickerAdded.
///
/// In en, this message translates to:
/// **'Sticker saved'**
String get stickerAdded;
/// No description provided for @stickerAlreadySaved.
///
/// In en, this message translates to:
/// **'This sticker is already saved'**
String get stickerAlreadySaved;
/// No description provided for @stickerCreateFailed.
///
/// In en, this message translates to:
/// **'The sticker could not be created.'**
String get stickerCreateFailed;
/// No description provided for @stickerSaveFailed.
///
/// In en, this message translates to:
/// **'The sticker could not be saved.'**
String get stickerSaveFailed;
/// No description provided for @notificationSticker.
///
/// In en, this message translates to:
/// **'sent a sticker{inGroup}.'**
String notificationSticker(Object inGroup);
/// No description provided for @storeSticker.
///
/// In en, this message translates to:
/// **'Save sticker'**
String get storeSticker;
/// No description provided for @removeSticker.
///
/// In en, this message translates to:
/// **'Remove from collection'**
String get removeSticker;
/// No description provided for @deleteSticker.
///
/// In en, this message translates to:
/// **'Delete sticker'**
String get deleteSticker;
}
class _AppLocalizationsDelegate

View file

@ -2944,4 +2944,42 @@ class AppLocalizationsAr extends AppLocalizations {
);
return '$_temp0';
}
@override
String get sticker => 'ملصق';
@override
String get stickers => 'الملصقات';
@override
String get createSticker => 'إنشاء ملصق';
@override
String get noStickersYet => 'أنشئ ملصقك الأول من صورة.';
@override
String get stickerAdded => 'تم حفظ الملصق';
@override
String get stickerAlreadySaved => 'هذا الملصق محفوظ بالفعل';
@override
String get stickerCreateFailed => 'تعذّر إنشاء الملصق.';
@override
String get stickerSaveFailed => 'تعذّر حفظ الملصق.';
@override
String notificationSticker(Object inGroup) {
return 'أرسل ملصقًا$inGroup.';
}
@override
String get storeSticker => 'حفظ الملصق';
@override
String get removeSticker => 'إزالة من المجموعة';
@override
String get deleteSticker => 'حذف الملصق';
}

View file

@ -2945,4 +2945,43 @@ class AppLocalizationsDe extends AppLocalizations {
);
return '$_temp0';
}
@override
String get sticker => 'Sticker';
@override
String get stickers => 'Sticker';
@override
String get createSticker => 'Sticker erstellen';
@override
String get noStickersYet => 'Erstelle deinen ersten Sticker aus einem Foto.';
@override
String get stickerAdded => 'Sticker gespeichert';
@override
String get stickerAlreadySaved => 'Dieser Sticker ist bereits gespeichert';
@override
String get stickerCreateFailed => 'Der Sticker konnte nicht erstellt werden.';
@override
String get stickerSaveFailed =>
'Der Sticker konnte nicht gespeichert werden.';
@override
String notificationSticker(Object inGroup) {
return 'hat einen Sticker gesendet$inGroup.';
}
@override
String get storeSticker => 'Sticker speichern';
@override
String get removeSticker => 'Aus Sammlung entfernen';
@override
String get deleteSticker => 'Sticker löschen';
}

View file

@ -2916,4 +2916,42 @@ class AppLocalizationsEn extends AppLocalizations {
);
return '$_temp0';
}
@override
String get sticker => 'Sticker';
@override
String get stickers => 'Stickers';
@override
String get createSticker => 'Create sticker';
@override
String get noStickersYet => 'Create your first sticker from a photo.';
@override
String get stickerAdded => 'Sticker saved';
@override
String get stickerAlreadySaved => 'This sticker is already saved';
@override
String get stickerCreateFailed => 'The sticker could not be created.';
@override
String get stickerSaveFailed => 'The sticker could not be saved.';
@override
String notificationSticker(Object inGroup) {
return 'sent a sticker$inGroup.';
}
@override
String get storeSticker => 'Save sticker';
@override
String get removeSticker => 'Remove from collection';
@override
String get deleteSticker => 'Delete sticker';
}

View file

@ -836,5 +836,17 @@
"type": "int"
}
}
}
},
"sticker": "ملصق",
"stickers": "الملصقات",
"createSticker": "إنشاء ملصق",
"noStickersYet": "أنشئ ملصقك الأول من صورة.",
"stickerAdded": "تم حفظ الملصق",
"stickerAlreadySaved": "هذا الملصق محفوظ بالفعل",
"stickerCreateFailed": "تعذّر إنشاء الملصق.",
"stickerSaveFailed": "تعذّر حفظ الملصق.",
"notificationSticker": "أرسل ملصقًا{inGroup}.",
"storeSticker": "حفظ الملصق",
"removeSticker": "إزالة من المجموعة",
"deleteSticker": "حذف الملصق"
}

View file

@ -1149,5 +1149,17 @@
"type": "int"
}
}
}
},
"sticker": "Sticker",
"stickers": "Sticker",
"createSticker": "Sticker erstellen",
"noStickersYet": "Erstelle deinen ersten Sticker aus einem Foto.",
"stickerAdded": "Sticker gespeichert",
"stickerAlreadySaved": "Dieser Sticker ist bereits gespeichert",
"stickerCreateFailed": "Der Sticker konnte nicht erstellt werden.",
"stickerSaveFailed": "Der Sticker konnte nicht gespeichert werden.",
"notificationSticker": "hat einen Sticker gesendet{inGroup}.",
"storeSticker": "Sticker speichern",
"removeSticker": "Aus Sammlung entfernen",
"deleteSticker": "Sticker löschen"
}

View file

@ -1159,5 +1159,17 @@
"type": "int"
}
}
}
},
"sticker": "Sticker",
"stickers": "Stickers",
"createSticker": "Create sticker",
"noStickersYet": "Create your first sticker from a photo.",
"stickerAdded": "Sticker saved",
"stickerAlreadySaved": "This sticker is already saved",
"stickerCreateFailed": "The sticker could not be created.",
"stickerSaveFailed": "The sticker could not be saved.",
"notificationSticker": "sent a sticker{inGroup}.",
"storeSticker": "Save sticker",
"removeSticker": "Remove from collection",
"deleteSticker": "Delete sticker"
}

View file

@ -111,6 +111,7 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
WebxdcOrigin? webxdcOrigin,
WebxdcSyncChunk? webxdcSync,
WebxdcSyncRequest? webxdcSyncRequest,
StickerData? sticker,
}) {
final result = create();
if (type != null) result.type = type;
@ -124,6 +125,7 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
if (webxdcOrigin != null) result.webxdcOrigin = webxdcOrigin;
if (webxdcSync != null) result.webxdcSync = webxdcSync;
if (webxdcSyncRequest != null) result.webxdcSyncRequest = webxdcSyncRequest;
if (sticker != null) result.sticker = sticker;
return result;
}
@ -156,6 +158,8 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
subBuilder: WebxdcSyncChunk.create)
..aOM<WebxdcSyncRequest>(10, _omitFieldNames ? '' : 'webxdcSyncRequest',
subBuilder: WebxdcSyncRequest.create)
..aOM<StickerData>(11, _omitFieldNames ? '' : 'sticker',
subBuilder: StickerData.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -271,6 +275,123 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
void clearWebxdcSyncRequest() => $_clearField(10);
@$pb.TagNumber(10)
WebxdcSyncRequest ensureWebxdcSyncRequest() => $_ensure(9);
@$pb.TagNumber(11)
StickerData get sticker => $_getN(10);
@$pb.TagNumber(11)
set sticker(StickerData value) => $_setField(11, value);
@$pb.TagNumber(11)
$core.bool hasSticker() => $_has(10);
@$pb.TagNumber(11)
void clearSticker() => $_clearField(11);
@$pb.TagNumber(11)
StickerData ensureSticker() => $_ensure(10);
}
/// A small, self-contained sticker. Keeping the bytes beside the text message
/// makes it available immediately and preserves end-to-end encryption without
/// introducing a second media download lifecycle.
class StickerData extends $pb.GeneratedMessage {
factory StickerData({
$core.int? version,
$core.List<$core.int>? webp,
$core.List<$core.int>? sha256,
$core.int? width,
$core.int? height,
}) {
final result = create();
if (version != null) result.version = version;
if (webp != null) result.webp = webp;
if (sha256 != null) result.sha256 = sha256;
if (width != null) result.width = width;
if (height != null) result.height = height;
return result;
}
StickerData._();
factory StickerData.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory StickerData.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'StickerData',
createEmptyInstance: create)
..aI(1, _omitFieldNames ? '' : 'version', fieldType: $pb.PbFieldType.OU3)
..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'webp', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
3, _omitFieldNames ? '' : 'sha256', $pb.PbFieldType.OY)
..aI(4, _omitFieldNames ? '' : 'width', fieldType: $pb.PbFieldType.OU3)
..aI(5, _omitFieldNames ? '' : 'height', fieldType: $pb.PbFieldType.OU3)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
StickerData clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
StickerData copyWith(void Function(StickerData) updates) =>
super.copyWith((message) => updates(message as StickerData))
as StickerData;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static StickerData create() => StickerData._();
@$core.override
StickerData createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static StickerData getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<StickerData>(create);
static StickerData? _defaultInstance;
@$pb.TagNumber(1)
$core.int get version => $_getIZ(0);
@$pb.TagNumber(1)
set version($core.int value) => $_setUnsignedInt32(0, value);
@$pb.TagNumber(1)
$core.bool hasVersion() => $_has(0);
@$pb.TagNumber(1)
void clearVersion() => $_clearField(1);
@$pb.TagNumber(2)
$core.List<$core.int> get webp => $_getN(1);
@$pb.TagNumber(2)
set webp($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(2)
$core.bool hasWebp() => $_has(1);
@$pb.TagNumber(2)
void clearWebp() => $_clearField(2);
@$pb.TagNumber(3)
$core.List<$core.int> get sha256 => $_getN(2);
@$pb.TagNumber(3)
set sha256($core.List<$core.int> value) => $_setBytes(2, value);
@$pb.TagNumber(3)
$core.bool hasSha256() => $_has(2);
@$pb.TagNumber(3)
void clearSha256() => $_clearField(3);
@$pb.TagNumber(4)
$core.int get width => $_getIZ(3);
@$pb.TagNumber(4)
set width($core.int value) => $_setUnsignedInt32(3, value);
@$pb.TagNumber(4)
$core.bool hasWidth() => $_has(3);
@$pb.TagNumber(4)
void clearWidth() => $_clearField(4);
@$pb.TagNumber(5)
$core.int get height => $_getIZ(4);
@$pb.TagNumber(5)
set height($core.int value) => $_setUnsignedInt32(4, value);
@$pb.TagNumber(5)
$core.bool hasHeight() => $_has(4);
@$pb.TagNumber(5)
void clearHeight() => $_clearField(5);
}
/// A peer-to-peer transfer of existing one-time app state. The encoded payload

View file

@ -35,6 +35,8 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum {
static const AdditionalMessageData_Type WEBXDC_SYNC_REQUEST =
AdditionalMessageData_Type._(
8, _omitEnumNames ? '' : 'WEBXDC_SYNC_REQUEST');
static const AdditionalMessageData_Type STICKER =
AdditionalMessageData_Type._(9, _omitEnumNames ? '' : 'STICKER');
static const $core.List<AdditionalMessageData_Type> values =
<AdditionalMessageData_Type>[
@ -47,10 +49,11 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum {
WEBXDC_SENT,
WEBXDC_SYNC,
WEBXDC_SYNC_REQUEST,
STICKER,
];
static final $core.List<AdditionalMessageData_Type?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 8);
$pb.ProtobufEnum.$_initByValueList(values, 9);
static AdditionalMessageData_Type? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];

View file

@ -126,6 +126,16 @@ const AdditionalMessageData$json = {
'10': 'webxdcSyncRequest',
'17': true
},
{
'1': 'sticker',
'3': 11,
'4': 1,
'5': 11,
'6': '.StickerData',
'9': 8,
'10': 'sticker',
'17': true
},
],
'4': [AdditionalMessageData_Type$json],
'8': [
@ -137,6 +147,7 @@ const AdditionalMessageData$json = {
{'1': '_webxdc_origin'},
{'1': '_webxdc_sync'},
{'1': '_webxdc_sync_request'},
{'1': '_sticker'},
],
};
@ -153,6 +164,7 @@ const AdditionalMessageData_Type$json = {
{'1': 'WEBXDC_SENT', '2': 6},
{'1': 'WEBXDC_SYNC', '2': 7},
{'1': 'WEBXDC_SYNC_REQUEST', '2': 8},
{'1': 'STICKER', '2': 9},
],
};
@ -167,13 +179,32 @@ final $typed_data.Uint8List additionalMessageDataDescriptor = $convert.base64Dec
'ZUgEUgx3ZWJ4ZGNVcGRhdGWIAQESNwoNd2VieGRjX29yaWdpbhgIIAEoCzINLldlYnhkY09yaW'
'dpbkgFUgx3ZWJ4ZGNPcmlnaW6IAQESNgoLd2VieGRjX3N5bmMYCSABKAsyEC5XZWJ4ZGNTeW5j'
'Q2h1bmtIBlIKd2VieGRjU3luY4gBARJHChN3ZWJ4ZGNfc3luY19yZXF1ZXN0GAogASgLMhIuV2'
'VieGRjU3luY1JlcXVlc3RIB1IRd2VieGRjU3luY1JlcXVlc3SIAQEirAEKBFR5cGUSCAoETElO'
'SxAAEgwKCENPTlRBQ1RTEAESGgoWUkVTVE9SRURfRkxBTUVfQ09VTlRFUhACEhIKDkFTS19BQk'
'9VVF9VU0VSEAMSDgoKV0VCWERDX0FQUBAEEhEKDVdFQlhEQ19VUERBVEUQBRIPCgtXRUJYRENf'
'U0VOVBAGEg8KC1dFQlhEQ19TWU5DEAcSFwoTV0VCWERDX1NZTkNfUkVRVUVTVBAIQgcKBV9saW'
'5rQhkKF19yZXN0b3JlZF9mbGFtZV9jb3VudGVyQhQKEl9hc2tfYWJvdXRfdXNlcl9pZEINCgtf'
'd2VieGRjX2FwcEIQCg5fd2VieGRjX3VwZGF0ZUIQCg5fd2VieGRjX29yaWdpbkIOCgxfd2VieG'
'RjX3N5bmNCFgoUX3dlYnhkY19zeW5jX3JlcXVlc3Q=');
'VieGRjU3luY1JlcXVlc3RIB1IRd2VieGRjU3luY1JlcXVlc3SIAQESKwoHc3RpY2tlchgLIAEo'
'CzIMLlN0aWNrZXJEYXRhSAhSB3N0aWNrZXKIAQEiuQEKBFR5cGUSCAoETElOSxAAEgwKCENPTl'
'RBQ1RTEAESGgoWUkVTVE9SRURfRkxBTUVfQ09VTlRFUhACEhIKDkFTS19BQk9VVF9VU0VSEAMS'
'DgoKV0VCWERDX0FQUBAEEhEKDVdFQlhEQ19VUERBVEUQBRIPCgtXRUJYRENfU0VOVBAGEg8KC1'
'dFQlhEQ19TWU5DEAcSFwoTV0VCWERDX1NZTkNfUkVRVUVTVBAIEgsKB1NUSUNLRVIQCUIHCgVf'
'bGlua0IZChdfcmVzdG9yZWRfZmxhbWVfY291bnRlckIUChJfYXNrX2Fib3V0X3VzZXJfaWRCDQ'
'oLX3dlYnhkY19hcHBCEAoOX3dlYnhkY191cGRhdGVCEAoOX3dlYnhkY19vcmlnaW5CDgoMX3dl'
'YnhkY19zeW5jQhYKFF93ZWJ4ZGNfc3luY19yZXF1ZXN0QgoKCF9zdGlja2Vy');
@$core.Deprecated('Use stickerDataDescriptor instead')
const StickerData$json = {
'1': 'StickerData',
'2': [
{'1': 'version', '3': 1, '4': 1, '5': 13, '10': 'version'},
{'1': 'webp', '3': 2, '4': 1, '5': 12, '10': 'webp'},
{'1': 'sha256', '3': 3, '4': 1, '5': 12, '10': 'sha256'},
{'1': 'width', '3': 4, '4': 1, '5': 13, '10': 'width'},
{'1': 'height', '3': 5, '4': 1, '5': 13, '10': 'height'},
],
};
/// Descriptor for `StickerData`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List stickerDataDescriptor = $convert.base64Decode(
'CgtTdGlja2VyRGF0YRIYCgd2ZXJzaW9uGAEgASgNUgd2ZXJzaW9uEhIKBHdlYnAYAiABKAxSBH'
'dlYnASFgoGc2hhMjU2GAMgASgMUgZzaGEyNTYSFAoFd2lkdGgYBCABKA1SBXdpZHRoEhYKBmhl'
'aWdodBgFIAEoDVIGaGVpZ2h0');
@$core.Deprecated('Use webxdcSyncChunkDescriptor instead')
const WebxdcSyncChunk$json = {

View file

@ -44,6 +44,7 @@ class NativeNotificationService {
static bool opensConversation(String? kind) =>
kind == 'text' ||
kind == 'response' ||
kind == 'sticker' ||
kind == 'webxdc' ||
kind == 'story_reply' ||
kind == 'story_reaction';

View file

@ -0,0 +1,104 @@
import 'dart:typed_data';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:image_picker/image_picker.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.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;
}
static Future<LocalSticker?> createFromGallery() async {
final image = await ImagePicker().pickImage(source: ImageSource.gallery);
if (image == null) return null;
return createFromPath(image.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);
}
}

View file

@ -1,12 +1,23 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
import 'package:twonly/src/visual/components/sticker_picker.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
class EmojiPickerBottom extends StatelessWidget {
const EmojiPickerBottom({super.key});
class EmojiPickerBottom extends StatefulWidget {
const EmojiPickerBottom({this.allowStickers = false, super.key});
final bool allowStickers;
@override
State<EmojiPickerBottom> createState() => _EmojiPickerBottomState();
}
class _EmojiPickerBottomState extends State<EmojiPickerBottom> {
bool _showStickers = false;
@override
Widget build(BuildContext context) {
@ -41,6 +52,32 @@ class EmojiPickerBottom extends StatelessWidget {
),
Expanded(
child: EmojiPicker(
alternateView: _showStickers
? StickerPicker(
selectCreatedSticker: true,
onEmojiPressed: () {
setState(() => _showStickers = false);
},
onStickerSelected: (sticker) {
unawaitedRustCall(
twonlyDB.stickersDao.recordUse(
sticker.contentHash,
),
'recordStickerUse',
);
Navigator.pop(
context,
StickerLayerData(
key: GlobalKey(),
webp: sticker.webp,
contentHash: sticker.contentHash,
width: sticker.width,
height: sticker.height,
),
);
},
)
: null,
onEmojiSelected: (category, emoji) {
Navigator.pop(
context,
@ -55,7 +92,9 @@ class EmojiPickerBottom extends StatelessWidget {
height: 400,
locale: Localizations.localeOf(context),
checkPlatformCompatibility: false,
resizeConfig: const ResizeConfig(showDragHandle: false),
resizeConfig: const ResizeConfig(
showDragHandle: false,
),
emojiTextStyle: TextStyle(
fontSize: 24 * (Platform.isIOS ? 1.2 : 1),
fontFamilyFallback: Platform.isAndroid
@ -81,6 +120,11 @@ class EmojiPickerBottom extends StatelessWidget {
backgroundColor: context.color.surfaceContainer,
buttonColor: context.color.surfaceContainer,
buttonIconColor: context.color.secondary,
stickerButtonLabel: context.lang.sticker,
showStickerButton: widget.allowStickers,
onStickerButtonPressed: widget.allowStickers
? () => setState(() => _showStickers = true)
: null,
),
),
),

View file

@ -4,8 +4,8 @@
* Copyright (c) 2024 Stefan Humm
*/
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
import 'package:flutter/material.dart';
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
/// Template class for custom implementation
abstract class BottomActionBar extends StatefulWidget {
@ -36,6 +36,7 @@ typedef BottomActionBarBuilder =
);
/// Configuration for the bottom action bar.
@immutable
class BottomActionBarConfig {
/// Constructor.
const BottomActionBarConfig({
@ -69,7 +70,7 @@ class BottomActionBarConfig {
final BottomActionBarBuilder? customBottomActionBar;
@override
bool operator ==(other) {
bool operator ==(Object other) {
return other is BottomActionBarConfig &&
other.enabled == enabled &&
other.showBackspaceButton == showBackspaceButton &&
@ -119,72 +120,17 @@ class DefaultBottomActionBar extends BottomActionBar {
class _DefaultBottomActionBarState extends State<DefaultBottomActionBar> {
@override
Widget build(BuildContext context) {
return Container(
color: widget.config.bottomActionBarConfig.backgroundColor,
child: Row(
children: [
SizedBox(width: 48, child: _buildSearchViewButton()),
Expanded(
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: _buildPickerTabs(),
),
),
),
SizedBox(width: 48, child: _buildBackspaceButton()),
],
),
);
}
Widget _buildPickerTabs() {
final actionBarConfig = widget.config.bottomActionBarConfig;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPickerTab(
label: actionBarConfig.emojiButtonLabel,
selected: true,
),
if (actionBarConfig.showStickerButton) ...[
const SizedBox(width: 4),
_buildPickerTab(
label: actionBarConfig.stickerButtonLabel,
onTap: actionBarConfig.onStickerButtonPressed,
),
],
],
);
}
Widget _buildPickerTab({
required String label,
bool selected = false,
VoidCallback? onTap,
}) {
final actionBarConfig = widget.config.bottomActionBarConfig;
final selectedColor =
actionBarConfig.selectedTabColor ??
actionBarConfig.buttonIconColor.withValues(alpha: 0.14);
return Material(
color: selected ? selectedColor : Colors.transparent,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Text(
label,
style: TextStyle(
color: actionBarConfig.buttonIconColor,
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
return PickerBottomBar(
backgroundColor: actionBarConfig.backgroundColor,
buttonIconColor: actionBarConfig.buttonIconColor,
selectedTabColor: actionBarConfig.selectedTabColor,
emojiButtonLabel: actionBarConfig.emojiButtonLabel,
stickerButtonLabel: actionBarConfig.stickerButtonLabel,
showStickerButton: actionBarConfig.showStickerButton,
onStickerButtonPressed: actionBarConfig.onStickerButtonPressed,
leading: _buildSearchViewButton(),
trailing: _buildBackspaceButton(),
);
}
@ -214,3 +160,121 @@ class _DefaultBottomActionBarState extends State<DefaultBottomActionBar> {
);
}
}
/// Shared shell for emoji and sticker mode. Reserving the same 48 logical
/// pixels on both sides keeps the tabs perfectly stationary while switching.
class PickerBottomBar extends StatelessWidget {
const PickerBottomBar({
required this.backgroundColor,
required this.buttonIconColor,
required this.emojiButtonLabel,
required this.stickerButtonLabel,
required this.showStickerButton,
this.selectedTabColor,
this.stickerSelected = false,
this.onEmojiButtonPressed,
this.onStickerButtonPressed,
this.leading,
this.trailing,
super.key,
});
final Color? backgroundColor;
final Color buttonIconColor;
final Color? selectedTabColor;
final String emojiButtonLabel;
final String stickerButtonLabel;
final bool showStickerButton;
final bool stickerSelected;
final VoidCallback? onEmojiButtonPressed;
final VoidCallback? onStickerButtonPressed;
final Widget? leading;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: backgroundColor ?? Colors.transparent,
child: SizedBox(
height: 48,
child: Row(
children: [
SizedBox(width: 48, child: leading),
Expanded(
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PickerTab(
label: emojiButtonLabel,
selected: !stickerSelected,
foreground: buttonIconColor,
selectedColor: selectedTabColor,
onTap: onEmojiButtonPressed,
),
if (showStickerButton) ...[
const SizedBox(width: 4),
_PickerTab(
label: stickerButtonLabel,
selected: stickerSelected,
foreground: buttonIconColor,
selectedColor: selectedTabColor,
onTap: onStickerButtonPressed,
),
],
],
),
),
),
),
SizedBox(width: 48, child: trailing),
],
),
),
);
}
}
class _PickerTab extends StatelessWidget {
const _PickerTab({
required this.label,
required this.selected,
required this.foreground,
this.selectedColor,
this.onTap,
});
final String label;
final bool selected;
final Color foreground;
final Color? selectedColor;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: selected
? selectedColor ?? foreground.withValues(alpha: 0.14)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
color: foreground,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}

View file

@ -134,11 +134,15 @@ class EmojiPicker extends StatefulWidget {
this.onCategoryChanged,
this.config = const Config(),
this.customWidget,
this.alternateView,
});
/// Custom widget
final EmojiViewBuilder? customWidget;
/// Alternate picker content, sharing the same resize handle and height.
final Widget? alternateView;
/// If you provide the [TextEditingController] that is linked to a
/// [TextField] this widget handles inserting and deleting for you
/// automatically.
@ -260,6 +264,9 @@ class EmojiPickerState extends State<EmojiPicker> {
@override
Widget build(BuildContext context) {
if (widget.alternateView != null) {
return _buildPickerSurface(widget.alternateView!);
}
if (!_loaded) {
return widget.config.emojiViewConfig.loadingIndicator;
}

View file

@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/stickers/sticker.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/emoji_picker/bottom_action_bar.dart';
import 'package:twonly/src/visual/components/sticker_preview.dialog.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
class StickerPicker extends StatefulWidget {
const StickerPicker({
required this.onEmojiPressed,
required this.onStickerSelected,
this.selectCreatedSticker = false,
super.key,
});
final VoidCallback onEmojiPressed;
final ValueChanged<LocalSticker> onStickerSelected;
final bool selectCreatedSticker;
@override
State<StickerPicker> createState() => _StickerPickerState();
}
class _StickerPickerState extends State<StickerPicker> {
late final Stream<List<LocalSticker>> _stickers = twonlyDB.stickersDao
.watchAll();
bool _creating = false;
Future<void> _createSticker() async {
if (_creating) return;
setState(() => _creating = true);
try {
final sticker = await StickerService.createFromGallery();
if (!mounted || sticker == null) return;
if (widget.selectCreatedSticker) widget.onStickerSelected(sticker);
} on Object {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.lang.stickerCreateFailed)),
);
} finally {
if (mounted) setState(() => _creating = false);
}
}
@override
Widget build(BuildContext context) {
final color = context.color;
return ColoredBox(
color: color.surfaceContainer,
child: Column(
children: [
PickerBottomBar(
backgroundColor: color.surfaceContainer,
buttonIconColor: color.secondary,
emojiButtonLabel: 'Emoji',
stickerButtonLabel: context.lang.sticker,
showStickerButton: true,
stickerSelected: true,
onEmojiButtonPressed: widget.onEmojiPressed,
),
Expanded(
child: StreamBuilder<List<LocalSticker>>(
stream: _stickers,
builder: (context, snapshot) {
if (!snapshot.hasData && !snapshot.hasError) {
return const Center(child: ThreeRotatingDots(size: 26));
}
final stickers = snapshot.data ?? const <LocalSticker>[];
return GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 104,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: stickers.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Semantics(
button: true,
label: context.lang.createSticker,
child: Material(
color: color.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: _creating ? null : _createSticker,
child: Center(
child: _creating
? const ThreeRotatingDots(size: 26)
: Icon(
Icons.add_photo_alternate_outlined,
color: color.primary,
size: 32,
),
),
),
),
);
}
final sticker = stickers[index - 1];
return Semantics(
button: true,
label: context.lang.sticker,
child: InkWell(
key: ValueKey(sticker.contentHash),
borderRadius: BorderRadius.circular(12),
onTap: () => widget.onStickerSelected(sticker),
onLongPress: () => showStickerPreview(
context,
StickerService.fromLocal(sticker),
collectionOnly: true,
),
child: Padding(
padding: const EdgeInsets.all(5),
child: Image.memory(
sticker.webp,
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
),
);
},
);
},
),
),
],
),
);
}
}

View file

@ -0,0 +1,137 @@
import 'dart:typed_data';
import 'package:convert/convert.dart';
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/stickers/sticker.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
Future<void> showStickerPreview(
BuildContext context,
StickerData sticker, {
bool collectionOnly = false,
}) {
return showDialog<void>(
context: context,
builder: (context) => _StickerPreviewDialog(
sticker: sticker,
collectionOnly: collectionOnly,
),
);
}
class _StickerPreviewDialog extends StatefulWidget {
const _StickerPreviewDialog({
required this.sticker,
required this.collectionOnly,
});
final StickerData sticker;
final bool collectionOnly;
@override
State<_StickerPreviewDialog> createState() => _StickerPreviewDialogState();
}
class _StickerPreviewDialogState extends State<_StickerPreviewDialog> {
late final Uint8List _webp = Uint8List.fromList(widget.sticker.webp);
late final String _contentHash = hex.encode(widget.sticker.sha256);
bool _stored = false;
bool _busy = true;
String? _error;
@override
void initState() {
super.initState();
_loadStoredState();
}
Future<void> _loadStoredState() async {
try {
final local = await twonlyDB.stickersDao.getByHash(_contentHash);
if (!mounted) return;
setState(() {
_stored = local != null;
_busy = false;
});
} on Object {
if (!mounted) return;
setState(() {
_error = context.lang.stickerSaveFailed;
_busy = false;
});
}
}
Future<void> _performAction() async {
if (_busy) return;
setState(() {
_busy = true;
_error = null;
});
try {
if (_stored || widget.collectionOnly) {
await twonlyDB.stickersDao.remove(_contentHash);
} else {
await StickerService.saveReceived(widget.sticker);
}
if (!mounted) return;
Navigator.of(context).pop();
} on Object {
if (!mounted) return;
setState(() {
_busy = false;
_error = context.lang.stickerSaveFailed;
});
}
}
@override
Widget build(BuildContext context) {
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 240,
height: 240,
child: Image.memory(_webp, fit: BoxFit.contain),
),
const SizedBox(height: 24),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: context.color.error)),
const SizedBox(height: 12),
],
MyButton(
variant: _stored || widget.collectionOnly
? MyButtonVariant.error
: MyButtonVariant.primary,
onPressed: _busy ? null : _performAction,
child: _busy
? const ThreeRotatingDots(size: 24)
: Text(
widget.collectionOnly
? context.lang.deleteSticker
: _stored
? context.lang.removeSticker
: context.lang.storeSticker,
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.lang.cancel),
),
],
),
),
),
);
}
}

View file

@ -8,7 +8,7 @@ import 'package:twonly/src/visual/views/camera/share_image_editor_components/lay
///
/// The stack always starts with the [BackgroundLayerData] (the photo itself)
/// followed by the [FilterLayerData]; everything the user adds (text, drawings,
/// emojis, link previews) is stacked on top of those two.
/// emojis, stickers, link previews) is stacked on top of those two.
///
/// Layers are not removed immediately when the user deletes them. They are
/// flagged via [Layer.isDeleted] by the layer widgets and only collected later,

View file

@ -108,7 +108,8 @@ class EditorSideToolbar extends StatelessWidget {
final layer = await showModalBottomSheet<Layer>(
context: context,
backgroundColor: context.color.surface,
builder: (context) => const EmojiPickerBottom(),
builder: (context) =>
const EmojiPickerBottom(allowStickers: true),
);
if (layer == null) return;
layerStack.add(layer);

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:hand_signature/signature.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
@ -69,6 +71,28 @@ class EmojiLayerData extends Layer {
double size;
}
class StickerLayerData extends Layer {
StickerLayerData({
required super.key,
required this.webp,
required this.contentHash,
required this.width,
required this.height,
this.size = 160,
super.offset,
super.opacity,
super.rotation,
super.scale,
super.isEditing,
});
final Uint8List webp;
final String contentHash;
final int width;
final int height;
double size;
}
class TextLayerData extends Layer {
TextLayerData({
required super.key,

View file

@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/action_button.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
class StickerLayer extends StatefulWidget {
const StickerLayer({required this.layerData, this.onUpdate, super.key});
final StickerLayerData layerData;
final VoidCallback? onUpdate;
@override
State<StickerLayer> createState() => _StickerLayerState();
}
class _StickerLayerState extends State<StickerLayer> {
double initialRotation = 0;
Offset initialOffset = Offset.zero;
Offset initialFocalPoint = Offset.zero;
double initialSize = 1;
bool deleteLayer = false;
bool twoPointersWereDown = false;
final GlobalKey outlineKey = GlobalKey();
final GlobalKey stickerKey = GlobalKey();
int pointers = 0;
bool display = false;
@override
void initState() {
super.initState();
if (widget.layerData.offset.dy == 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
widget.layerData.offset = Offset(
MediaQuery.sizeOf(context).width / 2 - 110,
MediaQuery.sizeOf(context).height / 2 - 210,
);
display = true;
});
});
} else {
display = true;
}
}
@override
Widget build(BuildContext context) {
if (!display || widget.layerData.isDeleted) return const SizedBox.shrink();
return Stack(
key: outlineKey,
children: [
Positioned(
left: widget.layerData.offset.dx,
top: widget.layerData.offset.dy,
child: Listener(
onPointerDown: (_) => setState(() => pointers++),
onPointerUp: (_) {
setState(() {
pointers = pointers > 0 ? pointers - 1 : 0;
if (pointers == 0) twoPointersWereDown = false;
if (deleteLayer) {
widget.layerData.isDeleted = true;
widget.onUpdate?.call();
}
});
},
child: GestureDetector(
onScaleStart: (details) {
initialSize = widget.layerData.size;
initialRotation = widget.layerData.rotation;
initialOffset = widget.layerData.offset;
initialFocalPoint = details.focalPoint;
},
onScaleUpdate: (details) async {
if (twoPointersWereDown && details.pointerCount != 2) return;
final outline =
outlineKey.currentContext?.findRenderObject() as RenderBox?;
final sticker =
stickerKey.currentContext?.findRenderObject() as RenderBox?;
if (outline == null || sticker == null) return;
final center =
widget.layerData.offset +
Offset(sticker.size.width / 2, sticker.size.height / 2);
final overDelete =
center.dy > outline.size.height - 80 &&
center.dx > MediaQuery.sizeOf(context).width / 2 - 30 &&
center.dx < MediaQuery.sizeOf(context).width / 2 + 20;
if (overDelete && !deleteLayer) {
await HapticFeedback.heavyImpact();
}
if (!mounted) return;
setState(() {
deleteLayer = overDelete;
twoPointersWereDown = details.pointerCount >= 2;
widget.layerData.size = (initialSize * details.scale).clamp(
42.0,
420.0,
);
widget.layerData.rotation =
initialRotation + details.rotation;
widget.layerData.offset =
initialOffset + (details.focalPoint - initialFocalPoint);
});
},
child: Transform.rotate(
angle: widget.layerData.rotation,
key: stickerKey,
child: Padding(
padding: const EdgeInsets.all(36),
child: _StickerImage(layerData: widget.layerData),
),
),
),
),
),
if (pointers > 0)
Positioned(
left: 0,
right: 0,
bottom: 20,
child: Center(
child: ActionButton(
FontAwesomeIcons.trashCan,
tooltipText: '',
color: deleteLayer
? context.appColor(AppColor.recording)
: context.appColor(AppColor.mediaForeground),
),
),
),
],
);
}
}
class _StickerImage extends StatelessWidget {
const _StickerImage({required this.layerData});
final StickerLayerData layerData;
@override
Widget build(BuildContext context) {
final aspect = layerData.width / layerData.height;
final width = aspect >= 1 ? layerData.size : layerData.size * aspect;
final height = aspect >= 1 ? layerData.size / aspect : layerData.size;
return SizedBox(
width: width,
height: height,
child: Opacity(
opacity: layerData.opacity,
child: Image.memory(
layerData.webp,
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:twonly/src/visual/views/camera/share_image_editor_components/lay
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/emoji.layer.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/filter.layer.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview.layer.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/sticker.layer.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/text.layer.dart';
/// View stacked layers (unbounded height, width)
@ -43,6 +44,7 @@ class LayersViewer extends StatelessWidget {
.where(
(layerItem) =>
layerItem is EmojiLayerData ||
layerItem is StickerLayerData ||
layerItem is DrawLayerData ||
layerItem is LinkPreviewLayerData ||
layerItem is TextLayerData,
@ -54,6 +56,12 @@ class LayersViewer extends StatelessWidget {
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is StickerLayerData) {
return StickerLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is DrawLayerData) {
return DrawLayer(
key: layerItem.key,

View file

@ -10,6 +10,7 @@ import 'package:twonly/src/database/tables/messages.table.dart'
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/stickers/sticker.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
@ -19,6 +20,7 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_sticker.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_webxdc.entry.dart';
@ -146,9 +148,12 @@ class _ChatListEntryState extends State<ChatListEntry> {
BubbleInfo info,
) {
if (widget.message.type == MessageType.text.name) {
// Only a message a webxdc app produced -- text handed to a chat, or an
// announcement an app made in one -- carries data alongside its text, so
// this is enough to route it without decoding anything here.
final sticker = StickerService.decodeAdditional(
widget.message.additionalMessageData,
);
if (sticker != null) {
return ChatStickerEntry(message: widget.message, sticker: sticker);
}
if (widget.message.additionalMessageData != null) {
return ChatWebxdcMessageEntry(
message: widget.message,

View file

@ -0,0 +1,73 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/sticker_preview.dialog.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart';
class ChatStickerEntry extends StatelessWidget {
const ChatStickerEntry({
required this.message,
required this.sticker,
super.key,
});
final Message message;
final StickerData sticker;
@override
Widget build(BuildContext context) {
const maxEdge = 190.0;
final maxDimension = sticker.width > sticker.height
? sticker.width
: sticker.height;
final fittedScale = maxEdge / maxDimension.clamp(1, 300).toDouble();
final width = (sticker.width * fittedScale).clamp(48.0, maxEdge);
final height = (sticker.height * fittedScale).clamp(48.0, maxEdge);
return Semantics(
button: true,
label: context.lang.sticker,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showStickerPreview(context, sticker),
child: Stack(
alignment: Alignment.bottomRight,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 14),
child: SizedBox(
width: width,
height: height,
child: Image.memory(
Uint8List.fromList(sticker.webp),
fit: BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.broken_image_outlined,
color: context.color.onSurfaceVariant,
size: 44,
),
),
),
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.52),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
child: FriendlyMessageTime(
message: message,
color: Colors.white,
),
),
),
],
),
),
);
}
}

View file

@ -22,7 +22,7 @@ class FriendlyMessageTime extends StatelessWidget {
Widget build(BuildContext context) {
final statusIcon = _buildStatusIcon(
context,
context.appColor(AppColor.chatMessageMeta),
color ?? context.appColor(AppColor.chatMessageMeta),
);
return Padding(

View file

@ -7,17 +7,20 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'
as pb;
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/stickers/sticker.service.dart';
import 'package:twonly/src/services/webxdc/webxdc.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
import 'package:twonly/src/visual/context_menu/context_menu.helper.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
import 'package:twonly/src/visual/views/chats/message_info.view.dart';
@ -40,6 +43,51 @@ class MessageContextMenu extends StatelessWidget {
final MediaFileService? mediaFileService;
final VoidCallback onResponseTriggered;
Future<void> createStickerFromMedia(BuildContext context) async {
final media = mediaFileService;
if (media == null) return;
final candidates = [media.storedPath, media.tempPath, media.originalPath];
final source = candidates.where((file) => file.existsSync()).firstOrNull;
if (source == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.lang.stickerCreateFailed)),
);
return;
}
final navigator = Navigator.of(context);
final loadingRoute = DialogRoute<void>(
context: context,
barrierDismissible: false,
builder: (context) => const PopScope(
canPop: false,
child: Dialog(
child: Padding(
padding: EdgeInsets.all(32),
child: ThreeRotatingDots(size: 32),
),
),
),
);
navigator.push(loadingRoute);
String result;
try {
await StickerService.createFromPath(source.path);
if (!context.mounted) return;
result = context.lang.stickerAdded;
} on Object {
if (!context.mounted) return;
result = context.lang.stickerCreateFailed;
} finally {
if (loadingRoute.isActive) navigator.removeRoute(loadingRoute);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result)),
);
}
}
Future<void> reopenMediaFile(BuildContext context) async {
if (message.senderId == null) {
final isAuth = await authenticateUser(
@ -107,6 +155,8 @@ class MessageContextMenu extends StatelessWidget {
// `late` so the ancestor lookup only runs if the menu is actually
// opened, rather than once per row on every rebuild.
late final navigator = Navigator.of(context);
final isSticker =
StickerService.decodeAdditional(message.additionalMessageData) != null;
return ContextMenu(
items: () => [
if (!message.isDeletedFromSender)
@ -150,6 +200,14 @@ class MessageContextMenu extends StatelessWidget {
onTap: () => reopenMediaFile(navigator.context),
icon: FontAwesomeIcons.clockRotateLeft,
),
if (!message.isDeletedFromSender &&
mediaFileService?.mediaFile.type == MediaType.image &&
(mediaFileService?.mediaFile.stored ?? false))
ContextMenuItem(
title: context.lang.createSticker,
onTap: () => createStickerFromMedia(navigator.context),
icon: FontAwesomeIcons.wandMagicSparkles,
),
if (!message.isDeletedFromSender)
ContextMenuItem(
title: context.lang.reply,
@ -160,7 +218,8 @@ class MessageContextMenu extends StatelessWidget {
),
if (!message.isDeletedFromSender &&
message.senderId == null &&
message.type == MessageType.text.name)
message.type == MessageType.text.name &&
!isSticker)
ContextMenuItem(
title: context.lang.edit,
onTap: () async {
@ -168,7 +227,7 @@ class MessageContextMenu extends StatelessWidget {
},
icon: FontAwesomeIcons.pencil,
),
if (message.content != null)
if (message.content != null && !isSticker)
ContextMenuItem(
title: context.lang.copy,
onTap: () async {

View file

@ -13,8 +13,10 @@ 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/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/stickers/sticker.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
import 'package:twonly/src/visual/components/sticker_picker.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart';
@ -70,6 +72,7 @@ class _MessageInputState extends State<MessageInput>
late final RecorderController recorderController;
final bool isApple = Platform.isIOS;
bool _emojiShowing = false;
bool _stickerShowing = false;
bool _switchingToSystemKeyboard = false;
double _emojiPickerHeight = 300;
bool _showSparks = false;
@ -112,6 +115,19 @@ class _MessageInputState extends State<MessageInput>
);
}
void _sendSticker(LocalSticker sticker) {
final quoteMessageId = widget.quotesMessage?.messageId;
widget.onMessageSend();
unawaitedRustCall(
StickerService.send(
groupId: widget.group.groupId,
sticker: sticker,
quoteMessageId: quoteMessageId,
),
'sendSticker',
);
}
@override
void initState() {
super.initState();
@ -246,6 +262,7 @@ class _MessageInputState extends State<MessageInput>
_emojiPickerHeight = keyboardHeight;
}
_emojiShowing = true;
_stickerShowing = false;
});
widget.onEmojiVisibilityChanged(true);
widget.textFieldFocus.unfocus();
@ -756,6 +773,14 @@ class _MessageInputState extends State<MessageInput>
Offstage(
offstage: !_emojiShowing,
child: EmojiPicker(
alternateView: _stickerShowing
? StickerPicker(
onEmojiPressed: () {
setState(() => _stickerShowing = false);
},
onStickerSelected: _sendSticker,
)
: null,
textEditingController: _textFieldController,
onEmojiSelected: (category, emoji) {
setState(() {});
@ -787,6 +812,10 @@ class _MessageInputState extends State<MessageInput>
backgroundColor: context.color.surfaceContainer,
buttonColor: context.color.surfaceContainer,
buttonIconColor: context.color.secondary,
stickerButtonLabel: context.lang.sticker,
onStickerButtonPressed: () {
setState(() => _stickerShowing = true);
},
),
),
),

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
@ -8,6 +10,7 @@ import 'package:twonly/src/database/tables/messages.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/stickers/sticker.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/chats/chat_messages.view.dart';
@ -191,7 +194,12 @@ class _ResponsePreviewState extends State<ResponsePreview> {
if (_message != null) {
if (_message!.type == MessageType.text.name) {
if (_message!.content != null) {
final sticker = StickerService.decodeAdditional(
_message!.additionalMessageData,
);
if (sticker != null) {
subtitle = context.lang.sticker;
} else if (_message!.content != null) {
subtitle = truncateString(_message!.content!);
}
}
@ -248,6 +256,9 @@ class _ResponsePreviewState extends State<ResponsePreview> {
_mediaService!.mediaFile.type != MediaType.audio;
Widget? imageWidget;
final quotedSticker = StickerService.decodeAdditional(
_message?.additionalMessageData,
);
if (hasImage) {
final isVideo = _mediaService!.mediaFile.type == MediaType.video;
// A story nobody saved is still in its temporary file.
@ -269,6 +280,16 @@ class _ResponsePreviewState extends State<ResponsePreview> {
),
),
);
} else if (quotedSticker != null) {
imageWidget = Container(
height: 40,
width: 40,
margin: const EdgeInsets.only(left: 8),
child: Image.memory(
Uint8List.fromList(quotedSticker.webp),
fit: BoxFit.contain,
),
);
}
return Container(

View file

@ -85,6 +85,11 @@ class _CreditsViewState extends State<CreditsView> {
title: 'Avatar Icons',
url: 'https://github.com/RoadTripMoustache/avatar_maker',
),
const UrlListTitle(
title: 'Sticker background removal: U²-Net-P',
subtitle: 'Apache-2.0 · Xuebin Qin and contributors',
url: 'https://github.com/xuebinqin/U-2-Net',
),
const Divider(),
const ListTile(
title: Center(

View file

@ -211,6 +211,7 @@ flutter:
assets:
# Add assets from the images directory to the application.
- assets/images/
- assets/licenses/
- assets/icons/
- assets/icons/verification_badge_numeric/
- assets/animated_icons/

846
rust/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -78,6 +78,7 @@ bon = "3.9.3"
stream-tungstenite = "0.6.1"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
webp = "0.3.1"
tract = { version = "0.23.8", default-features = false, features = ["onnx"] }
[target.'cfg(target_os = "ios")'.dependencies]
# iOS backend: Requires the 'protected' feature for Data Protection Keychain
apple-native-keyring-store = { version = "1", features = ["protected"] }

11
rust/models/README.md Normal file
View file

@ -0,0 +1,11 @@
# Bundled sticker segmentation model
`u2netp.onnx` is the compact U²-Net portrait/object segmentation model used to
create stickers entirely on device. It is bundled for the first release; a
later model manager can replace the bytes after verifying the same digest.
- Source: <https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx>
- Architecture/source project: <https://github.com/xuebinqin/U-2-Net>
- License: Apache-2.0
- License text distributed with the app: `assets/licenses/u2net.txt`
- SHA-256: `309c8469258dda742793dce0ebea8e6dd393174f89934733ecc8b14c76f4ddd8`

BIN
rust/models/u2netp.onnx Normal file

Binary file not shown.

View file

@ -22,6 +22,19 @@ pub(crate) async fn handle_text_message(
let timestamp = milliseconds_to_seconds(message.timestamp);
let additional_message_data = match message.additional_message_data.as_deref() {
Some(data) => match crate::services::stickers::validate_additional_if_sticker(data) {
Ok(()) => Some(data),
Err(error) => {
// Keep the fallback text so an invalid attachment cannot make
// a reliable-mailbox message retry forever.
tracing::warn!(%error, "discarding invalid sticker attachment");
None
}
},
None => None,
};
NewMessage::builder()
.group_id(group_id)
.message_id(&message.sender_message_id)
@ -30,7 +43,7 @@ pub(crate) async fn handle_text_message(
.sender_id(from_user_id)
.content(&message.text)
.maybe_quotes_message_id(message.quote_message_id.as_deref())
.maybe_additional_message_data(message.additional_message_data.as_deref())
.maybe_additional_message_data(additional_message_data)
.ack_by_server(current_time().timestamp())
.build()
.insert(t)

View file

@ -17,6 +17,7 @@ message AdditionalMessageData {
WEBXDC_SENT = 6;
WEBXDC_SYNC = 7;
WEBXDC_SYNC_REQUEST = 8;
STICKER = 9;
}
Type type = 1;
@ -29,6 +30,18 @@ message AdditionalMessageData {
optional WebxdcOrigin webxdc_origin = 8;
optional WebxdcSyncChunk webxdc_sync = 9;
optional WebxdcSyncRequest webxdc_sync_request = 10;
optional StickerData sticker = 11;
}
// A small, self-contained sticker. Keeping the bytes beside the text message
// makes it available immediately and preserves end-to-end encryption without
// introducing a second media download lifecycle.
message StickerData {
uint32 version = 1;
bytes webp = 2;
bytes sha256 = 3;
uint32 width = 4;
uint32 height = 5;
}
// A peer-to-peer transfer of existing one-time app state. The encoded payload

View file

@ -14,10 +14,14 @@ use crate::services::contacts::ContactService;
use crate::services::media_upload::{MediaSizeReport, MediaUploadService};
use crate::services::messages::MessageService;
use crate::services::outbox_dispatch::OutboxDispatchService;
use crate::services::stickers;
use crate::services::stories::StoryAudience;
use crate::user_config::UserConfig;
use flutter_rust_bridge::frb;
use std::collections::HashMap;
use std::path::Path;
pub use crate::services::stickers::StickerOutput;
#[frb(ignore)]
pub enum ServerResult<T> {
@ -157,6 +161,17 @@ pub struct FrbMemoriesUploadUrls {
pub struct RustApi {}
impl RustApi {
/// Creates a transparent, message-sized sticker from a local image. Model
/// inference and image encoding are CPU-heavy, so neither runs on Tokio's
/// async worker threads.
pub async fn create_sticker(image_path: String) -> Result<StickerOutput> {
tokio::task::spawn_blocking(move || stickers::create(Path::new(&image_path)))
.await
.map_err(|error| {
TwonlyError::Generic(format!("sticker creation task failed: {error}"))
})?
}
#[frb(sync)]
pub fn decode_avatar_svg(avatar_svg_compressed: Vec<u8>) -> String {
avatars::decode_avatar_svg(avatar_svg_compressed)

View file

@ -0,0 +1,10 @@
CREATE TABLE stickers (
content_hash TEXT NOT NULL PRIMARY KEY CHECK(length(content_hash) = 64),
webp BLOB NOT NULL CHECK(length(webp) BETWEEN 1 AND 131072),
width INTEGER NOT NULL CHECK(width BETWEEN 1 AND 300),
height INTEGER NOT NULL CHECK(height BETWEEN 1 AND 300),
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER)),
last_used_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER))
);
CREATE INDEX stickers_last_used_at ON stickers(last_used_at DESC);

View file

@ -0,0 +1,2 @@
ALTER TABLE stickers ADD COLUMN usage_count INTEGER NOT NULL DEFAULT 0 CHECK(usage_count >= 0);
CREATE INDEX stickers_usage ON stickers(usage_count DESC, last_used_at DESC);

View file

@ -16,7 +16,7 @@ mod legacy_import;
pub mod tables;
pub const APP_DATABASE_FILE: &str = "app_db.sqlite";
pub const APP_SCHEMA_VERSION: i64 = 11;
pub const APP_SCHEMA_VERSION: i64 = 13;
/// User-owned application tables in the current Rust schema. Rust-only outbox
/// tables are deliberately absent because they are reconstructed locally.

View file

@ -116,7 +116,8 @@ impl Message {
.await?;
sqlx::query!(
r#"UPDATE messages
SET is_deleted_from_sender = 1, content = NULL, media_id = NULL, modified_at = ?
SET is_deleted_from_sender = 1, content = NULL, media_id = NULL,
additional_message_data = NULL, modified_at = ?
WHERE message_id = ? AND sender_id = ?"#,
timestamp,
message_id,

View file

@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1336092087;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1844664049;
// Section: executor
@ -1880,6 +1880,43 @@ fn wire__crate__bridge__api__rust_api_connection_state_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_create_sticker_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_api_create_sticker",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_image_path = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok =
crate::bridge::api::RustApi::create_sticker(api_image_path).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -7730,6 +7767,24 @@ impl SseDecode for crate::database::app::SqlValue {
}
}
impl SseDecode for crate::services::stickers::StickerOutput {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_webp = <Vec<u8>>::sse_decode(deserializer);
let mut var_sha256 = <Vec<u8>>::sse_decode(deserializer);
let mut var_contentHash = <String>::sse_decode(deserializer);
let mut var_width = <u32>::sse_decode(deserializer);
let mut var_height = <u32>::sse_decode(deserializer);
return crate::services::stickers::StickerOutput {
webp: var_webp,
sha256: var_sha256,
content_hash: var_contentHash,
width: var_width,
height: var_height,
};
}
}
impl SseDecode for crate::bridge::stories::StoryAudience {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -8134,132 +8189,133 @@ fn pde_ffi_dispatcher_primary_impl(
49 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len),
50 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len),
51 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len),
52 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len),
53 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len),
55 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len),
56 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len),
57 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len),
58 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len),
59 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len),
60 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len),
61 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len),
62 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len),
63 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len),
64 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len),
65 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len),
66 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len),
67 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len),
68 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_initialize_memory_location_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
139 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
142 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
145 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
146 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
147 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
149 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
157 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
158 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
159 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
160 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
161 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
162 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len),
163 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
164 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
165 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len),
166 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
167 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
168 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
169 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
170 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len),
171 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
172 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
173 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
175 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
176 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
177 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
178 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
179 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
52 => wire__crate__bridge__api__rust_api_create_sticker_impl(port, ptr, rust_vec_len, data_len),
53 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len),
54 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len),
56 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len),
57 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len),
58 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len),
59 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len),
60 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len),
61 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len),
62 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len),
63 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len),
64 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len),
65 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len),
66 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len),
67 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len),
68 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_initialize_memory_location_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
139 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
142 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
145 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
146 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
147 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
149 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
157 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
158 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
159 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
160 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
161 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
162 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
163 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len),
164 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
165 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
166 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len),
167 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
168 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
169 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
170 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
171 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len),
172 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
173 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
174 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
176 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
177 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
178 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
179 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
180 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -8275,15 +8331,15 @@ fn pde_ffi_dispatcher_sync_impl(
28 => wire__crate__bridge__stories__max_media_recipients_impl(ptr, rust_vec_len, data_len),
40 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len),
42 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len),
54 => {
55 => {
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len)
}
174 => wire__crate__bridge__user_config__user_config_api_clone_impl(
175 => wire__crate__bridge__user_config__user_config_api_clone_impl(
ptr,
rust_vec_len,
data_len,
),
180 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
181 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -9058,6 +9114,30 @@ impl flutter_rust_bridge::IntoIntoDart<crate::database::app::SqlValue>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::services::stickers::StickerOutput {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.webp.into_into_dart().into_dart(),
self.sha256.into_into_dart().into_dart(),
self.content_hash.into_into_dart().into_dart(),
self.width.into_into_dart().into_dart(),
self.height.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::services::stickers::StickerOutput
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::services::stickers::StickerOutput>
for crate::services::stickers::StickerOutput
{
fn into_into_dart(self) -> crate::services::stickers::StickerOutput {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for FrbWrapper<crate::bridge::stories::StoryAudience> {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@ -10214,6 +10294,17 @@ impl SseEncode for crate::database::app::SqlValue {
}
}
impl SseEncode for crate::services::stickers::StickerOutput {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<Vec<u8>>::sse_encode(self.webp, serializer);
<Vec<u8>>::sse_encode(self.sha256, serializer);
<String>::sse_encode(self.content_hash, serializer);
<u32>::sse_encode(self.width, serializer);
<u32>::sse_encode(self.height, serializer);
}
}
impl SseEncode for crate::bridge::stories::StoryAudience {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {

View file

@ -239,6 +239,7 @@ impl GroupService {
webxdc_origin: None,
webxdc_sync: None,
webxdc_sync_request: None,
sticker: None,
}
.encode_to_vec();

View file

@ -35,7 +35,7 @@ const MIN_CROPPED_EDGE: u32 = 10;
/// Encodes with libwebp. RGB input is encoded without an alpha channel so
/// opaque photos do not pay for one.
fn encode_webp(image: &DynamicImage, quality: f32) -> Result<Vec<u8>> {
pub(crate) fn encode_webp(image: &DynamicImage, quality: f32) -> Result<Vec<u8>> {
let total_started = Instant::now();
let has_alpha = image.color().has_alpha();
let conversion_started = Instant::now();

View file

@ -240,6 +240,9 @@ impl MessageService {
quote_message_id: Option<String>,
additional_message_data: Option<Vec<u8>>,
) -> Result<String> {
if let Some(data) = additional_message_data.as_deref() {
crate::services::stickers::validate_additional_if_sticker(data)?;
}
let database = self.ctx.app_db.read().await.clone();
let message_id = uuid::Uuid::new_v4().to_string();
let timestamp = chrono::Utc::now().timestamp_millis();
@ -415,6 +418,7 @@ impl MessageService {
webxdc_origin: None,
webxdc_sync: None,
webxdc_sync_request: None,
sticker: None,
}
.encode_to_vec();
self.insert_and_send_additional_data(group_id, "contacts".into(), data, false)
@ -452,6 +456,7 @@ impl MessageService {
webxdc_origin: None,
webxdc_sync: None,
webxdc_sync_request: None,
sticker: None,
}
.encode_to_vec();
self.insert_and_send_additional_data(group_id, "askAboutUser".into(), data, false)

View file

@ -17,5 +17,6 @@ pub mod mediafiles;
pub mod messages;
pub mod notifications;
pub mod outbox_dispatch;
pub mod stickers;
pub mod stories;
pub mod webxdc;

View file

@ -310,19 +310,30 @@ pub(crate) async fn record_incoming_event(
let conversation_id = content.group_id.clone();
let now = current_time().timestamp();
let draft = if let Some(message) = content.text_message.as_ref() {
let is_sticker = message
.additional_message_data
.as_deref()
.and_then(|data| {
crate::services::stickers::decode_additional(data)
.ok()
.flatten()
})
.is_some();
// An answer to the user's own story says so: an emoji is a reaction
// to it, anything else a reply.
let about_story = match message.quote_message_id.as_deref() {
Some(quoted) => is_own_story(transaction, quoted).await?,
None => false,
};
let (kind, reaction) = match (about_story, message.quote_message_id.is_some()) {
(true, _) if is_emoji_only(&message.text) => {
let (kind, reaction) = match (about_story, message.quote_message_id.is_some(), is_sticker) {
(true, _, true) => ("story_reply", None),
(true, _, false) if is_emoji_only(&message.text) => {
("story_reaction", Some(message.text.trim().to_owned()))
}
(true, _) => ("story_reply", None),
(false, true) => ("response", None),
(false, false) => ("text", None),
(true, _, false) => ("story_reply", None),
(false, _, true) => ("sticker", None),
(false, true, false) => ("response", None),
(false, false, false) => ("text", None),
};
Some(NotificationDraft {
event_id: receipt_id.to_owned(),
@ -516,7 +527,7 @@ async fn clear_stale_opened(database: &Arc<AppDatabase>) -> Result<()> {
UPDATE notification_outbox
SET cleared_at = ?
WHERE cleared_at IS NULL
AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc', 'story', 'story_reply', 'story_reaction')
AND kind IN ('text', 'response', 'sticker', 'image', 'video', 'audio', 'twonly', 'webxdc', 'story', 'story_reply', 'story_reaction')
AND EXISTS (
SELECT 1
FROM messages
@ -838,7 +849,7 @@ pub(crate) async fn clear_opened_messages(
SET cleared_at = ?
WHERE message_id = ?
AND cleared_at IS NULL
AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc', 'story', 'story_reply', 'story_reaction')
AND kind IN ('text', 'response', 'sticker', 'image', 'video', 'audio', 'twonly', 'webxdc', 'story', 'story_reply', 'story_reaction')
"#,
)
.bind(cleared_at)
@ -925,6 +936,7 @@ fn localized_body(locale: &str, row: &PendingRow) -> String {
let key = match row.kind.as_str() {
"text" => "notificationText",
"response" => "notificationResponse",
"sticker" => "notificationSticker",
"twonly" => "notificationTwonly",
"video" => "notificationVideo",
"image" => "notificationImage",

View file

@ -0,0 +1,352 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! On-device sticker creation and validation.
//!
//! The compact U²-Net model is compiled into the native library for now. The
//! digest check is intentionally kept beside model loading so a future model
//! downloader can feed these same trusted bytes without changing inference.
use crate::api::proto::client::{self as proto, additional_message_data};
use crate::error::{Result, TwonlyError};
use crate::services::media_codec;
use image::imageops::{self, FilterType};
use image::{DynamicImage, GrayImage, ImageDecoder, Luma, Rgba, RgbaImage};
use prost::Message as _;
use sha2::{Digest, Sha256};
use std::io::Cursor;
use std::path::Path;
use std::sync::OnceLock;
use tract::prelude::*;
const MODEL_BYTES: &[u8] = include_bytes!("../../models/u2netp.onnx");
const MODEL_SHA256: &str = "309c8469258dda742793dce0ebea8e6dd393174f89934733ecc8b14c76f4ddd8";
const MODEL_EDGE: u32 = 320;
const WORKING_EDGE: u32 = 900;
pub(crate) const MAX_STICKER_EDGE: u32 = 300;
pub(crate) const MAX_STICKER_BYTES: usize = 128 * 1024;
pub(crate) const STICKER_VERSION: u32 = 1;
const TRANSPARENT_ALPHA: u8 = 10;
static MODEL: OnceLock<std::result::Result<Runnable, String>> = OnceLock::new();
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StickerOutput {
pub webp: Vec<u8>,
pub sha256: Vec<u8>,
pub content_hash: String,
pub width: u32,
pub height: u32,
}
fn sticker_error(message: impl Into<String>) -> TwonlyError {
TwonlyError::Generic(message.into())
}
fn model() -> Result<&'static Runnable> {
let loaded = MODEL.get_or_init(|| {
let digest = hex::encode(Sha256::digest(MODEL_BYTES));
if digest != MODEL_SHA256 {
return Err(format!(
"bundled sticker model digest mismatch: expected {MODEL_SHA256}, got {digest}"
));
}
tract::onnx()
.and_then(|onnx| onnx.load_buffer(MODEL_BYTES))
.and_then(|model| model.into_model())
.and_then(|model| model.into_runnable())
.map_err(|error| format!("could not load bundled sticker model: {error}"))
});
loaded
.as_ref()
.map_err(|error| sticker_error(error.clone()))
}
/// Removes the background from `source`, trims transparent borders and returns
/// a compact transparent WebP suitable for embedding in a text message.
pub(crate) fn create(source: &Path) -> Result<StickerOutput> {
let source = media_codec::decode(source)?;
if source.width() == 0 || source.height() == 0 {
return Err(sticker_error("the sticker source image is empty"));
}
// There is no visual value in carrying a multi-megapixel source through
// compositing when the wire result is capped at 300 px. Keeping a 3x
// working image still gives the resized segmentation mask room to soften.
let source = source.thumbnail(WORKING_EDGE, WORKING_EDGE).to_rgba8();
let input_image = imageops::resize(
&DynamicImage::ImageRgba8(source.clone()).to_rgb8(),
MODEL_EDGE,
MODEL_EDGE,
FilterType::Triangle,
);
let pixel_count = (MODEL_EDGE * MODEL_EDGE) as usize;
let mut input = vec![0.0_f32; pixel_count * 3];
const MEAN: [f32; 3] = [0.485, 0.456, 0.406];
const STD: [f32; 3] = [0.229, 0.224, 0.225];
for (index, pixel) in input_image.pixels().enumerate() {
for channel in 0..3 {
input[channel * pixel_count + index] =
(f32::from(pixel[channel]) / 255.0 - MEAN[channel]) / STD[channel];
}
}
let tensor = Tensor::from_slice(&[1, 3, MODEL_EDGE as usize, MODEL_EDGE as usize], &input)
.map_err(|error| sticker_error(format!("could not create sticker input: {error}")))?;
let outputs = model()?
.run([tensor])
.map_err(|error| sticker_error(format!("sticker background removal failed: {error}")))?;
let foreground = outputs
.first()
.ok_or_else(|| sticker_error("sticker model returned no mask"))?
.as_slice::<f32>()
.map_err(|error| sticker_error(format!("sticker mask has an invalid type: {error}")))?;
if foreground.len() < pixel_count {
return Err(sticker_error("sticker model returned a truncated mask"));
}
let foreground = &foreground[..pixel_count];
let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
for value in foreground.iter().copied().filter(|value| value.is_finite()) {
min = min.min(value);
max = max.max(value);
}
if !min.is_finite() || !max.is_finite() || max - min < f32::EPSILON {
return Err(sticker_error("sticker model produced an empty mask"));
}
let mask = GrayImage::from_fn(MODEL_EDGE, MODEL_EDGE, |x, y| {
let value = foreground[(y * MODEL_EDGE + x) as usize];
let normalized = ((value - min) / (max - min)).clamp(0.0, 1.0);
Luma([(normalized * 255.0).round() as u8])
});
let mask = imageops::resize(
&mask,
source.width(),
source.height(),
FilterType::CatmullRom,
);
let mut composited = source;
for (pixel, alpha) in composited.pixels_mut().zip(mask.pixels()) {
pixel[3] = ((u16::from(pixel[3]) * u16::from(alpha[0])) / 255) as u8;
}
let bounds = visible_bounds(&composited)
.ok_or_else(|| sticker_error("no foreground was found in this image"))?;
let cropped = imageops::crop_imm(
&composited,
bounds.0,
bounds.1,
bounds.2 - bounds.0 + 1,
bounds.3 - bounds.1 + 1,
)
.to_image();
let padding =
((cropped.width().max(cropped.height()) as f32 * 0.025).round() as u32).clamp(2, 18);
let mut padded = RgbaImage::from_pixel(
cropped.width() + padding * 2,
cropped.height() + padding * 2,
Rgba([0, 0, 0, 0]),
);
imageops::overlay(
&mut padded,
&cropped,
i64::from(padding),
i64::from(padding),
);
let mut sticker =
DynamicImage::ImageRgba8(padded).thumbnail(MAX_STICKER_EDGE, MAX_STICKER_EDGE);
let mut encoded = Vec::new();
'encode: for edge in [300_u32, 280, 256, 224, 192] {
if sticker.width() > edge || sticker.height() > edge {
sticker = sticker.thumbnail(edge, edge);
}
for quality in [90.0_f32, 80.0, 70.0, 60.0] {
encoded = media_codec::encode_webp(&sticker, quality)?;
if encoded.len() <= MAX_STICKER_BYTES {
break 'encode;
}
}
}
if encoded.len() > MAX_STICKER_BYTES {
return Err(sticker_error(format!(
"sticker is too large after compression: {} bytes",
encoded.len()
)));
}
let sha256 = Sha256::digest(&encoded).to_vec();
let output = StickerOutput {
content_hash: hex::encode(&sha256),
sha256,
width: sticker.width(),
height: sticker.height(),
webp: encoded,
};
validate_sticker(&output.as_proto())?;
Ok(output)
}
fn visible_bounds(image: &RgbaImage) -> Option<(u32, u32, u32, u32)> {
let (mut left, mut top) = (image.width(), image.height());
let (mut right, mut bottom) = (0, 0);
let mut found = false;
for (x, y, pixel) in image.enumerate_pixels() {
if pixel[3] <= TRANSPARENT_ALPHA {
continue;
}
found = true;
left = left.min(x);
top = top.min(y);
right = right.max(x);
bottom = bottom.max(y);
}
found.then_some((left, top, right, bottom))
}
impl StickerOutput {
pub(crate) fn as_proto(&self) -> proto::StickerData {
proto::StickerData {
version: STICKER_VERSION,
webp: self.webp.clone(),
sha256: self.sha256.clone(),
width: self.width,
height: self.height,
}
}
}
/// Returns the sticker carried by additional message data after checking all
/// wire bounds and its content digest.
pub(crate) fn decode_additional(bytes: &[u8]) -> Result<Option<proto::StickerData>> {
let data = proto::AdditionalMessageData::decode(bytes)?;
if data.r#type != additional_message_data::Type::Sticker as i32 {
return Ok(None);
}
let sticker = data
.sticker
.ok_or_else(|| sticker_error("sticker message is missing its image"))?;
validate_sticker(&sticker)?;
Ok(Some(sticker))
}
pub(crate) fn validate_additional_if_sticker(bytes: &[u8]) -> Result<()> {
let _ = decode_additional(bytes)?;
Ok(())
}
fn validate_sticker(sticker: &proto::StickerData) -> Result<()> {
if sticker.version != STICKER_VERSION {
return Err(sticker_error(format!(
"unsupported sticker version {}",
sticker.version
)));
}
if sticker.webp.is_empty() || sticker.webp.len() > MAX_STICKER_BYTES {
return Err(sticker_error("sticker WebP has an invalid size"));
}
if sticker.sha256.len() != 32 || Sha256::digest(&sticker.webp).as_slice() != sticker.sha256 {
return Err(sticker_error("sticker digest does not match its bytes"));
}
if sticker.width == 0
|| sticker.height == 0
|| sticker.width > MAX_STICKER_EDGE
|| sticker.height > MAX_STICKER_EDGE
{
return Err(sticker_error(
"sticker dimensions are outside the allowed bounds",
));
}
if image::guess_format(&sticker.webp).ok() != Some(image::ImageFormat::WebP) {
return Err(sticker_error("sticker payload is not WebP"));
}
let decoder = image::codecs::webp::WebPDecoder::new(Cursor::new(&sticker.webp))
.map_err(|error| sticker_error(format!("invalid sticker WebP: {error}")))?;
// Read the actual dimensions before allocating pixels. Declared protobuf
// dimensions cannot be trusted to bound a peer's image decoder allocation.
if decoder.dimensions() != (sticker.width, sticker.height) {
return Err(sticker_error("sticker dimensions do not match its WebP"));
}
if decoder.has_animation() {
return Err(sticker_error("animated stickers are not supported"));
}
let decoded = DynamicImage::from_decoder(decoder)
.map_err(|error| sticker_error(format!("invalid sticker WebP: {error}")))?;
if !decoded.color().has_alpha() || !decoded.to_rgba8().pixels().any(|pixel| pixel[3] > 0) {
return Err(sticker_error("sticker has no visible transparent image"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_sticker() -> proto::StickerData {
let image = DynamicImage::ImageRgba8(RgbaImage::from_fn(32, 24, |x, y| {
if x > 4 && x < 28 && y > 4 && y < 20 {
Rgba([240, 50, 80, 255])
} else {
Rgba([0, 0, 0, 0])
}
}));
let webp = media_codec::encode_webp(&image, 90.0).unwrap();
proto::StickerData {
version: STICKER_VERSION,
sha256: Sha256::digest(&webp).to_vec(),
webp,
width: 32,
height: 24,
}
}
#[test]
fn sticker_wire_data_round_trips_and_rejects_tampering() {
let sticker = sample_sticker();
let data = proto::AdditionalMessageData {
r#type: additional_message_data::Type::Sticker as i32,
sticker: Some(sticker.clone()),
..Default::default()
};
assert_eq!(
decode_additional(&data.encode_to_vec()).unwrap(),
Some(sticker.clone())
);
let mut changed = sticker.clone();
changed.webp[20] ^= 1;
assert!(validate_sticker(&changed).is_err());
let mut changed = sticker.clone();
changed.width = 300;
assert!(validate_sticker(&changed).is_err());
let mut changed = sticker;
changed.version = 2;
assert!(validate_sticker(&changed).is_err());
}
#[test]
fn normal_additional_data_is_not_a_sticker() {
let data = proto::AdditionalMessageData::default();
assert!(decode_additional(&data.encode_to_vec()).unwrap().is_none());
}
#[test]
fn bundled_model_creates_a_bounded_transparent_webp() {
let image = Path::new(env!("CARGO_MANIFEST_DIR")).join("../assets/images/logo.png");
let sticker = create(&image).expect("bundled model must load and run");
assert!(sticker.width <= 300 && sticker.height <= 300);
assert!(sticker.webp.len() <= MAX_STICKER_BYTES);
assert_eq!(
sticker.content_hash,
hex::encode(Sha256::digest(&sticker.webp))
);
validate_sticker(&sticker.as_proto()).unwrap();
let image = image::load_from_memory(&sticker.webp).unwrap().to_rgba8();
assert!(image.pixels().any(|pixel| pixel[3] == 0));
assert!(image.pixels().any(|pixel| pixel[3] > 10));
}
}

View file

@ -0,0 +1,75 @@
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/database/twonly.db.dart';
void main() {
late TwonlyDB database;
setUp(() => database = TwonlyDB(NativeDatabase.memory()));
tearDown(() => database.close());
test('identical stickers are deduplicated without resetting usage', () async {
final bytes = Uint8List.fromList([1, 2, 3]);
final hash = sha256.convert(bytes).toString();
expect(
await database.stickersDao.save(webp: bytes, width: 30, height: 20),
isTrue,
);
await database.stickersDao.recordUse(hash);
expect(
await database.stickersDao.save(webp: bytes, width: 30, height: 20),
isFalse,
);
final stickers = await database.stickersDao.watchAll().first;
expect(stickers, hasLength(1));
expect(stickers.single.usageCount, 1);
});
test(
'most used stickers come first and removal only removes the selected one',
() async {
final first = Uint8List.fromList([1]);
final second = Uint8List.fromList([2]);
final firstHash = sha256.convert(first).toString();
final secondHash = sha256.convert(second).toString();
await database.stickersDao.save(webp: first, width: 30, height: 20);
await database.stickersDao.save(webp: second, width: 30, height: 20);
await database.stickersDao.recordUse(secondHash);
await database.stickersDao.recordUse(firstHash);
await database.stickersDao.recordUse(firstHash);
final stickers = await database.stickersDao.watchAll().first;
expect(stickers.map((sticker) => sticker.contentHash), [
firstHash,
secondHash,
]);
expect(stickers.map((sticker) => sticker.usageCount), [2, 1]);
await database.stickersDao.remove(firstHash);
expect(await database.stickersDao.getByHash(firstHash), isNull);
expect((await database.stickersDao.getByHash(secondHash))?.usageCount, 1);
},
);
test(
'false digests and invalid bounds cannot enter the collection',
() async {
final bytes = Uint8List.fromList([1]);
await expectLater(
database.stickersDao.save(
webp: bytes,
width: 30,
height: 20,
expectedHash: 'incorrect',
),
throwsArgumentError,
);
await expectLater(
database.stickersDao.save(webp: bytes, width: 301, height: 20),
throwsArgumentError,
);
expect(await database.stickersDao.watchAll().first, isEmpty);
},
);
}

View file

@ -5,6 +5,7 @@ void main() {
test('text notifications open their conversation', () {
expect(NativeNotificationService.opensConversation('text'), isTrue);
expect(NativeNotificationService.opensConversation('response'), isTrue);
expect(NativeNotificationService.opensConversation('sticker'), isTrue);
});
test('replies and reactions to the own story open the conversation', () {

View file

@ -0,0 +1,206 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/stickers/sticker.service.dart';
import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart';
import 'package:twonly/src/visual/components/sticker_picker.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_sticker.entry.dart';
// A small valid image for exercising image widgets; protocol codec validation
// and real transparent WebP inference are covered by the Rust sticker tests.
final Uint8List _image = File(
'assets/images/default_avatar.png',
).readAsBytesSync();
StickerData _sticker() => StickerData(
version: 1,
webp: _image,
sha256: sha256.convert(_image).bytes,
width: 1,
height: 1,
);
Widget _app(Widget child) => MaterialApp(
theme: lightTheme,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Align(alignment: Alignment.bottomCenter, child: child),
),
);
Future<void> _settle(WidgetTester tester) async {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pumpAndSettle();
}
void main() {
late TwonlyDB database;
setUp(() async {
SharedPreferences.setMockInitialValues({});
await locator.reset();
database = TwonlyDB(NativeDatabase.memory());
locator.registerSingleton<TwonlyDB>(database);
await database.customSelect('SELECT 1').get();
});
tearDown(() async {
await database.close();
await locator.reset();
});
testWidgets(
'tabs and resized drag handle stay in place across picker modes',
(tester) async {
var showStickers = false;
await tester.pumpWidget(
_app(
StatefulBuilder(
builder: (context, setState) {
return EmojiPicker(
alternateView: showStickers
? StickerPicker(
onEmojiPressed: () =>
setState(() => showStickers = false),
onStickerSelected: (_) {},
)
: null,
config: Config(
height: 300,
checkPlatformCompatibility: false,
bottomActionBarConfig: BottomActionBarConfig(
onStickerButtonPressed: () =>
setState(() => showStickers = true),
),
),
);
},
),
),
);
await _settle(tester);
final container = find.byKey(const Key('emojiPickerResizableContainer'));
final handle = find.byKey(const Key('emojiPickerDragHandle'));
await tester.drag(handle, const Offset(0, -80));
await _settle(tester);
final height = tester.getSize(container).height;
expect(height, greaterThan(300));
final emojiRect = tester.getRect(find.text('Emoji'));
final stickerRect = tester.getRect(find.text('Sticker'));
final handleRect = tester.getRect(handle);
await tester.tap(find.text('Sticker'));
await _settle(tester);
expect(tester.getRect(find.text('Emoji')), emojiRect);
expect(tester.getRect(find.text('Sticker')), stickerRect);
expect(tester.getRect(handle), handleRect);
expect(tester.getSize(container).height, height);
await tester.tap(find.text('Emoji'));
await _settle(tester);
expect(tester.getSize(container).height, height);
expect(tester.getRect(handle), handleRect);
},
);
testWidgets('chat tap previews without saving; buttons save then remove', (
tester,
) async {
await tester.pumpWidget(
_app(
ChatStickerEntry(
message: Message(
groupId: 'group',
messageId: 'message',
senderId: 42,
type: 'text',
mediaStored: false,
mediaReopened: false,
isDeletedFromSender: false,
isWidgetMedia: false,
isStory: false,
createdAt: DateTime.now(),
),
sticker: _sticker(),
),
),
);
await _settle(tester);
await tester.tap(find.byType(ChatStickerEntry));
await _settle(tester);
expect(find.byType(Dialog), findsOneWidget);
expect(find.text('Save sticker'), findsOneWidget);
expect(
await tester.runAsync(() => database.stickersDao.watchAll().first),
isEmpty,
);
await tester.tap(find.byType(MyButton));
await _settle(tester);
expect(find.byType(Dialog), findsNothing);
expect(
await tester.runAsync(() => database.stickersDao.watchAll().first),
hasLength(1),
);
await tester.tap(find.byType(ChatStickerEntry));
await _settle(tester);
expect(find.text('Remove from collection'), findsOneWidget);
await tester.tap(find.byType(MyButton));
await _settle(tester);
expect(
await tester.runAsync(() => database.stickersDao.watchAll().first),
isEmpty,
);
expect(find.byType(ChatStickerEntry), findsOneWidget);
});
testWidgets('long press in collection previews deletion without selecting', (
tester,
) async {
await tester.runAsync(() => StickerService.saveReceived(_sticker()));
var selected = false;
await tester.pumpWidget(
_app(
SizedBox(
height: 300,
child: StickerPicker(
onEmojiPressed: () {},
onStickerSelected: (_) => selected = true,
),
),
),
);
await _settle(tester);
final tile = find.byKey(ValueKey(sha256.convert(_image).toString()));
await tester.longPress(tile);
await _settle(tester);
expect(selected, isFalse);
expect(find.text('Delete sticker'), findsOneWidget);
expect(
await tester.runAsync(() => database.stickersDao.watchAll().first),
hasLength(1),
);
await tester.tap(find.byType(MyButton));
await _settle(tester);
expect(find.byType(Dialog), findsNothing);
expect(
await tester.runAsync(() => database.stickersDao.watchAll().first),
isEmpty,
);
expect(selected, isFalse);
// Dispose the live Drift query while the test can still drain its timers.
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
});
}