mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
sticker feature with onnx model
This commit is contained in:
parent
ce14681276
commit
b339bbf923
64 changed files with 4445 additions and 396 deletions
201
assets/licenses/u2net.txt
Normal file
201
assets/licenses/u2net.txt
Normal 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.
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
42
lib/core/services/stickers.dart
Normal file
42
lib/core/services/stickers.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -401,6 +401,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
isDeletedFromSender: Value(true),
|
||||
content: Value(null),
|
||||
mediaId: Value(null),
|
||||
additionalMessageData: Value(null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
81
lib/src/database/daos/stickers.dao.dart
Normal file
81
lib/src/database/daos/stickers.dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
16
lib/src/database/daos/stickers.dao.g.dart
Normal file
16
lib/src/database/daos/stickers.dao.g.dart
Normal 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);
|
||||
}
|
||||
15
lib/src/database/tables/stickers.table.dart
Normal file
15
lib/src/database/tables/stickers.table.dart
Normal 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};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => 'حذف الملصق';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -836,5 +836,17 @@
|
|||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sticker": "ملصق",
|
||||
"stickers": "الملصقات",
|
||||
"createSticker": "إنشاء ملصق",
|
||||
"noStickersYet": "أنشئ ملصقك الأول من صورة.",
|
||||
"stickerAdded": "تم حفظ الملصق",
|
||||
"stickerAlreadySaved": "هذا الملصق محفوظ بالفعل",
|
||||
"stickerCreateFailed": "تعذّر إنشاء الملصق.",
|
||||
"stickerSaveFailed": "تعذّر حفظ الملصق.",
|
||||
"notificationSticker": "أرسل ملصقًا{inGroup}.",
|
||||
"storeSticker": "حفظ الملصق",
|
||||
"removeSticker": "إزالة من المجموعة",
|
||||
"deleteSticker": "حذف الملصق"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
104
lib/src/services/stickers/sticker.service.dart
Normal file
104
lib/src/services/stickers/sticker.service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
136
lib/src/visual/components/sticker_picker.dart
Normal file
136
lib/src/visual/components/sticker_picker.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
137
lib/src/visual/components/sticker_preview.dialog.dart
Normal file
137
lib/src/visual/components/sticker_preview.dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
846
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
11
rust/models/README.md
Normal 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
BIN
rust/models/u2netp.onnx
Normal file
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
10
rust/src/database/app/migrations/0027_stickers.sql
Normal file
10
rust/src/database/app/migrations/0027_stickers.sql
Normal 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);
|
||||
2
rust/src/database/app/migrations/0028_sticker_usage.sql
Normal file
2
rust/src/database/app/migrations/0028_sticker_usage.sql
Normal 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);
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ impl GroupService {
|
|||
webxdc_origin: None,
|
||||
webxdc_sync: None,
|
||||
webxdc_sync_request: None,
|
||||
sticker: None,
|
||||
}
|
||||
.encode_to_vec();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
352
rust/src/services/stickers.rs
Normal file
352
rust/src/services/stickers.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
75
test/drift/stickers_dao_test.dart
Normal file
75
test/drift/stickers_dao_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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', () {
|
||||
|
|
|
|||
206
test/visual/components/sticker_picker_test.dart
Normal file
206
test/visual/components/sticker_picker_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue