person avatar

This commit is contained in:
otsmr 2026-10-06 22:14:27 +02:00
parent 7d17ffba1f
commit 854121c8c4
36 changed files with 2663 additions and 527 deletions

@ -1 +1 @@
Subproject commit 8cf0eb53c6955201bea92d4515e0978e383640b3
Subproject commit c3d085882368d7269956048bbb69fbd8a69a711c

View file

@ -5,6 +5,7 @@
import '../api/server/prekeys.dart';
import '../frb_generated.dart';
import '../services/avatars.dart';
import '../services/media_upload.dart';
import '../services/stickers.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
@ -353,6 +354,14 @@ class RustApi {
static Future<ApiConnectionState> connectionState() =>
RustLib.instance.api.crateBridgeApiRustApiConnectionState();
static Future<CustomAvatarInfo> createCustomAvatar({
required String imagePath,
required bool acceptedContactsOnly,
}) => RustLib.instance.api.crateBridgeApiRustApiCreateCustomAvatar(
imagePath: imagePath,
acceptedContactsOnly: acceptedContactsOnly,
);
/// 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.
@ -426,6 +435,9 @@ class RustApi {
static Future<void> forceIpaCheck() =>
RustLib.instance.api.crateBridgeApiRustApiForceIpaCheck();
static Future<CustomAvatarInfo> getCustomAvatar() =>
RustLib.instance.api.crateBridgeApiRustApiGetCustomAvatar();
static Future<String> getMemoriesUrl({
required String mediaId,
required bool thumbnail,
@ -667,6 +679,9 @@ class RustApi {
userId: userId,
);
static Future<CustomAvatarInfo> removeCustomAvatar() =>
RustLib.instance.api.crateBridgeApiRustApiRemoveCustomAvatar();
/// Deletes every file of a media item while keeping its row.
static Future<void> removeMediaFiles({required String mediaId}) => RustLib
.instance
@ -800,6 +815,12 @@ class RustApi {
.api
.crateBridgeApiRustApiSetBackground(inBackground: inBackground);
static Future<CustomAvatarInfo> setCustomAvatarAudience({
required bool acceptedContactsOnly,
}) => RustLib.instance.api.crateBridgeApiRustApiSetCustomAvatarAudience(
acceptedContactsOnly: acceptedContactsOnly,
);
static Future<void> setHomeWidgetGroups({
required String widgetId,
required String platform,

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,7 @@ import 'frb_generated.dart';
import 'keys/backup_password_keys.dart';
import 'lib.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
import 'services/avatars.dart';
import 'services/media_upload.dart';
import 'services/stickers.dart';
import 'signal/engine.dart';
@ -137,6 +138,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw);
@protected
CustomAvatarInfo dco_decode_custom_avatar_info(dynamic raw);
@protected
double dco_decode_f_64(dynamic raw);
@ -559,6 +563,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
CustomAvatarInfo sse_decode_custom_avatar_info(SseDeserializer deserializer);
@protected
double sse_decode_f_64(SseDeserializer deserializer);
@ -1078,6 +1085,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_custom_avatar_info(
CustomAvatarInfo self,
SseSerializer serializer,
);
@protected
void sse_encode_f_64(double self, SseSerializer serializer);

View file

@ -28,6 +28,7 @@ import 'frb_generated.dart';
import 'keys/backup_password_keys.dart';
import 'lib.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
import 'services/avatars.dart';
import 'services/media_upload.dart';
import 'services/stickers.dart';
import 'signal/engine.dart';
@ -139,6 +140,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw);
@protected
CustomAvatarInfo dco_decode_custom_avatar_info(dynamic raw);
@protected
double dco_decode_f_64(dynamic raw);
@ -561,6 +565,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
CustomAvatarInfo sse_decode_custom_avatar_info(SseDeserializer deserializer);
@protected
double sse_decode_f_64(SseDeserializer deserializer);
@ -1080,6 +1087,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_custom_avatar_info(
CustomAvatarInfo self,
SseSerializer serializer,
);
@protected
void sse_encode_f_64(double self, SseSerializer serializer);

View file

@ -0,0 +1,46 @@
// 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 CustomAvatarInfo {
final Uint8List? webp;
final Uint8List? sha256;
final int? width;
final int? height;
final bool acceptedContactsOnly;
final PlatformInt64 sourceRevision;
const CustomAvatarInfo({
this.webp,
this.sha256,
this.width,
this.height,
required this.acceptedContactsOnly,
required this.sourceRevision,
});
@override
int get hashCode =>
webp.hashCode ^
sha256.hashCode ^
width.hashCode ^
height.hashCode ^
acceptedContactsOnly.hashCode ^
sourceRevision.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CustomAvatarInfo &&
runtimeType == other.runtimeType &&
webp == other.webp &&
sha256 == other.sha256 &&
width == other.width &&
height == other.height &&
acceptedContactsOnly == other.acceptedContactsOnly &&
sourceRevision == other.sourceRevision;
}

View file

@ -2,4 +2,6 @@ class KeyValueKeys {
static const String currentBackupState = 'current_backup_state';
static const String backupRecoveryState = 'backup_recovery_state';
static const String onboardingState = 'onboarding_state';
static const String shareImageWidgetExplainer =
'share_image_widget_explainer';
}

View file

@ -2440,6 +2440,78 @@ abstract class AppLocalizations {
/// **'Discard'**
String get avatarSaveChangesDiscard;
/// No description provided for @customAvatarSvgTab.
///
/// In en, this message translates to:
/// **'Public avatar'**
String get customAvatarSvgTab;
/// No description provided for @customAvatarCustomize.
///
/// In en, this message translates to:
/// **'Customize'**
String get customAvatarCustomize;
/// No description provided for @customAvatarPhotoTab.
///
/// In en, this message translates to:
/// **'Personal avatar'**
String get customAvatarPhotoTab;
/// No description provided for @customAvatarExplanation.
///
/// In en, this message translates to:
/// **'Replaces your public avatar for the selected audience.'**
String get customAvatarExplanation;
/// No description provided for @customAvatarCreate.
///
/// In en, this message translates to:
/// **'Choose photo'**
String get customAvatarCreate;
/// No description provided for @customAvatarReplace.
///
/// In en, this message translates to:
/// **'Change photo'**
String get customAvatarReplace;
/// No description provided for @customAvatarRemove.
///
/// In en, this message translates to:
/// **'Remove'**
String get customAvatarRemove;
/// No description provided for @customAvatarAudience.
///
/// In en, this message translates to:
/// **'Who can see the photo avatar?'**
String get customAvatarAudience;
/// No description provided for @customAvatarAcceptedOnly.
///
/// In en, this message translates to:
/// **'Contacts only'**
String get customAvatarAcceptedOnly;
/// No description provided for @customAvatarEveryone.
///
/// In en, this message translates to:
/// **'Everyone'**
String get customAvatarEveryone;
/// No description provided for @customAvatarChooseSource.
///
/// In en, this message translates to:
/// **'Choose avatar photo'**
String get customAvatarChooseSource;
/// No description provided for @customAvatarCreateFailed.
///
/// In en, this message translates to:
/// **'The photo avatar could not be created.'**
String get customAvatarCreateFailed;
/// No description provided for @inProcess.
///
/// In en, this message translates to:

View file

@ -1398,6 +1398,43 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get avatarSaveChangesDiscard => 'تجاهل';
@override
String get customAvatarSvgTab => 'الصورة الرمزية العامة';
@override
String get customAvatarCustomize => 'تخصيص';
@override
String get customAvatarPhotoTab => 'الصورة الرمزية الشخصية';
@override
String get customAvatarExplanation =>
'تحل محل صورتك الرمزية العامة لدى الجمهور المحدد.';
@override
String get customAvatarCreate => 'اختيار صورة';
@override
String get customAvatarReplace => 'تغيير الصورة';
@override
String get customAvatarRemove => 'إزالة';
@override
String get customAvatarAudience => 'من يمكنه رؤية الصورة الرمزية؟';
@override
String get customAvatarAcceptedOnly => 'جهات الاتصال فقط';
@override
String get customAvatarEveryone => 'الجميع';
@override
String get customAvatarChooseSource => 'اختر صورة رمزية';
@override
String get customAvatarCreateFailed => 'تعذر إنشاء الصورة الرمزية.';
@override
String get inProcess => 'قيد المعالجة';

View file

@ -1387,6 +1387,44 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get avatarSaveChangesDiscard => 'Verwerfen';
@override
String get customAvatarSvgTab => 'Öffentlicher Avatar';
@override
String get customAvatarCustomize => 'Anpassen';
@override
String get customAvatarPhotoTab => 'Persönlicher Avatar';
@override
String get customAvatarExplanation =>
'Ersetzt deinen öffentlichen Avatar für den ausgewählten Personenkreis.';
@override
String get customAvatarCreate => 'Foto auswählen';
@override
String get customAvatarReplace => 'Foto ändern';
@override
String get customAvatarRemove => 'Entfernen';
@override
String get customAvatarAudience => 'Wer darf den Foto-Avatar sehen?';
@override
String get customAvatarAcceptedOnly => 'Nur Kontakte';
@override
String get customAvatarEveryone => 'Alle';
@override
String get customAvatarChooseSource => 'Avatar-Foto auswählen';
@override
String get customAvatarCreateFailed =>
'Der Foto-Avatar konnte nicht erstellt werden.';
@override
String get inProcess => 'Wird verarbeitet';

View file

@ -1379,6 +1379,44 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get avatarSaveChangesDiscard => 'Discard';
@override
String get customAvatarSvgTab => 'Public avatar';
@override
String get customAvatarCustomize => 'Customize';
@override
String get customAvatarPhotoTab => 'Personal avatar';
@override
String get customAvatarExplanation =>
'Replaces your public avatar for the selected audience.';
@override
String get customAvatarCreate => 'Choose photo';
@override
String get customAvatarReplace => 'Change photo';
@override
String get customAvatarRemove => 'Remove';
@override
String get customAvatarAudience => 'Who can see the photo avatar?';
@override
String get customAvatarAcceptedOnly => 'Contacts only';
@override
String get customAvatarEveryone => 'Everyone';
@override
String get customAvatarChooseSource => 'Choose avatar photo';
@override
String get customAvatarCreateFailed =>
'The photo avatar could not be created.';
@override
String get inProcess => 'In process';

View file

@ -390,6 +390,18 @@
"avatarSaveChanges": "هل تريد حفظ التغييرات؟",
"avatarSaveChangesStore": "حفظ",
"avatarSaveChangesDiscard": "تجاهل",
"customAvatarSvgTab": "الصورة الرمزية العامة",
"customAvatarCustomize": "تخصيص",
"customAvatarPhotoTab": "الصورة الرمزية الشخصية",
"customAvatarExplanation": "تحل محل صورتك الرمزية العامة لدى الجمهور المحدد.",
"customAvatarCreate": "اختيار صورة",
"customAvatarReplace": "تغيير الصورة",
"customAvatarRemove": "إزالة",
"customAvatarAudience": "من يمكنه رؤية الصورة الرمزية؟",
"customAvatarAcceptedOnly": "جهات الاتصال فقط",
"customAvatarEveryone": "الجميع",
"customAvatarChooseSource": "اختر صورة رمزية",
"customAvatarCreateFailed": "تعذر إنشاء الصورة الرمزية.",
"inProcess": "قيد المعالجة",
"draftMessage": "مسودة",
"voiceMessageSlideToCancel": "اسحب للإلغاء",

View file

@ -455,6 +455,18 @@
"avatarSaveChanges": "Möchtest du die Änderungen speichern?",
"avatarSaveChangesStore": "Speichern",
"avatarSaveChangesDiscard": "Verwerfen",
"customAvatarSvgTab": "Öffentlicher Avatar",
"customAvatarCustomize": "Anpassen",
"customAvatarPhotoTab": "Persönlicher Avatar",
"customAvatarExplanation": "Ersetzt deinen öffentlichen Avatar für den ausgewählten Personenkreis.",
"customAvatarCreate": "Foto auswählen",
"customAvatarReplace": "Foto ändern",
"customAvatarRemove": "Entfernen",
"customAvatarAudience": "Wer darf den Foto-Avatar sehen?",
"customAvatarAcceptedOnly": "Nur Kontakte",
"customAvatarEveryone": "Alle",
"customAvatarChooseSource": "Avatar-Foto auswählen",
"customAvatarCreateFailed": "Der Foto-Avatar konnte nicht erstellt werden.",
"inProcess": "Wird verarbeitet",
"draftMessage": "Entwurf",
"voiceMessageSlideToCancel": "Zum Abbrechen ziehen",

View file

@ -465,6 +465,18 @@
"avatarSaveChanges": "Would you like to save the changes?",
"avatarSaveChangesStore": "Save",
"avatarSaveChangesDiscard": "Discard",
"customAvatarSvgTab": "Public avatar",
"customAvatarCustomize": "Customize",
"customAvatarPhotoTab": "Personal avatar",
"customAvatarExplanation": "Replaces your public avatar for the selected audience.",
"customAvatarCreate": "Choose photo",
"customAvatarReplace": "Change photo",
"customAvatarRemove": "Remove",
"customAvatarAudience": "Who can see the photo avatar?",
"customAvatarAcceptedOnly": "Contacts only",
"customAvatarEveryone": "Everyone",
"customAvatarChooseSource": "Choose avatar photo",
"customAvatarCreateFailed": "The photo avatar could not be created.",
"inProcess": "In process",
"draftMessage": "Draft",
"voiceMessageSlideToCancel": "Slide to cancel",

View file

@ -1174,6 +1174,8 @@ class EncryptedContent_ContactUpdate extends $pb.GeneratedMessage {
$core.List<$core.int>? avatarSvgCompressed,
$core.String? username,
$core.String? displayName,
$core.bool? customAvatarRequested,
EncryptedContent_CustomAvatar? customAvatar,
}) {
final result = create();
if (type != null) result.type = type;
@ -1181,6 +1183,9 @@ class EncryptedContent_ContactUpdate extends $pb.GeneratedMessage {
result.avatarSvgCompressed = avatarSvgCompressed;
if (username != null) result.username = username;
if (displayName != null) result.displayName = displayName;
if (customAvatarRequested != null)
result.customAvatarRequested = customAvatarRequested;
if (customAvatar != null) result.customAvatar = customAvatar;
return result;
}
@ -1202,6 +1207,10 @@ class EncryptedContent_ContactUpdate extends $pb.GeneratedMessage {
2, _omitFieldNames ? '' : 'avatarSvgCompressed', $pb.PbFieldType.OY)
..aOS(3, _omitFieldNames ? '' : 'username')
..aOS(4, _omitFieldNames ? '' : 'displayName')
..aOB(5, _omitFieldNames ? '' : 'customAvatarRequested')
..aOM<EncryptedContent_CustomAvatar>(
6, _omitFieldNames ? '' : 'customAvatar',
subBuilder: EncryptedContent_CustomAvatar.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -1261,6 +1270,160 @@ class EncryptedContent_ContactUpdate extends $pb.GeneratedMessage {
$core.bool hasDisplayName() => $_has(3);
@$pb.TagNumber(4)
void clearDisplayName() => $_clearField(4);
/// Set on a REQUEST only by clients that understand CustomAvatar.
@$pb.TagNumber(5)
$core.bool get customAvatarRequested => $_getBF(4);
@$pb.TagNumber(5)
set customAvatarRequested($core.bool value) => $_setBool(4, value);
@$pb.TagNumber(5)
$core.bool hasCustomAvatarRequested() => $_has(4);
@$pb.TagNumber(5)
void clearCustomAvatarRequested() => $_clearField(5);
/// Set on UPDATE only in direct response to such a request.
@$pb.TagNumber(6)
EncryptedContent_CustomAvatar get customAvatar => $_getN(5);
@$pb.TagNumber(6)
set customAvatar(EncryptedContent_CustomAvatar value) => $_setField(6, value);
@$pb.TagNumber(6)
$core.bool hasCustomAvatar() => $_has(5);
@$pb.TagNumber(6)
void clearCustomAvatar() => $_clearField(6);
@$pb.TagNumber(6)
EncryptedContent_CustomAvatar ensureCustomAvatar() => $_ensure(5);
}
class EncryptedContent_CustomAvatar extends $pb.GeneratedMessage {
factory EncryptedContent_CustomAvatar({
$core.int? version,
$fixnum.Int64? publicationCounter,
EncryptedContent_CustomAvatar_State? state,
$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 (publicationCounter != null)
result.publicationCounter = publicationCounter;
if (state != null) result.state = state;
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;
}
EncryptedContent_CustomAvatar._();
factory EncryptedContent_CustomAvatar.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory EncryptedContent_CustomAvatar.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'EncryptedContent.CustomAvatar',
createEmptyInstance: create)
..aI(1, _omitFieldNames ? '' : 'version', fieldType: $pb.PbFieldType.OU3)
..aInt64(2, _omitFieldNames ? '' : 'publicationCounter')
..aE<EncryptedContent_CustomAvatar_State>(3, _omitFieldNames ? '' : 'state',
enumValues: EncryptedContent_CustomAvatar_State.values)
..a<$core.List<$core.int>>(
4, _omitFieldNames ? '' : 'webp', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
5, _omitFieldNames ? '' : 'sha256', $pb.PbFieldType.OY)
..aI(6, _omitFieldNames ? '' : 'width', fieldType: $pb.PbFieldType.OU3)
..aI(7, _omitFieldNames ? '' : 'height', fieldType: $pb.PbFieldType.OU3)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
EncryptedContent_CustomAvatar clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
EncryptedContent_CustomAvatar copyWith(
void Function(EncryptedContent_CustomAvatar) updates) =>
super.copyWith(
(message) => updates(message as EncryptedContent_CustomAvatar))
as EncryptedContent_CustomAvatar;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static EncryptedContent_CustomAvatar create() =>
EncryptedContent_CustomAvatar._();
@$core.override
EncryptedContent_CustomAvatar createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static EncryptedContent_CustomAvatar getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<EncryptedContent_CustomAvatar>(create);
static EncryptedContent_CustomAvatar? _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)
$fixnum.Int64 get publicationCounter => $_getI64(1);
@$pb.TagNumber(2)
set publicationCounter($fixnum.Int64 value) => $_setInt64(1, value);
@$pb.TagNumber(2)
$core.bool hasPublicationCounter() => $_has(1);
@$pb.TagNumber(2)
void clearPublicationCounter() => $_clearField(2);
@$pb.TagNumber(3)
EncryptedContent_CustomAvatar_State get state => $_getN(2);
@$pb.TagNumber(3)
set state(EncryptedContent_CustomAvatar_State value) => $_setField(3, value);
@$pb.TagNumber(3)
$core.bool hasState() => $_has(2);
@$pb.TagNumber(3)
void clearState() => $_clearField(3);
@$pb.TagNumber(4)
$core.List<$core.int> get webp => $_getN(3);
@$pb.TagNumber(4)
set webp($core.List<$core.int> value) => $_setBytes(3, value);
@$pb.TagNumber(4)
$core.bool hasWebp() => $_has(3);
@$pb.TagNumber(4)
void clearWebp() => $_clearField(4);
@$pb.TagNumber(5)
$core.List<$core.int> get sha256 => $_getN(4);
@$pb.TagNumber(5)
set sha256($core.List<$core.int> value) => $_setBytes(4, value);
@$pb.TagNumber(5)
$core.bool hasSha256() => $_has(4);
@$pb.TagNumber(5)
void clearSha256() => $_clearField(5);
@$pb.TagNumber(6)
$core.int get width => $_getIZ(5);
@$pb.TagNumber(6)
set width($core.int value) => $_setUnsignedInt32(5, value);
@$pb.TagNumber(6)
$core.bool hasWidth() => $_has(5);
@$pb.TagNumber(6)
void clearWidth() => $_clearField(6);
@$pb.TagNumber(7)
$core.int get height => $_getIZ(6);
@$pb.TagNumber(7)
set height($core.int value) => $_setUnsignedInt32(6, value);
@$pb.TagNumber(7)
$core.bool hasHeight() => $_has(6);
@$pb.TagNumber(7)
void clearHeight() => $_clearField(7);
}
class EncryptedContent_FlameSync extends $pb.GeneratedMessage {
@ -1775,6 +1938,8 @@ class EncryptedContent extends $pb.GeneratedMessage {
$core.bool? widgetSharingAllowed,
EncryptedContent_Story? story,
$fixnum.Int64? senderTwonlyScore,
$fixnum.Int64? senderCustomAvatarCounter,
$core.int? customAvatarProtocolVersion,
}) {
final result = create();
if (groupId != null) result.groupId = groupId;
@ -1816,6 +1981,10 @@ class EncryptedContent extends $pb.GeneratedMessage {
result.widgetSharingAllowed = widgetSharingAllowed;
if (story != null) result.story = story;
if (senderTwonlyScore != null) result.senderTwonlyScore = senderTwonlyScore;
if (senderCustomAvatarCounter != null)
result.senderCustomAvatarCounter = senderCustomAvatarCounter;
if (customAvatarProtocolVersion != null)
result.customAvatarProtocolVersion = customAvatarProtocolVersion;
return result;
}
@ -1896,6 +2065,9 @@ class EncryptedContent extends $pb.GeneratedMessage {
..aOM<EncryptedContent_Story>(30, _omitFieldNames ? '' : 'story',
subBuilder: EncryptedContent_Story.create)
..aInt64(31, _omitFieldNames ? '' : 'senderTwonlyScore')
..aInt64(32, _omitFieldNames ? '' : 'senderCustomAvatarCounter')
..aI(33, _omitFieldNames ? '' : 'customAvatarProtocolVersion',
fieldType: $pb.PbFieldType.OU3)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -2226,6 +2398,7 @@ class EncryptedContent extends $pb.GeneratedMessage {
@$pb.TagNumber(30)
EncryptedContent_Story ensureStory() => $_ensure(26);
/// Sum of all exchanged media, omitted when profile sharing is disabled.
@$pb.TagNumber(31)
$fixnum.Int64 get senderTwonlyScore => $_getI64(27);
@$pb.TagNumber(31)
@ -2234,6 +2407,27 @@ class EncryptedContent extends $pb.GeneratedMessage {
$core.bool hasSenderTwonlyScore() => $_has(27);
@$pb.TagNumber(31)
void clearSenderTwonlyScore() => $_clearField(31);
/// Per-recipient version of the custom photo-avatar state. Receivers request
/// the payload only when this advances; the image is never broadcast.
@$pb.TagNumber(32)
$fixnum.Int64 get senderCustomAvatarCounter => $_getI64(28);
@$pb.TagNumber(32)
set senderCustomAvatarCounter($fixnum.Int64 value) => $_setInt64(28, value);
@$pb.TagNumber(32)
$core.bool hasSenderCustomAvatarCounter() => $_has(28);
@$pb.TagNumber(32)
void clearSenderCustomAvatarCounter() => $_clearField(32);
@$pb.TagNumber(33)
$core.int get customAvatarProtocolVersion => $_getIZ(29);
@$pb.TagNumber(33)
set customAvatarProtocolVersion($core.int value) =>
$_setUnsignedInt32(29, value);
@$pb.TagNumber(33)
$core.bool hasCustomAvatarProtocolVersion() => $_has(29);
@$pb.TagNumber(33)
void clearCustomAvatarProtocolVersion() => $_clearField(33);
}
const $core.bool _omitFieldNames =

View file

@ -169,5 +169,26 @@ class EncryptedContent_ContactUpdate_Type extends $pb.ProtobufEnum {
const EncryptedContent_ContactUpdate_Type._(super.value, super.name);
}
class EncryptedContent_CustomAvatar_State extends $pb.ProtobufEnum {
static const EncryptedContent_CustomAvatar_State SVG_ONLY =
EncryptedContent_CustomAvatar_State._(
0, _omitEnumNames ? '' : 'SVG_ONLY');
static const EncryptedContent_CustomAvatar_State PHOTO =
EncryptedContent_CustomAvatar_State._(1, _omitEnumNames ? '' : 'PHOTO');
static const $core.List<EncryptedContent_CustomAvatar_State> values =
<EncryptedContent_CustomAvatar_State>[
SVG_ONLY,
PHOTO,
];
static final $core.List<EncryptedContent_CustomAvatar_State?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 1);
static EncryptedContent_CustomAvatar_State? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];
const EncryptedContent_CustomAvatar_State._(super.value, super.name);
}
const $core.bool _omitEnumNames =
$core.bool.fromEnvironment('protobuf.omit_enum_names');

View file

@ -82,13 +82,31 @@ const EncryptedContent$json = {
'10': 'senderTwonlyScore',
'17': true
},
{
'1': 'sender_custom_avatar_counter',
'3': 32,
'4': 1,
'5': 3,
'9': 7,
'10': 'senderCustomAvatarCounter',
'17': true
},
{
'1': 'custom_avatar_protocol_version',
'3': 33,
'4': 1,
'5': 13,
'9': 8,
'10': 'customAvatarProtocolVersion',
'17': true
},
{
'1': 'message_update',
'3': 5,
'4': 1,
'5': 11,
'6': '.EncryptedContent.MessageUpdate',
'9': 7,
'9': 9,
'10': 'messageUpdate',
'17': true
},
@ -98,7 +116,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.Media',
'9': 8,
'9': 10,
'10': 'media',
'17': true
},
@ -108,7 +126,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.MediaUpdate',
'9': 9,
'9': 11,
'10': 'mediaUpdate',
'17': true
},
@ -118,7 +136,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.ContactUpdate',
'9': 10,
'9': 12,
'10': 'contactUpdate',
'17': true
},
@ -128,7 +146,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.ContactRequest',
'9': 11,
'9': 13,
'10': 'contactRequest',
'17': true
},
@ -138,7 +156,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.FlameSync',
'9': 12,
'9': 14,
'10': 'flameSync',
'17': true
},
@ -148,7 +166,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.Reaction',
'9': 13,
'9': 15,
'10': 'reaction',
'17': true
},
@ -158,7 +176,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.TextMessage',
'9': 14,
'9': 16,
'10': 'textMessage',
'17': true
},
@ -168,7 +186,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.GroupCreate',
'9': 15,
'9': 17,
'10': 'groupCreate',
'17': true
},
@ -178,7 +196,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.GroupJoin',
'9': 16,
'9': 18,
'10': 'groupJoin',
'17': true
},
@ -188,7 +206,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.GroupUpdate',
'9': 17,
'9': 19,
'10': 'groupUpdate',
'17': true
},
@ -198,7 +216,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.ResendGroupPublicKey',
'9': 18,
'9': 20,
'10': 'resendGroupPublicKey',
'17': true
},
@ -208,7 +226,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.ErrorMessages',
'9': 19,
'9': 21,
'10': 'errorMessages',
'17': true
},
@ -218,7 +236,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.AdditionalDataMessage',
'9': 20,
'9': 22,
'10': 'additionalDataMessage',
'17': true
},
@ -228,7 +246,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.TypingIndicator',
'9': 21,
'9': 23,
'10': 'typingIndicator',
'17': true
},
@ -238,7 +256,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.UserDiscoveryRequest',
'9': 22,
'9': 24,
'10': 'userDiscoveryRequest',
'17': true
},
@ -248,7 +266,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.UserDiscoveryUpdate',
'9': 23,
'9': 25,
'10': 'userDiscoveryUpdate',
'17': true
},
@ -258,7 +276,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.KeyVerificationProof',
'9': 24,
'9': 26,
'10': 'keyVerificationProof',
'17': true
},
@ -268,7 +286,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.PasswordLessRecovery',
'9': 25,
'9': 27,
'10': 'passwordlessRecovery',
'17': true
},
@ -278,7 +296,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.PasswordLessRecoveryHeartbeat',
'9': 26,
'9': 28,
'10': 'passwordlessRecoveryHeartbeat',
'17': true
},
@ -288,7 +306,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.Story',
'9': 27,
'9': 29,
'10': 'story',
'17': true
},
@ -308,6 +326,7 @@ const EncryptedContent$json = {
EncryptedContent_MediaUpdate$json,
EncryptedContent_ContactRequest$json,
EncryptedContent_ContactUpdate$json,
EncryptedContent_CustomAvatar$json,
EncryptedContent_FlameSync$json,
EncryptedContent_TypingIndicator$json,
EncryptedContent_UserDiscoveryRequest$json,
@ -324,6 +343,8 @@ const EncryptedContent$json = {
{'1': '_ask_for_friend_promotions'},
{'1': '_widget_sharing_allowed'},
{'1': '_sender_twonly_score'},
{'1': '_sender_custom_avatar_counter'},
{'1': '_custom_avatar_protocol_version'},
{'1': '_message_update'},
{'1': '_media'},
{'1': '_media_update'},
@ -799,12 +820,33 @@ const EncryptedContent_ContactUpdate$json = {
'10': 'displayName',
'17': true
},
{
'1': 'custom_avatar_requested',
'3': 5,
'4': 1,
'5': 8,
'9': 3,
'10': 'customAvatarRequested',
'17': true
},
{
'1': 'custom_avatar',
'3': 6,
'4': 1,
'5': 11,
'6': '.EncryptedContent.CustomAvatar',
'9': 4,
'10': 'customAvatar',
'17': true
},
],
'4': [EncryptedContent_ContactUpdate_Type$json],
'8': [
{'1': '_avatar_svg_compressed'},
{'1': '_username'},
{'1': '_display_name'},
{'1': '_custom_avatar_requested'},
{'1': '_custom_avatar'},
],
};
@ -817,6 +859,65 @@ const EncryptedContent_ContactUpdate_Type$json = {
],
};
@$core.Deprecated('Use encryptedContentDescriptor instead')
const EncryptedContent_CustomAvatar$json = {
'1': 'CustomAvatar',
'2': [
{'1': 'version', '3': 1, '4': 1, '5': 13, '10': 'version'},
{
'1': 'publication_counter',
'3': 2,
'4': 1,
'5': 3,
'10': 'publicationCounter'
},
{
'1': 'state',
'3': 3,
'4': 1,
'5': 14,
'6': '.EncryptedContent.CustomAvatar.State',
'10': 'state'
},
{'1': 'webp', '3': 4, '4': 1, '5': 12, '9': 0, '10': 'webp', '17': true},
{
'1': 'sha256',
'3': 5,
'4': 1,
'5': 12,
'9': 1,
'10': 'sha256',
'17': true
},
{'1': 'width', '3': 6, '4': 1, '5': 13, '9': 2, '10': 'width', '17': true},
{
'1': 'height',
'3': 7,
'4': 1,
'5': 13,
'9': 3,
'10': 'height',
'17': true
},
],
'4': [EncryptedContent_CustomAvatar_State$json],
'8': [
{'1': '_webp'},
{'1': '_sha256'},
{'1': '_width'},
{'1': '_height'},
],
};
@$core.Deprecated('Use encryptedContentDescriptor instead')
const EncryptedContent_CustomAvatar_State$json = {
'1': 'State',
'2': [
{'1': 'SVG_ONLY', '2': 0},
{'1': 'PHOTO', '2': 1},
],
};
@$core.Deprecated('Use encryptedContentDescriptor instead')
const EncryptedContent_FlameSync$json = {
'1': 'FlameSync',
@ -905,115 +1006,129 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
'AQESPgoZYXNrX2Zvcl9mcmllbmRfcHJvbW90aW9ucxgZIAEoCEgEUhZhc2tGb3JGcmllbmRQcm'
'9tb3Rpb25ziAEBEjkKFndpZGdldF9zaGFyaW5nX2FsbG93ZWQYHSABKAhIBVIUd2lkZ2V0U2hh'
'cmluZ0FsbG93ZWSIAQESMwoTc2VuZGVyX3R3b25seV9zY29yZRgfIAEoA0gGUhFzZW5kZXJUd2'
'9ubHlTY29yZYgBARJLCg5tZXNzYWdlX3VwZGF0ZRgFIAEoCzIfLkVuY3J5cHRlZENvbnRlbnQu'
'TWVzc2FnZVVwZGF0ZUgHUg1tZXNzYWdlVXBkYXRliAEBEjIKBW1lZGlhGAYgASgLMhcuRW5jcn'
'lwdGVkQ29udGVudC5NZWRpYUgIUgVtZWRpYYgBARJFCgxtZWRpYV91cGRhdGUYByABKAsyHS5F'
'bmNyeXB0ZWRDb250ZW50Lk1lZGlhVXBkYXRlSAlSC21lZGlhVXBkYXRliAEBEksKDmNvbnRhY3'
'RfdXBkYXRlGAggASgLMh8uRW5jcnlwdGVkQ29udGVudC5Db250YWN0VXBkYXRlSApSDWNvbnRh'
'Y3RVcGRhdGWIAQESTgoPY29udGFjdF9yZXF1ZXN0GAkgASgLMiAuRW5jcnlwdGVkQ29udGVudC'
'5Db250YWN0UmVxdWVzdEgLUg5jb250YWN0UmVxdWVzdIgBARI/CgpmbGFtZV9zeW5jGAogASgL'
'MhsuRW5jcnlwdGVkQ29udGVudC5GbGFtZVN5bmNIDFIJZmxhbWVTeW5jiAEBEjsKCHJlYWN0aW'
'9uGAwgASgLMhouRW5jcnlwdGVkQ29udGVudC5SZWFjdGlvbkgNUghyZWFjdGlvbogBARJFCgx0'
'ZXh0X21lc3NhZ2UYDSABKAsyHS5FbmNyeXB0ZWRDb250ZW50LlRleHRNZXNzYWdlSA5SC3RleH'
'RNZXNzYWdliAEBEkUKDGdyb3VwX2NyZWF0ZRgOIAEoCzIdLkVuY3J5cHRlZENvbnRlbnQuR3Jv'
'dXBDcmVhdGVID1ILZ3JvdXBDcmVhdGWIAQESPwoKZ3JvdXBfam9pbhgPIAEoCzIbLkVuY3J5cH'
'RlZENvbnRlbnQuR3JvdXBKb2luSBBSCWdyb3VwSm9pbogBARJFCgxncm91cF91cGRhdGUYECAB'
'KAsyHS5FbmNyeXB0ZWRDb250ZW50Lkdyb3VwVXBkYXRlSBFSC2dyb3VwVXBkYXRliAEBEmIKF3'
'Jlc2VuZF9ncm91cF9wdWJsaWNfa2V5GBEgASgLMiYuRW5jcnlwdGVkQ29udGVudC5SZXNlbmRH'
'cm91cFB1YmxpY0tleUgSUhRyZXNlbmRHcm91cFB1YmxpY0tleYgBARJLCg5lcnJvcl9tZXNzYW'
'dlcxgSIAEoCzIfLkVuY3J5cHRlZENvbnRlbnQuRXJyb3JNZXNzYWdlc0gTUg1lcnJvck1lc3Nh'
'Z2VziAEBEmQKF2FkZGl0aW9uYWxfZGF0YV9tZXNzYWdlGBMgASgLMicuRW5jcnlwdGVkQ29udG'
'VudC5BZGRpdGlvbmFsRGF0YU1lc3NhZ2VIFFIVYWRkaXRpb25hbERhdGFNZXNzYWdliAEBElEK'
'EHR5cGluZ19pbmRpY2F0b3IYFCABKAsyIS5FbmNyeXB0ZWRDb250ZW50LlR5cGluZ0luZGljYX'
'RvckgVUg90eXBpbmdJbmRpY2F0b3KIAQESYQoWdXNlcl9kaXNjb3ZlcnlfcmVxdWVzdBgWIAEo'
'CzImLkVuY3J5cHRlZENvbnRlbnQuVXNlckRpc2NvdmVyeVJlcXVlc3RIFlIUdXNlckRpc2Nvdm'
'VyeVJlcXVlc3SIAQESXgoVdXNlcl9kaXNjb3ZlcnlfdXBkYXRlGBcgASgLMiUuRW5jcnlwdGVk'
'Q29udGVudC5Vc2VyRGlzY292ZXJ5VXBkYXRlSBdSE3VzZXJEaXNjb3ZlcnlVcGRhdGWIAQESYQ'
'oWa2V5X3ZlcmlmaWNhdGlvbl9wcm9vZhgYIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuS2V5VmVy'
'aWZpY2F0aW9uUHJvb2ZIGFIUa2V5VmVyaWZpY2F0aW9uUHJvb2aIAQESYAoVcGFzc3dvcmRsZX'
'NzX3JlY292ZXJ5GBogASgLMiYuRW5jcnlwdGVkQ29udGVudC5QYXNzd29yZExlc3NSZWNvdmVy'
'eUgZUhRwYXNzd29yZGxlc3NSZWNvdmVyeYgBARJ8Ch9wYXNzd29yZGxlc3NfcmVjb3ZlcnlfaG'
'VhcnRiZWF0GBsgASgLMi8uRW5jcnlwdGVkQ29udGVudC5QYXNzd29yZExlc3NSZWNvdmVyeUhl'
'YXJ0YmVhdEgaUh1wYXNzd29yZGxlc3NSZWNvdmVyeUhlYXJ0YmVhdIgBARIyCgVzdG9yeRgeIA'
'EoCzIXLkVuY3J5cHRlZENvbnRlbnQuU3RvcnlIG1IFc3RvcnmIAQEalgIKDUVycm9yTWVzc2Fn'
'ZXMSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuRXJyb3JNZXNzYWdlcy5UeXBlUg'
'R0eXBlEiwKEnJlbGF0ZWRfcmVjZWlwdF9pZBgCIAEoCVIQcmVsYXRlZFJlY2VpcHRJZCKcAQoE'
'VHlwZRI8CjhFUlJPUl9QUk9DRVNTSU5HX01FU1NBR0VfQ1JFQVRFRF9BQ0NPVU5UX1JFUVVFU1'
'RfSU5TVEVBRBAAEhgKFFVOS05PV05fTUVTU0FHRV9UWVBFEAISFwoTU0VTU0lPTl9PVVRfT0Zf'
'U1lOQxADEiMKH0dST1VQX05PVF9GT1VORF9PUl9OT1RfQV9NRU1CRVIQBBqHAQoLR3JvdXBDcm'
'VhdGUSGwoJc3RhdGVfa2V5GAMgASgMUghzdGF0ZUtleRIoChBncm91cF9wdWJsaWNfa2V5GAQg'
'ASgMUg5ncm91cFB1YmxpY0tleRIiCgpncm91cF9uYW1lGAUgASgJSABSCWdyb3VwTmFtZYgBAU'
'INCgtfZ3JvdXBfbmFtZRo1CglHcm91cEpvaW4SKAoQZ3JvdXBfcHVibGljX2tleRgBIAEoDFIO'
'Z3JvdXBQdWJsaWNLZXkaFgoUUmVzZW5kR3JvdXBQdWJsaWNLZXkayAIKC0dyb3VwVXBkYXRlEi'
'oKEWdyb3VwX2FjdGlvbl90eXBlGAEgASgJUg9ncm91cEFjdGlvblR5cGUSMwoTYWZmZWN0ZWRf'
'Y29udGFjdF9pZBgCIAEoA0gAUhFhZmZlY3RlZENvbnRhY3RJZIgBARIpCg5uZXdfZ3JvdXBfbm'
'FtZRgDIAEoCUgBUgxuZXdHcm91cE5hbWWIAQESVwombmV3X2RlbGV0ZV9tZXNzYWdlc19hZnRl'
'cl9taWxsaXNlY29uZHMYBCABKANIAlIibmV3RGVsZXRlTWVzc2FnZXNBZnRlck1pbGxpc2Vjb2'
'5kc4gBAUIWChRfYWZmZWN0ZWRfY29udGFjdF9pZEIRCg9fbmV3X2dyb3VwX25hbWVCKQonX25l'
'd19kZWxldGVfbWVzc2FnZXNfYWZ0ZXJfbWlsbGlzZWNvbmRzGogCCgtUZXh0TWVzc2FnZRIqCh'
'FzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVyTWVzc2FnZUlkEhIKBHRleHQYAiABKAlS'
'BHRleHQSHAoJdGltZXN0YW1wGAMgASgDUgl0aW1lc3RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZB'
'gEIAEoCUgAUg5xdW90ZU1lc3NhZ2VJZIgBARI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgF'
'IAEoDEgBUhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQFCEwoRX3F1b3RlX21lc3NhZ2VfaWRCGg'
'oYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGuYBChVBZGRpdGlvbmFsRGF0YU1lc3NhZ2USKgoR'
'c2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3NlbmRlck1lc3NhZ2VJZBIcCgl0aW1lc3RhbXAYAi'
'ABKANSCXRpbWVzdGFtcBISCgR0eXBlGAMgASgJUgR0eXBlEjsKF2FkZGl0aW9uYWxfbWVzc2Fn'
'ZV9kYXRhGAQgASgMSABSFWFkZGl0aW9uYWxNZXNzYWdlRGF0YYgBARIWCgZoaWRkZW4YBSABKA'
'hSBmhpZGRlbkIaChhfYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEaZAoIUmVhY3Rpb24SKgoRdGFy'
'Z2V0X21lc3NhZ2VfaWQYASABKAlSD3RhcmdldE1lc3NhZ2VJZBIUCgVlbW9qaRgCIAEoCVIFZW'
'1vamkSFgoGcmVtb3ZlGAMgASgIUgZyZW1vdmUavgIKDU1lc3NhZ2VVcGRhdGUSOAoEdHlwZRgB'
'IAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuTWVzc2FnZVVwZGF0ZS5UeXBlUgR0eXBlEi8KEXNlbm'
'Rlcl9tZXNzYWdlX2lkGAIgASgJSABSD3NlbmRlck1lc3NhZ2VJZIgBARI9ChttdWx0aXBsZV90'
'YXJnZXRfbWVzc2FnZV9pZHMYAyADKAlSGG11bHRpcGxlVGFyZ2V0TWVzc2FnZUlkcxIXCgR0ZX'
'h0GAQgASgJSAFSBHRleHSIAQESHAoJdGltZXN0YW1wGAUgASgDUgl0aW1lc3RhbXAiLQoEVHlw'
'ZRIKCgZERUxFVEUQABINCglFRElUX1RFWFQQARIKCgZPUEVORUQQAkIUChJfc2VuZGVyX21lc3'
'NhZ2VfaWRCBwoFX3RleHQauwYKBU1lZGlhEioKEXNlbmRlcl9tZXNzYWdlX2lkGAEgASgJUg9z'
'ZW5kZXJNZXNzYWdlSWQSMAoEdHlwZRgCIAEoDjIcLkVuY3J5cHRlZENvbnRlbnQuTWVkaWEuVH'
'lwZVIEdHlwZRJGCh1kaXNwbGF5X2xpbWl0X2luX21pbGxpc2Vjb25kcxgDIAEoA0gAUhpkaXNw'
'bGF5TGltaXRJbk1pbGxpc2Vjb25kc4gBARI3ChdyZXF1aXJlc19hdXRoZW50aWNhdGlvbhgEIA'
'EoCFIWcmVxdWlyZXNBdXRoZW50aWNhdGlvbhIcCgl0aW1lc3RhbXAYBSABKANSCXRpbWVzdGFt'
'cBItChBxdW90ZV9tZXNzYWdlX2lkGAYgASgJSAFSDnF1b3RlTWVzc2FnZUlkiAEBEioKDmRvd2'
'5sb2FkX3Rva2VuGAcgASgMSAJSDWRvd25sb2FkVG9rZW6IAQESKgoOZW5jcnlwdGlvbl9rZXkY'
'CCABKAxIA1INZW5jcnlwdGlvbktleYgBARIqCg5lbmNyeXB0aW9uX21hYxgJIAEoDEgEUg1lbm'
'NyeXB0aW9uTWFjiAEBEi4KEGVuY3J5cHRpb25fbm9uY2UYCiABKAxIBVIPZW5jcnlwdGlvbk5v'
'bmNliAEBEjsKF2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGAsgASgMSAZSFWFkZGl0aW9uYWxNZX'
'NzYWdlRGF0YYgBARIkCgt3aWRnZXRfb25seRgMIAEoCEgHUgp3aWRnZXRPbmx5iAEBIj4KBFR5'
'cGUSDAoIUkVVUExPQUQQABIJCgVJTUFHRRABEgkKBVZJREVPEAISBwoDR0lGEAMSCQoFQVVESU'
'8QBEIgCh5fZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHNCEwoRX3F1b3RlX21lc3NhZ2Vf'
'aWRCEQoPX2Rvd25sb2FkX3Rva2VuQhEKD19lbmNyeXB0aW9uX2tleUIRCg9fZW5jcnlwdGlvbl'
'9tYWNCEwoRX2VuY3J5cHRpb25fbm9uY2VCGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhQg4K'
'DF93aWRnZXRfb25seRpOCgVTdG9yeRItCgVtZWRpYRgBIAEoCzIXLkVuY3J5cHRlZENvbnRlbn'
'QuTWVkaWFSBW1lZGlhEhYKBm5vdGlmeRgCIAEoCFIGbm90aWZ5GqkBCgtNZWRpYVVwZGF0ZRI2'
'CgR0eXBlGAEgASgOMiIuRW5jcnlwdGVkQ29udGVudC5NZWRpYVVwZGF0ZS5UeXBlUgR0eXBlEi'
'oKEXRhcmdldF9tZXNzYWdlX2lkGAIgASgJUg90YXJnZXRNZXNzYWdlSWQiNgoEVHlwZRIMCghS'
'RU9QRU5FRBAAEgoKBlNUT1JFRBABEhQKEERFQ1JZUFRJT05fRVJST1IQAhp4Cg5Db250YWN0Um'
'VxdWVzdBI5CgR0eXBlGAEgASgOMiUuRW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdC5U'
'eXBlUgR0eXBlIisKBFR5cGUSCwoHUkVRVUVTVBAAEgoKBlJFSkVDVBABEgoKBkFDQ0VQVBACGq'
'QCCg1Db250YWN0VXBkYXRlEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkNvbnRh'
'Y3RVcGRhdGUuVHlwZVIEdHlwZRI3ChVhdmF0YXJfc3ZnX2NvbXByZXNzZWQYAiABKAxIAFITYX'
'ZhdGFyU3ZnQ29tcHJlc3NlZIgBARIfCgh1c2VybmFtZRgDIAEoCUgBUgh1c2VybmFtZYgBARIm'
'CgxkaXNwbGF5X25hbWUYBCABKAlIAlILZGlzcGxheU5hbWWIAQEiHwoEVHlwZRILCgdSRVFVRV'
'NUEAASCgoGVVBEQVRFEAFCGAoWX2F2YXRhcl9zdmdfY29tcHJlc3NlZEILCglfdXNlcm5hbWVC'
'DwoNX2Rpc3BsYXlfbmFtZRqvAQoJRmxhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASABKANSDG'
'ZsYW1lQ291bnRlchI5ChlsYXN0X2ZsYW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYXN0Rmxh'
'bWVDb3VudGVyQ2hhbmdlEh8KC2Jlc3RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEKDGZvcm'
'NlX3VwZGF0ZRgEIAEoCFILZm9yY2VVcGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCWlzX3R5'
'cGluZxgBIAEoCFIIaXNUeXBpbmcSHQoKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0Gj8KFF'
'VzZXJEaXNjb3ZlcnlSZXF1ZXN0EicKD2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3VycmVudFZl'
'cnNpb24aMQoTVXNlckRpc2NvdmVyeVVwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVzc2FnZX'
'MaPQoUS2V5VmVyaWZpY2F0aW9uUHJvb2YSJQoOY2FsY3VsYXRlZF9tYWMYASABKAxSDWNhbGN1'
'bGF0ZWRNYWMamwEKFFBhc3N3b3JkTGVzc1JlY292ZXJ5EjUKE3JlY292ZXJ5U2VjcmV0U2hhcm'
'UYASABKAxIAFITcmVjb3ZlcnlTZWNyZXRTaGFyZYgBARIWCgZkZWxldGUYAiABKAhSBmRlbGV0'
'ZRIcCgl0aHJlc2hvbGQYAyABKANSCXRocmVzaG9sZEIWChRfcmVjb3ZlcnlTZWNyZXRTaGFyZR'
'ozCh1QYXNzd29yZExlc3NSZWNvdmVyeUhlYXJ0YmVhdBISCgRoYXNoGAEgASgMUgRoYXNoQgsK'
'CV9ncm91cF9pZEIRCg9faXNfZGlyZWN0X2NoYXRCGQoXX3NlbmRlcl9wcm9maWxlX2NvdW50ZX'
'JCIAoeX3NlbmRlcl91c2VyX2Rpc2NvdmVyeV92ZXJzaW9uQhwKGl9hc2tfZm9yX2ZyaWVuZF9w'
'cm9tb3Rpb25zQhkKF193aWRnZXRfc2hhcmluZ19hbGxvd2VkQhYKFF9zZW5kZXJfdHdvbmx5X3'
'Njb3JlQhEKD19tZXNzYWdlX3VwZGF0ZUIICgZfbWVkaWFCDwoNX21lZGlhX3VwZGF0ZUIRCg9f'
'Y29udGFjdF91cGRhdGVCEgoQX2NvbnRhY3RfcmVxdWVzdEINCgtfZmxhbWVfc3luY0ILCglfcm'
'VhY3Rpb25CDwoNX3RleHRfbWVzc2FnZUIPCg1fZ3JvdXBfY3JlYXRlQg0KC19ncm91cF9qb2lu'
'Qg8KDV9ncm91cF91cGRhdGVCGgoYX3Jlc2VuZF9ncm91cF9wdWJsaWNfa2V5QhEKD19lcnJvcl'
'9tZXNzYWdlc0IaChhfYWRkaXRpb25hbF9kYXRhX21lc3NhZ2VCEwoRX3R5cGluZ19pbmRpY2F0'
'b3JCGQoXX3VzZXJfZGlzY292ZXJ5X3JlcXVlc3RCGAoWX3VzZXJfZGlzY292ZXJ5X3VwZGF0ZU'
'IZChdfa2V5X3ZlcmlmaWNhdGlvbl9wcm9vZkIYChZfcGFzc3dvcmRsZXNzX3JlY292ZXJ5QiIK'
'IF9wYXNzd29yZGxlc3NfcmVjb3ZlcnlfaGVhcnRiZWF0QggKBl9zdG9yeUoECBwQHQ==');
'9ubHlTY29yZYgBARJEChxzZW5kZXJfY3VzdG9tX2F2YXRhcl9jb3VudGVyGCAgASgDSAdSGXNl'
'bmRlckN1c3RvbUF2YXRhckNvdW50ZXKIAQESSAoeY3VzdG9tX2F2YXRhcl9wcm90b2NvbF92ZX'
'JzaW9uGCEgASgNSAhSG2N1c3RvbUF2YXRhclByb3RvY29sVmVyc2lvbogBARJLCg5tZXNzYWdl'
'X3VwZGF0ZRgFIAEoCzIfLkVuY3J5cHRlZENvbnRlbnQuTWVzc2FnZVVwZGF0ZUgJUg1tZXNzYW'
'dlVXBkYXRliAEBEjIKBW1lZGlhGAYgASgLMhcuRW5jcnlwdGVkQ29udGVudC5NZWRpYUgKUgVt'
'ZWRpYYgBARJFCgxtZWRpYV91cGRhdGUYByABKAsyHS5FbmNyeXB0ZWRDb250ZW50Lk1lZGlhVX'
'BkYXRlSAtSC21lZGlhVXBkYXRliAEBEksKDmNvbnRhY3RfdXBkYXRlGAggASgLMh8uRW5jcnlw'
'dGVkQ29udGVudC5Db250YWN0VXBkYXRlSAxSDWNvbnRhY3RVcGRhdGWIAQESTgoPY29udGFjdF'
'9yZXF1ZXN0GAkgASgLMiAuRW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdEgNUg5jb250'
'YWN0UmVxdWVzdIgBARI/CgpmbGFtZV9zeW5jGAogASgLMhsuRW5jcnlwdGVkQ29udGVudC5GbG'
'FtZVN5bmNIDlIJZmxhbWVTeW5jiAEBEjsKCHJlYWN0aW9uGAwgASgLMhouRW5jcnlwdGVkQ29u'
'dGVudC5SZWFjdGlvbkgPUghyZWFjdGlvbogBARJFCgx0ZXh0X21lc3NhZ2UYDSABKAsyHS5Fbm'
'NyeXB0ZWRDb250ZW50LlRleHRNZXNzYWdlSBBSC3RleHRNZXNzYWdliAEBEkUKDGdyb3VwX2Ny'
'ZWF0ZRgOIAEoCzIdLkVuY3J5cHRlZENvbnRlbnQuR3JvdXBDcmVhdGVIEVILZ3JvdXBDcmVhdG'
'WIAQESPwoKZ3JvdXBfam9pbhgPIAEoCzIbLkVuY3J5cHRlZENvbnRlbnQuR3JvdXBKb2luSBJS'
'CWdyb3VwSm9pbogBARJFCgxncm91cF91cGRhdGUYECABKAsyHS5FbmNyeXB0ZWRDb250ZW50Lk'
'dyb3VwVXBkYXRlSBNSC2dyb3VwVXBkYXRliAEBEmIKF3Jlc2VuZF9ncm91cF9wdWJsaWNfa2V5'
'GBEgASgLMiYuRW5jcnlwdGVkQ29udGVudC5SZXNlbmRHcm91cFB1YmxpY0tleUgUUhRyZXNlbm'
'RHcm91cFB1YmxpY0tleYgBARJLCg5lcnJvcl9tZXNzYWdlcxgSIAEoCzIfLkVuY3J5cHRlZENv'
'bnRlbnQuRXJyb3JNZXNzYWdlc0gVUg1lcnJvck1lc3NhZ2VziAEBEmQKF2FkZGl0aW9uYWxfZG'
'F0YV9tZXNzYWdlGBMgASgLMicuRW5jcnlwdGVkQ29udGVudC5BZGRpdGlvbmFsRGF0YU1lc3Nh'
'Z2VIFlIVYWRkaXRpb25hbERhdGFNZXNzYWdliAEBElEKEHR5cGluZ19pbmRpY2F0b3IYFCABKA'
'syIS5FbmNyeXB0ZWRDb250ZW50LlR5cGluZ0luZGljYXRvckgXUg90eXBpbmdJbmRpY2F0b3KI'
'AQESYQoWdXNlcl9kaXNjb3ZlcnlfcmVxdWVzdBgWIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuVX'
'NlckRpc2NvdmVyeVJlcXVlc3RIGFIUdXNlckRpc2NvdmVyeVJlcXVlc3SIAQESXgoVdXNlcl9k'
'aXNjb3ZlcnlfdXBkYXRlGBcgASgLMiUuRW5jcnlwdGVkQ29udGVudC5Vc2VyRGlzY292ZXJ5VX'
'BkYXRlSBlSE3VzZXJEaXNjb3ZlcnlVcGRhdGWIAQESYQoWa2V5X3ZlcmlmaWNhdGlvbl9wcm9v'
'ZhgYIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuS2V5VmVyaWZpY2F0aW9uUHJvb2ZIGlIUa2V5Vm'
'VyaWZpY2F0aW9uUHJvb2aIAQESYAoVcGFzc3dvcmRsZXNzX3JlY292ZXJ5GBogASgLMiYuRW5j'
'cnlwdGVkQ29udGVudC5QYXNzd29yZExlc3NSZWNvdmVyeUgbUhRwYXNzd29yZGxlc3NSZWNvdm'
'VyeYgBARJ8Ch9wYXNzd29yZGxlc3NfcmVjb3ZlcnlfaGVhcnRiZWF0GBsgASgLMi8uRW5jcnlw'
'dGVkQ29udGVudC5QYXNzd29yZExlc3NSZWNvdmVyeUhlYXJ0YmVhdEgcUh1wYXNzd29yZGxlc3'
'NSZWNvdmVyeUhlYXJ0YmVhdIgBARIyCgVzdG9yeRgeIAEoCzIXLkVuY3J5cHRlZENvbnRlbnQu'
'U3RvcnlIHVIFc3RvcnmIAQEalgIKDUVycm9yTWVzc2FnZXMSOAoEdHlwZRgBIAEoDjIkLkVuY3'
'J5cHRlZENvbnRlbnQuRXJyb3JNZXNzYWdlcy5UeXBlUgR0eXBlEiwKEnJlbGF0ZWRfcmVjZWlw'
'dF9pZBgCIAEoCVIQcmVsYXRlZFJlY2VpcHRJZCKcAQoEVHlwZRI8CjhFUlJPUl9QUk9DRVNTSU'
'5HX01FU1NBR0VfQ1JFQVRFRF9BQ0NPVU5UX1JFUVVFU1RfSU5TVEVBRBAAEhgKFFVOS05PV05f'
'TUVTU0FHRV9UWVBFEAISFwoTU0VTU0lPTl9PVVRfT0ZfU1lOQxADEiMKH0dST1VQX05PVF9GT1'
'VORF9PUl9OT1RfQV9NRU1CRVIQBBqHAQoLR3JvdXBDcmVhdGUSGwoJc3RhdGVfa2V5GAMgASgM'
'UghzdGF0ZUtleRIoChBncm91cF9wdWJsaWNfa2V5GAQgASgMUg5ncm91cFB1YmxpY0tleRIiCg'
'pncm91cF9uYW1lGAUgASgJSABSCWdyb3VwTmFtZYgBAUINCgtfZ3JvdXBfbmFtZRo1CglHcm91'
'cEpvaW4SKAoQZ3JvdXBfcHVibGljX2tleRgBIAEoDFIOZ3JvdXBQdWJsaWNLZXkaFgoUUmVzZW'
'5kR3JvdXBQdWJsaWNLZXkayAIKC0dyb3VwVXBkYXRlEioKEWdyb3VwX2FjdGlvbl90eXBlGAEg'
'ASgJUg9ncm91cEFjdGlvblR5cGUSMwoTYWZmZWN0ZWRfY29udGFjdF9pZBgCIAEoA0gAUhFhZm'
'ZlY3RlZENvbnRhY3RJZIgBARIpCg5uZXdfZ3JvdXBfbmFtZRgDIAEoCUgBUgxuZXdHcm91cE5h'
'bWWIAQESVwombmV3X2RlbGV0ZV9tZXNzYWdlc19hZnRlcl9taWxsaXNlY29uZHMYBCABKANIAl'
'IibmV3RGVsZXRlTWVzc2FnZXNBZnRlck1pbGxpc2Vjb25kc4gBAUIWChRfYWZmZWN0ZWRfY29u'
'dGFjdF9pZEIRCg9fbmV3X2dyb3VwX25hbWVCKQonX25ld19kZWxldGVfbWVzc2FnZXNfYWZ0ZX'
'JfbWlsbGlzZWNvbmRzGogCCgtUZXh0TWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEo'
'CVIPc2VuZGVyTWVzc2FnZUlkEhIKBHRleHQYAiABKAlSBHRleHQSHAoJdGltZXN0YW1wGAMgAS'
'gDUgl0aW1lc3RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgEIAEoCUgAUg5xdW90ZU1lc3NhZ2VJ'
'ZIgBARI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgFIAEoDEgBUhVhZGRpdGlvbmFsTWVzc2'
'FnZURhdGGIAQFCEwoRX3F1b3RlX21lc3NhZ2VfaWRCGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9k'
'YXRhGuYBChVBZGRpdGlvbmFsRGF0YU1lc3NhZ2USKgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKA'
'lSD3NlbmRlck1lc3NhZ2VJZBIcCgl0aW1lc3RhbXAYAiABKANSCXRpbWVzdGFtcBISCgR0eXBl'
'GAMgASgJUgR0eXBlEjsKF2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGAQgASgMSABSFWFkZGl0aW'
'9uYWxNZXNzYWdlRGF0YYgBARIWCgZoaWRkZW4YBSABKAhSBmhpZGRlbkIaChhfYWRkaXRpb25h'
'bF9tZXNzYWdlX2RhdGEaZAoIUmVhY3Rpb24SKgoRdGFyZ2V0X21lc3NhZ2VfaWQYASABKAlSD3'
'RhcmdldE1lc3NhZ2VJZBIUCgVlbW9qaRgCIAEoCVIFZW1vamkSFgoGcmVtb3ZlGAMgASgIUgZy'
'ZW1vdmUavgIKDU1lc3NhZ2VVcGRhdGUSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbn'
'QuTWVzc2FnZVVwZGF0ZS5UeXBlUgR0eXBlEi8KEXNlbmRlcl9tZXNzYWdlX2lkGAIgASgJSABS'
'D3NlbmRlck1lc3NhZ2VJZIgBARI9ChttdWx0aXBsZV90YXJnZXRfbWVzc2FnZV9pZHMYAyADKA'
'lSGG11bHRpcGxlVGFyZ2V0TWVzc2FnZUlkcxIXCgR0ZXh0GAQgASgJSAFSBHRleHSIAQESHAoJ'
'dGltZXN0YW1wGAUgASgDUgl0aW1lc3RhbXAiLQoEVHlwZRIKCgZERUxFVEUQABINCglFRElUX1'
'RFWFQQARIKCgZPUEVORUQQAkIUChJfc2VuZGVyX21lc3NhZ2VfaWRCBwoFX3RleHQauwYKBU1l'
'ZGlhEioKEXNlbmRlcl9tZXNzYWdlX2lkGAEgASgJUg9zZW5kZXJNZXNzYWdlSWQSMAoEdHlwZR'
'gCIAEoDjIcLkVuY3J5cHRlZENvbnRlbnQuTWVkaWEuVHlwZVIEdHlwZRJGCh1kaXNwbGF5X2xp'
'bWl0X2luX21pbGxpc2Vjb25kcxgDIAEoA0gAUhpkaXNwbGF5TGltaXRJbk1pbGxpc2Vjb25kc4'
'gBARI3ChdyZXF1aXJlc19hdXRoZW50aWNhdGlvbhgEIAEoCFIWcmVxdWlyZXNBdXRoZW50aWNh'
'dGlvbhIcCgl0aW1lc3RhbXAYBSABKANSCXRpbWVzdGFtcBItChBxdW90ZV9tZXNzYWdlX2lkGA'
'YgASgJSAFSDnF1b3RlTWVzc2FnZUlkiAEBEioKDmRvd25sb2FkX3Rva2VuGAcgASgMSAJSDWRv'
'd25sb2FkVG9rZW6IAQESKgoOZW5jcnlwdGlvbl9rZXkYCCABKAxIA1INZW5jcnlwdGlvbktleY'
'gBARIqCg5lbmNyeXB0aW9uX21hYxgJIAEoDEgEUg1lbmNyeXB0aW9uTWFjiAEBEi4KEGVuY3J5'
'cHRpb25fbm9uY2UYCiABKAxIBVIPZW5jcnlwdGlvbk5vbmNliAEBEjsKF2FkZGl0aW9uYWxfbW'
'Vzc2FnZV9kYXRhGAsgASgMSAZSFWFkZGl0aW9uYWxNZXNzYWdlRGF0YYgBARIkCgt3aWRnZXRf'
'b25seRgMIAEoCEgHUgp3aWRnZXRPbmx5iAEBIj4KBFR5cGUSDAoIUkVVUExPQUQQABIJCgVJTU'
'FHRRABEgkKBVZJREVPEAISBwoDR0lGEAMSCQoFQVVESU8QBEIgCh5fZGlzcGxheV9saW1pdF9p'
'bl9taWxsaXNlY29uZHNCEwoRX3F1b3RlX21lc3NhZ2VfaWRCEQoPX2Rvd25sb2FkX3Rva2VuQh'
'EKD19lbmNyeXB0aW9uX2tleUIRCg9fZW5jcnlwdGlvbl9tYWNCEwoRX2VuY3J5cHRpb25fbm9u'
'Y2VCGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhQg4KDF93aWRnZXRfb25seRpOCgVTdG9yeR'
'ItCgVtZWRpYRgBIAEoCzIXLkVuY3J5cHRlZENvbnRlbnQuTWVkaWFSBW1lZGlhEhYKBm5vdGlm'
'eRgCIAEoCFIGbm90aWZ5GqkBCgtNZWRpYVVwZGF0ZRI2CgR0eXBlGAEgASgOMiIuRW5jcnlwdG'
'VkQ29udGVudC5NZWRpYVVwZGF0ZS5UeXBlUgR0eXBlEioKEXRhcmdldF9tZXNzYWdlX2lkGAIg'
'ASgJUg90YXJnZXRNZXNzYWdlSWQiNgoEVHlwZRIMCghSRU9QRU5FRBAAEgoKBlNUT1JFRBABEh'
'QKEERFQ1JZUFRJT05fRVJST1IQAhp4Cg5Db250YWN0UmVxdWVzdBI5CgR0eXBlGAEgASgOMiUu'
'RW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdC5UeXBlUgR0eXBlIisKBFR5cGUSCwoHUk'
'VRVUVTVBAAEgoKBlJFSkVDVBABEgoKBkFDQ0VQVBACGtkDCg1Db250YWN0VXBkYXRlEjgKBHR5'
'cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGUuVHlwZVIEdHlwZRI3Ch'
'VhdmF0YXJfc3ZnX2NvbXByZXNzZWQYAiABKAxIAFITYXZhdGFyU3ZnQ29tcHJlc3NlZIgBARIf'
'Cgh1c2VybmFtZRgDIAEoCUgBUgh1c2VybmFtZYgBARImCgxkaXNwbGF5X25hbWUYBCABKAlIAl'
'ILZGlzcGxheU5hbWWIAQESOwoXY3VzdG9tX2F2YXRhcl9yZXF1ZXN0ZWQYBSABKAhIA1IVY3Vz'
'dG9tQXZhdGFyUmVxdWVzdGVkiAEBEkgKDWN1c3RvbV9hdmF0YXIYBiABKAsyHi5FbmNyeXB0ZW'
'RDb250ZW50LkN1c3RvbUF2YXRhckgEUgxjdXN0b21BdmF0YXKIAQEiHwoEVHlwZRILCgdSRVFV'
'RVNUEAASCgoGVVBEQVRFEAFCGAoWX2F2YXRhcl9zdmdfY29tcHJlc3NlZEILCglfdXNlcm5hbW'
'VCDwoNX2Rpc3BsYXlfbmFtZUIaChhfY3VzdG9tX2F2YXRhcl9yZXF1ZXN0ZWRCEAoOX2N1c3Rv'
'bV9hdmF0YXIazgIKDEN1c3RvbUF2YXRhchIYCgd2ZXJzaW9uGAEgASgNUgd2ZXJzaW9uEi8KE3'
'B1YmxpY2F0aW9uX2NvdW50ZXIYAiABKANSEnB1YmxpY2F0aW9uQ291bnRlchI6CgVzdGF0ZRgD'
'IAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuQ3VzdG9tQXZhdGFyLlN0YXRlUgVzdGF0ZRIXCgR3ZW'
'JwGAQgASgMSABSBHdlYnCIAQESGwoGc2hhMjU2GAUgASgMSAFSBnNoYTI1NogBARIZCgV3aWR0'
'aBgGIAEoDUgCUgV3aWR0aIgBARIbCgZoZWlnaHQYByABKA1IA1IGaGVpZ2h0iAEBIiAKBVN0YX'
'RlEgwKCFNWR19PTkxZEAASCQoFUEhPVE8QAUIHCgVfd2VicEIJCgdfc2hhMjU2QggKBl93aWR0'
'aEIJCgdfaGVpZ2h0Gq8BCglGbGFtZVN5bmMSIwoNZmxhbWVfY291bnRlchgBIAEoA1IMZmxhbW'
'VDb3VudGVyEjkKGWxhc3RfZmxhbWVfY291bnRlcl9jaGFuZ2UYAiABKANSFmxhc3RGbGFtZUNv'
'dW50ZXJDaGFuZ2USHwoLYmVzdF9mcmllbmQYAyABKAhSCmJlc3RGcmllbmQSIQoMZm9yY2VfdX'
'BkYXRlGAQgASgIUgtmb3JjZVVwZGF0ZRpNCg9UeXBpbmdJbmRpY2F0b3ISGwoJaXNfdHlwaW5n'
'GAEgASgIUghpc1R5cGluZxIdCgpjcmVhdGVkX2F0GAIgASgDUgljcmVhdGVkQXQaPwoUVXNlck'
'Rpc2NvdmVyeVJlcXVlc3QSJwoPY3VycmVudF92ZXJzaW9uGAEgASgMUg5jdXJyZW50VmVyc2lv'
'bhoxChNVc2VyRGlzY292ZXJ5VXBkYXRlEhoKCG1lc3NhZ2VzGAEgAygMUghtZXNzYWdlcxo9Ch'
'RLZXlWZXJpZmljYXRpb25Qcm9vZhIlCg5jYWxjdWxhdGVkX21hYxgBIAEoDFINY2FsY3VsYXRl'
'ZE1hYxqbAQoUUGFzc3dvcmRMZXNzUmVjb3ZlcnkSNQoTcmVjb3ZlcnlTZWNyZXRTaGFyZRgBIA'
'EoDEgAUhNyZWNvdmVyeVNlY3JldFNoYXJliAEBEhYKBmRlbGV0ZRgCIAEoCFIGZGVsZXRlEhwK'
'CXRocmVzaG9sZBgDIAEoA1IJdGhyZXNob2xkQhYKFF9yZWNvdmVyeVNlY3JldFNoYXJlGjMKHV'
'Bhc3N3b3JkTGVzc1JlY292ZXJ5SGVhcnRiZWF0EhIKBGhhc2gYASABKAxSBGhhc2hCCwoJX2dy'
'b3VwX2lkQhEKD19pc19kaXJlY3RfY2hhdEIZChdfc2VuZGVyX3Byb2ZpbGVfY291bnRlckIgCh'
'5fc2VuZGVyX3VzZXJfZGlzY292ZXJ5X3ZlcnNpb25CHAoaX2Fza19mb3JfZnJpZW5kX3Byb21v'
'dGlvbnNCGQoXX3dpZGdldF9zaGFyaW5nX2FsbG93ZWRCFgoUX3NlbmRlcl90d29ubHlfc2Nvcm'
'VCHwodX3NlbmRlcl9jdXN0b21fYXZhdGFyX2NvdW50ZXJCIQofX2N1c3RvbV9hdmF0YXJfcHJv'
'dG9jb2xfdmVyc2lvbkIRCg9fbWVzc2FnZV91cGRhdGVCCAoGX21lZGlhQg8KDV9tZWRpYV91cG'
'RhdGVCEQoPX2NvbnRhY3RfdXBkYXRlQhIKEF9jb250YWN0X3JlcXVlc3RCDQoLX2ZsYW1lX3N5'
'bmNCCwoJX3JlYWN0aW9uQg8KDV90ZXh0X21lc3NhZ2VCDwoNX2dyb3VwX2NyZWF0ZUINCgtfZ3'
'JvdXBfam9pbkIPCg1fZ3JvdXBfdXBkYXRlQhoKGF9yZXNlbmRfZ3JvdXBfcHVibGljX2tleUIR'
'Cg9fZXJyb3JfbWVzc2FnZXNCGgoYX2FkZGl0aW9uYWxfZGF0YV9tZXNzYWdlQhMKEV90eXBpbm'
'dfaW5kaWNhdG9yQhkKF191c2VyX2Rpc2NvdmVyeV9yZXF1ZXN0QhgKFl91c2VyX2Rpc2NvdmVy'
'eV91cGRhdGVCGQoXX2tleV92ZXJpZmljYXRpb25fcHJvb2ZCGAoWX3Bhc3N3b3JkbGVzc19yZW'
'NvdmVyeUIiCiBfcGFzc3dvcmRsZXNzX3JlY292ZXJ5X2hlYXJ0YmVhdEIICgZfc3RvcnlKBAgc'
'EB0=');

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show listEquals, setEquals;
import 'package:flutter/foundation.dart' show listEquals;
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:twonly/locator.dart';
@ -80,6 +80,7 @@ String _avatarPngPathFor(Contact contact) {
class _AvatarIconState extends State<AvatarIcon> {
List<Contact> _avatarContacts = [];
Set<int> _contactsWithPngAvatar = {};
final Map<int, String> _resolvedAvatarPaths = {};
String? _myAvatarPath;
StreamSubscription<List<Contact>>? groupStream;
@ -105,35 +106,20 @@ class _AvatarIconState extends State<AvatarIcon> {
}
void _setAvatarContacts(List<Contact> contacts) {
_avatarContacts = contacts
.where((contact) => contact.avatarSvgCompressed != null)
.toList();
_avatarContacts = contacts;
unawaited(_refreshAvatarFiles());
if (mounted) setState(() {});
}
Future<void> _refreshAvatarFiles() async {
final available = <int>{};
for (final contact in _avatarContacts) {
final path = _avatarPngPathFor(contact);
var exists = _existingAvatarPngPaths.contains(path);
if (!exists) {
// Async file access keeps avatar discovery off the UI thread.
// ignore: avoid_slow_async_io
exists = await File(path).exists();
if (exists) _existingAvatarPngPaths.add(path);
}
if (exists) {
available.add(contact.userId);
} else if (!_unrenderableAvatars.contains(_avatarCacheKey(contact))) {
// No PNG yet: render one rather than falling back to the SVG. The
// default avatar is shown until it lands.
if (!_unrenderableAvatars.contains(_avatarCacheKey(contact))) {
// Rust resolves a received custom avatar before the SVG fallback. Do
// not trust the old profile-counter cache here: a custom-avatar update
// deliberately leaves that public counter unchanged.
unawaited(_renderAvatarPng(contact));
}
}
if (!mounted) return;
if (setEquals(_contactsWithPngAvatar, available)) return;
setState(() => _contactsWithPngAvatar = available);
}
Future<void> _renderAvatarPng(Contact contact) async {
@ -167,6 +153,7 @@ class _AvatarIconState extends State<AvatarIcon> {
if (_contactsWithPngAvatar.contains(contactId)) return;
if (!_avatarContacts.any((entry) => entry.userId == contactId)) return;
setState(() {
_resolvedAvatarPaths[contactId] = path!;
_contactsWithPngAvatar = {..._contactsWithPngAvatar, contactId};
});
}
@ -189,7 +176,9 @@ class _AvatarIconState extends State<AvatarIcon> {
Widget getAvatarForContact(Contact contact) {
if (_contactsWithPngAvatar.contains(contact.userId)) {
return Image.file(
File(_avatarPngPathFor(contact)),
File(
_resolvedAvatarPaths[contact.userId] ?? _avatarPngPathFor(contact),
),
errorBuilder: errorBuilder,
);
}
@ -206,16 +195,13 @@ class _AvatarIconState extends State<AvatarIcon> {
.watchGroupContact(widget.group!.groupId)
.listen((contacts) {
_avatarContacts = [];
if (contacts.length == 1) {
if (contacts.first.avatarSvgCompressed != null) {
_avatarContacts.add(contacts.first);
}
} else {
for (final contact in contacts) {
if (contact.avatarSvgCompressed != null) {
_avatarContacts.add(contact);
}
_unrenderableAvatars.remove(_avatarCacheKey(contact));
}
if (contacts.length == 1) {
_avatarContacts.add(contacts.first);
} else {
_avatarContacts.addAll(contacts);
}
unawaited(_refreshAvatarFiles());
setState(() {});
@ -229,7 +215,8 @@ class _AvatarIconState extends State<AvatarIcon> {
contactStream = twonlyDB.contactsDao
.watchContact(widget.contactId!)
.listen((contact) {
if (contact != null && contact.avatarSvgCompressed != null) {
if (contact != null) {
_unrenderableAvatars.remove(_avatarCacheKey(contact));
_avatarContacts = [contact];
unawaited(_refreshAvatarFiles());
setState(() {});

View file

@ -82,7 +82,10 @@ class _StoryTargetSelectorState extends State<StoryTargetSelector> {
);
if (before != widget.target.contactGroupIds.length) _changed();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_updateAudience());
});
}
@override

View file

@ -5,11 +5,13 @@ import 'dart:math' as math;
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
@ -96,6 +98,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
bool _widgetRecipientAvailable = false;
bool _sendToWidget = false;
bool _updatingWidgetMode = false;
bool _widgetExplainerPreferenceLoaded = false;
bool _widgetExplainerDismissed =
userService.currentUser.hideWidgetShareExplainer;
bool _previousMediaSettingsCaptured = false;
@ -103,7 +106,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
bool _requiresAuthBeforeWidget = false;
bool get _showWidgetExplainer =>
_widgetRecipientAvailable && !_widgetExplainerDismissed;
_widgetRecipientAvailable &&
_widgetExplainerPreferenceLoaded &&
!_widgetExplainerDismissed;
MediaFileService get mediaService => widget.mediaFileService;
MediaFile get media => widget.mediaFileService.mediaFile;
@ -138,6 +143,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
_widgetGroupsSubscription = twonlyDB.groupsDao
.watchGroupsAllowedForWidgetShare()
.listen(_updateWidgetRecipientAvailability);
unawaited(_loadWidgetExplainerPreference());
if (media.type == MediaType.image || media.type == MediaType.gif) {
_loadInitialImage();
}
@ -162,9 +168,27 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
super.dispose();
}
Future<void> _loadWidgetExplainerPreference() async {
final preference = await KeyValueStore.get(
KeyValueKeys.shareImageWidgetExplainer,
);
if (!mounted) return;
setState(() {
_widgetExplainerDismissed =
_widgetExplainerDismissed || preference?['dismissed'] == true;
_widgetExplainerPreferenceLoaded = true;
});
}
Future<void> _dismissWidgetExplainer() async {
setState(() => _widgetExplainerDismissed = true);
await UserService.update((u) => u.hideWidgetShareExplainer = true);
await Future.wait([
KeyValueStore.put(
KeyValueKeys.shareImageWidgetExplainer,
const {'dismissed': true},
),
UserService.update((u) => u.hideWidgetShareExplainer = true),
]);
}
void _updateWidgetRecipientAvailability(List<Group> widgetGroups) {

View file

@ -11,7 +11,9 @@ import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.com
/// Selects a full-resolution image from twonly Memories, with an explicit
/// escape hatch to the device gallery in the top app bar.
class StickerSourcePickerView extends StatefulWidget {
const StickerSourcePickerView({super.key});
const StickerSourcePickerView({this.forAvatar = false, super.key});
final bool forAvatar;
@override
State<StickerSourcePickerView> createState() =>
@ -63,7 +65,11 @@ class _StickerSourcePickerViewState extends State<StickerSourcePickerView> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.createSticker),
title: Text(
widget.forAvatar
? context.lang.customAvatarChooseSource
: context.lang.createSticker,
),
actions: [
TextButton.icon(
onPressed: _resolvingMediaId == null ? _pickFromGallery : null,

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:twonly/core/services/avatars.dart';
import 'package:twonly/core/user_config.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart';
@ -13,6 +14,38 @@ class PublicProfilePrivacyView extends StatefulWidget {
}
class _PublicProfilePrivacyViewState extends State<PublicProfilePrivacyView> {
CustomAvatarInfo? _customAvatar;
bool _updatingAvatarVisibility = false;
@override
void initState() {
super.initState();
_loadCustomAvatarVisibility();
}
Future<void> _loadCustomAvatarVisibility() async {
try {
final avatar = await RustApi.getCustomAvatar();
if (mounted) setState(() => _customAvatar = avatar);
} on Object {
// Keep the setting disabled when storage is unavailable. Other public
// profile settings remain usable.
}
}
Future<void> _setCustomAvatarVisibility(bool acceptedOnly) async {
if (_updatingAvatarVisibility) return;
setState(() => _updatingAvatarVisibility = true);
try {
final avatar = await RustApi.setCustomAvatarAudience(
acceptedContactsOnly: acceptedOnly,
);
if (mounted) setState(() => _customAvatar = avatar);
} finally {
if (mounted) setState(() => _updatingAvatarVisibility = false);
}
}
Future<void> _setTwonlyScoreVisibility(
TwonlyScoreVisibility visibility,
) async {
@ -69,6 +102,36 @@ class _PublicProfilePrivacyViewState extends State<PublicProfilePrivacyView> {
),
),
),
ListTile(
title: Text(context.lang.customAvatarPhotoTab),
subtitle: Text(context.lang.customAvatarExplanation),
trailing: SizedBox(
width: 180,
child: DropdownButtonHideUnderline(
child: DropdownButton<bool>(
isExpanded: true,
value: _customAvatar?.acceptedContactsOnly ?? true,
items: [
DropdownMenuItem(
value: true,
child: Text(context.lang.customAvatarAcceptedOnly),
),
DropdownMenuItem(
value: false,
child: Text(context.lang.privacyVisibilityEveryone),
),
],
onChanged: _customAvatar == null || _updatingAvatarVisibility
? null
: (acceptedOnly) {
if (acceptedOnly != null) {
_setCustomAvatarVisibility(acceptedOnly);
}
},
),
),
),
),
],
),
);

View file

@ -2,11 +2,15 @@ import 'dart:math';
import 'package:avatar_maker/avatar_maker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/core/services/avatars.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.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';
import 'package:twonly/src/visual/views/memories/sticker_source_picker.view.dart';
class ModifyAvatarView extends StatefulWidget {
const ModifyAvatarView({super.key});
@ -16,30 +20,340 @@ class ModifyAvatarView extends StatefulWidget {
}
class _ModifyAvatarViewState extends State<ModifyAvatarView> {
CustomAvatarInfo? _customAvatar;
bool _loadingCustomAvatar = true;
bool _processingCustomAvatar = false;
bool _updatingAudience = false;
@override
void initState() {
super.initState();
_loadCustomAvatar();
}
Future<void> _loadCustomAvatar() async {
try {
final avatar = await RustApi.getCustomAvatar();
if (mounted) setState(() => _customAvatar = avatar);
} finally {
if (mounted) setState(() => _loadingCustomAvatar = false);
}
}
Future<void> _chooseCustomAvatar() async {
if (_processingCustomAvatar) return;
final path = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (context) => const StickerSourcePickerView(forAvatar: true),
),
);
if (!mounted || path == null) return;
setState(() => _processingCustomAvatar = true);
try {
final avatar = await RustApi.createCustomAvatar(
imagePath: path,
acceptedContactsOnly: _customAvatar?.acceptedContactsOnly ?? true,
);
if (!mounted) return;
setState(() => _customAvatar = avatar);
userService.triggerUserUpdate();
} on Object {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.lang.customAvatarCreateFailed)),
);
} finally {
if (mounted) setState(() => _processingCustomAvatar = false);
}
}
Future<void> _removeCustomAvatar() async {
if (_processingCustomAvatar) return;
setState(() => _processingCustomAvatar = true);
try {
final avatar = await RustApi.removeCustomAvatar();
if (!mounted) return;
setState(() => _customAvatar = avatar);
userService.triggerUserUpdate();
} finally {
if (mounted) setState(() => _processingCustomAvatar = false);
}
}
Future<void> _setCustomAvatarAudience(bool acceptedContactsOnly) async {
if (_updatingAudience) return;
setState(() => _updatingAudience = true);
try {
final avatar = await RustApi.setCustomAvatarAudience(
acceptedContactsOnly: acceptedContactsOnly,
);
if (mounted) setState(() => _customAvatar = avatar);
} on Object {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.lang.errorInternalError)),
);
} finally {
if (mounted) setState(() => _updatingAudience = false);
}
}
Future<void> _openAvatarMaker() async {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (context) => const _SvgAvatarEditorView()),
);
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.settingsProfileCustomizeAvatar),
),
body: Stack(
children: [
ListView(
padding: const EdgeInsets.all(16),
children: [
_buildAvatarMakerCard(context),
const SizedBox(height: 16),
_buildPhotoAvatarCard(context),
const SizedBox(height: 30),
],
),
if (_processingCustomAvatar)
const Positioned.fill(
child: ColoredBox(
color: Color(0x66000000),
child: Center(child: ThreeRotatingDots(size: 36)),
),
),
],
),
);
}
Widget _buildAvatarMakerCard(BuildContext context) {
final svg = userService.currentUser.avatarSvg;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.lang.customAvatarSvgTab,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Center(
child: ClipOval(
child: SizedBox.square(
dimension: 150,
child: ColoredBox(
color: context.color.surfaceContainerHigh,
child: svg != null && svg.isNotEmpty
? SvgPicture.string(svg)
: const Icon(Icons.person_outline, size: 72),
),
),
),
),
const SizedBox(height: 16),
Center(
child: MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: _openAvatarMaker,
child: Text(context.lang.customAvatarCustomize),
),
),
],
),
),
);
}
Widget _buildPhotoAvatarCard(BuildContext context) {
if (_loadingCustomAvatar) {
return const Card(
margin: EdgeInsets.zero,
child: SizedBox(
height: 220,
child: Center(child: ThreeRotatingDots(size: 32)),
),
);
}
final avatar = _customAvatar;
final hasPhoto = avatar?.webp != null;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
context.lang.customAvatarPhotoTab,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
context.lang.customAvatarExplanation,
textAlign: TextAlign.center,
style: TextStyle(color: context.color.onSurfaceVariant),
),
const SizedBox(height: 16),
Center(
child: ClipOval(
child: SizedBox.square(
dimension: 220,
child: ColoredBox(
color: context.color.surfaceContainerHigh,
child: hasPhoto
? Image.memory(avatar!.webp!, fit: BoxFit.contain)
: const Icon(Icons.person_outline, size: 100),
),
),
),
),
const SizedBox(height: 20),
Text(
context.lang.customAvatarAudience,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Center(
child: DropdownButton<bool>(
value: avatar?.acceptedContactsOnly ?? true,
alignment: AlignmentDirectional.center,
items: [
DropdownMenuItem(
value: true,
child: Center(
child: Text(
context.lang.customAvatarAcceptedOnly,
textAlign: TextAlign.center,
),
),
),
DropdownMenuItem(
value: false,
child: Center(
child: Text(
context.lang.customAvatarEveryone,
textAlign: TextAlign.center,
),
),
),
],
onChanged: _updatingAudience
? null
: (acceptedContactsOnly) {
if (acceptedContactsOnly != null) {
_setCustomAvatarAudience(acceptedContactsOnly);
}
},
),
),
const SizedBox(height: 20),
Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: [
MyButton(
variant: MyButtonVariant.secondaryMiddle,
onPressed: _processingCustomAvatar
? null
: _chooseCustomAvatar,
child: Text(
hasPhoto
? context.lang.customAvatarReplace
: context.lang.customAvatarCreate,
),
),
if (hasPhoto)
MyButton(
variant: MyButtonVariant.errorMiddle,
onPressed: _processingCustomAvatar
? null
: _removeCustomAvatar,
child: Text(context.lang.customAvatarRemove),
),
],
),
],
),
),
);
}
}
class _SvgAvatarEditorView extends StatefulWidget {
const _SvgAvatarEditorView();
@override
State<_SvgAvatarEditorView> createState() => _SvgAvatarEditorViewState();
}
class _SvgAvatarEditorViewState extends State<_SvgAvatarEditorView> {
late final _CustomAvatarMakerController _avatarMakerController;
@override
void initState() {
super.initState();
final svg = userService.currentUser.avatarSvg;
if (svg != null && svg.isNotEmpty) {
_avatarMakerController = _CustomAvatarMakerController(
svg: svg,
);
} else {
_avatarMakerController = _CustomAvatarMakerController.defaultAvatar();
}
_avatarMakerController = svg != null && svg.isNotEmpty
? _CustomAvatarMakerController(svg: svg)
: _CustomAvatarMakerController.defaultAvatar();
}
Future<void> updateUserAvatar(String svg) async {
@override
void dispose() {
_avatarMakerController.dispose();
super.dispose();
}
Future<void> _updateUserAvatar(String svg) async {
await UserService.update(
(u) => u
(user) => user
..avatarSvg = svg
..avatarCounter = u.avatarCounter + 1,
..avatarCounter = user.avatarCounter + 1,
);
}
AvatarMakerThemeData getAvatarMakerTheme(BuildContext context) {
Future<void> _storeAvatarAndExit() async {
await _avatarMakerController.saveAvatarSVG();
final svg = _avatarMakerController.getAvatarSVGSync();
await _updateUserAvatar(svg);
if (mounted) Navigator.pop(context, true);
}
Future<bool?> _showBackDialog() {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.lang.avatarSaveChanges),
actions: [
FilledButton(
onPressed: _storeAvatarAndExit,
child: Text(context.lang.avatarSaveChangesStore),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.avatarSaveChangesDiscard),
),
],
),
);
}
AvatarMakerThemeData _theme(BuildContext context) {
final colors = context.color;
return AvatarMakerThemeData(
boxDecoration: BoxDecoration(
@ -73,42 +387,6 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
);
}
Future<bool?> _showBackDialog() {
return showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(
context.lang.avatarSaveChanges,
),
actions: [
FilledButton(
child: Text(context.lang.avatarSaveChangesStore),
onPressed: () async {
await storeAvatarAndExit();
},
),
TextButton(
child: Text(context.lang.avatarSaveChangesDiscard),
onPressed: () {
Navigator.pop(context, true);
},
),
],
);
},
);
}
Future<void> storeAvatarAndExit() async {
await _avatarMakerController.saveAvatarSVG();
final svg = _avatarMakerController.getAvatarSVGSync();
await updateUserAvatar(svg);
if (mounted) {
Navigator.pop(context, true);
}
}
@override
Widget build(BuildContext context) {
return PopScope<bool?>(
@ -117,31 +395,23 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
if (didPop) return;
if (_avatarMakerController.getAvatarSVGSync() !=
userService.currentUser.avatarSvg) {
// there where changes
final shouldPop = await _showBackDialog() ?? false;
if (context.mounted && shouldPop) {
Navigator.pop(context);
}
if (context.mounted && shouldPop) Navigator.pop(context);
} else {
Navigator.pop(context);
}
},
child: Scaffold(
appBar: AppBar(
title: Text(context.lang.settingsProfileCustomizeAvatar),
),
appBar: AppBar(title: Text(context.lang.customAvatarSvgTab)),
body: Center(
child: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: EdgeInsets.zero,
child: AvatarMakerAvatar(
AvatarMakerAvatar(
radius: 130,
backgroundColor: Colors.transparent,
controller: _avatarMakerController,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
@ -151,11 +421,14 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
children: [
MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: storeAvatarAndExit,
onPressed: _storeAvatarAndExit,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.floppyDisk, size: 16),
const FaIcon(
FontAwesomeIcons.floppyDisk,
size: 16,
),
const SizedBox(width: 6),
Text(context.lang.avatarSaveChangesStore),
],
@ -177,13 +450,14 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _avatarMakerController.restoreState,
onLongPress: () {
_avatarMakerController.clearCustomizations();
},
onLongPress: _avatarMakerController.clearCustomizations,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(FontAwesomeIcons.rotateLeft, size: 16),
const FaIcon(
FontAwesomeIcons.rotateLeft,
size: 16,
),
const SizedBox(width: 6),
Text(context.lang.avatarCustomizeReset),
],
@ -193,15 +467,13 @@ class _ModifyAvatarViewState extends State<ModifyAvatarView> {
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 30,
),
padding: const EdgeInsets.symmetric(vertical: 30),
child: AvatarMakerCustomizer(
scaffoldWidth: min(
600,
MediaQuery.of(context).size.width * 0.95,
),
theme: getAvatarMakerTheme(context),
theme: _theme(context),
controller: _avatarMakerController,
),
),

View file

@ -776,6 +776,7 @@ async fn handle_encrypted_inner(
}
contact::check_for_profile_update(t, from_user_id, &content).await?;
contact::check_for_custom_avatar_update(t, from_user_id, &content).await?;
if content.ask_for_friend_promotions == Some(true) {
Contact::update_ask_for_friend_promotions(t, from_user_id).await?;

View file

@ -21,6 +21,72 @@ use std::io::Write as _;
use std::sync::{Arc, Mutex, OnceLock};
static REQUESTED_PROFILES: OnceLock<Mutex<HashMap<i64, i64>>> = OnceLock::new();
static REQUESTED_CUSTOM_AVATARS: OnceLock<Mutex<HashMap<i64, (i64, std::time::Instant)>>> =
OnceLock::new();
pub(crate) async fn check_for_custom_avatar_update(
t: &mut Transaction<'_, Sqlite>,
from_user_id: i64,
content: &EncryptedContent,
) -> Result<()> {
if content.custom_avatar_protocol_version.unwrap_or_default()
< crate::services::avatars::CUSTOM_AVATAR_PROTOCOL_VERSION
|| content
.contact_update
.as_ref()
.is_some_and(|update| update.custom_avatar.is_some())
{
return Ok(());
}
let Some(counter) = content.sender_custom_avatar_counter else {
return Ok(());
};
if counter <= crate::services::avatars::applied_counter(t, from_user_id).await? {
return Ok(());
}
let should_request = {
let mut requested = REQUESTED_CUSTOM_AVATARS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap();
requested.retain(|_, (_, requested_at)| {
requested_at.elapsed() < std::time::Duration::from_secs(120)
});
let previous = requested
.get(&from_user_id)
.map(|(counter, _)| *counter)
.unwrap_or(-1);
if counter > previous {
requested.insert(from_user_id, (counter, std::time::Instant::now()));
true
} else {
false
}
};
if should_request {
queue_encrypted_content(
t,
from_user_id,
EncryptedContent {
contact_update: Some(encrypted_content::ContactUpdate {
r#type: encrypted_content::contact_update::Type::Request as i32,
username: None,
display_name: None,
avatar_svg_compressed: None,
custom_avatar_requested: Some(true),
custom_avatar: None,
}),
custom_avatar_protocol_version: Some(
crate::services::avatars::CUSTOM_AVATAR_PROTOCOL_VERSION,
),
..Default::default()
},
true,
)
.await?;
}
Ok(())
}
pub(crate) async fn check_for_profile_update(
t: &mut Transaction<'_, Sqlite>,
@ -68,6 +134,8 @@ pub(crate) async fn check_for_profile_update(
username: None,
display_name: None,
avatar_svg_compressed: None,
custom_avatar_requested: None,
custom_avatar: None,
}),
..Default::default()
},
@ -216,6 +284,11 @@ pub(crate) async fn handle_contact_update(
})
.transpose()?;
let custom_avatar = if update.custom_avatar_requested == Some(true) {
Some(crate::services::avatars::publication_payload(tr, from_user_id).await?)
} else {
None
};
queue_encrypted_content(
tr,
from_user_id,
@ -225,8 +298,16 @@ pub(crate) async fn handle_contact_update(
username: Some(user.username),
display_name: Some(user.display_name),
avatar_svg_compressed,
custom_avatar_requested: None,
custom_avatar: custom_avatar.clone(),
}),
sender_profile_counter: Some(user.avatar_counter),
sender_custom_avatar_counter: custom_avatar
.as_ref()
.map(|avatar| avatar.publication_counter),
custom_avatar_protocol_version: Some(
crate::services::avatars::CUSTOM_AVATAR_PROTOCOL_VERSION,
),
..Default::default()
},
true,
@ -240,6 +321,21 @@ pub(crate) async fn handle_contact_update(
}
let avatar_svg_compressed = update.avatar_svg_compressed.clone();
if let Some(custom_avatar) = update.custom_avatar.clone() {
match crate::services::avatars::apply_received(tr, from_user_id, custom_avatar).await {
Ok(_) => {
if let Some(requested) = REQUESTED_CUSTOM_AVATARS.get() {
requested.lock().unwrap().remove(&from_user_id);
}
}
Err(error) => {
// A malformed optional avatar must not keep an otherwise valid
// reliable-mailbox profile response retrying forever. The
// advertised counter stays stale and can be requested again.
tracing::warn!(from_user_id, %error, "discarding invalid custom avatar");
}
}
}
let previous = sqlx::query!(
"SELECT username, display_name FROM contacts WHERE user_id = ?",

View file

@ -509,6 +509,9 @@ async fn prepare_queued_receipt_from_row(
let plaintext = message.encrypted_content.take().ok_or_else(|| {
TwonlyError::Generic("queued encrypted message has no content".into())
})?;
let plaintext =
crate::services::avatars::refresh_queued_response(ctx, row.contact_id, plaintext)
.await?;
message.encrypted_content =
Some(encrypt_v2_with_session_recovery(ctx, row.contact_id, plaintext).await?);

View file

@ -56,6 +56,10 @@ pub(crate) async fn decorate_content(
};
content.sender_profile_counter = Some(config.avatar_counter);
content.sender_custom_avatar_counter =
Some(crate::services::avatars::prepare_publication(t, contact_id).await?);
content.custom_avatar_protocol_version =
Some(crate::services::avatars::CUSTOM_AVATAR_PROTOCOL_VERSION);
content.widget_sharing_allowed = Some(
sqlx::query_scalar!(
"SELECT widget_sharing_granted FROM contacts WHERE user_id = ?",

View file

@ -13,6 +13,10 @@ message EncryptedContent {
optional bool widget_sharing_allowed = 29;
// Sum of all exchanged media, omitted when profile sharing is disabled.
optional int64 sender_twonly_score = 31;
// Per-recipient version of the custom photo-avatar state. Receivers request
// the payload only when this advances; the image is never broadcast.
optional int64 sender_custom_avatar_counter = 32;
optional uint32 custom_avatar_protocol_version = 33;
optional MessageUpdate message_update = 5;
optional Media media = 6;
@ -177,6 +181,25 @@ message EncryptedContent {
optional bytes avatar_svg_compressed = 2;
optional string username = 3;
optional string display_name = 4;
// Set on a REQUEST only by clients that understand CustomAvatar.
optional bool custom_avatar_requested = 5;
// Set on UPDATE only in direct response to such a request.
optional CustomAvatar custom_avatar = 6;
}
message CustomAvatar {
enum State {
SVG_ONLY = 0;
PHOTO = 1;
}
uint32 version = 1;
int64 publication_counter = 2;
State state = 3;
optional bytes webp = 4;
optional bytes sha256 = 5;
optional uint32 width = 6;
optional uint32 height = 7;
}
message FlameSync {

View file

@ -198,6 +198,9 @@ impl RustApi {
pub async fn current_user_avatar_path() -> Result<Option<String>> {
let ctx = Context::get_static()?.clone();
if let Some(path) = avatars::current_custom_avatar_path(&ctx).await? {
return Ok(Some(path.display().to_string()));
}
let path = tokio::task::spawn_blocking(move || avatars::current_user_avatar_path(&ctx))
.await
.map_err(|error| {
@ -206,6 +209,33 @@ impl RustApi {
Ok(path.map(|path| path.display().to_string()))
}
pub async fn get_custom_avatar() -> Result<avatars::CustomAvatarInfo> {
avatars::own_custom_avatar(Context::get_static()?).await
}
pub async fn create_custom_avatar(
image_path: String,
accepted_contacts_only: bool,
) -> Result<avatars::CustomAvatarInfo> {
let avatar = tokio::task::spawn_blocking(move || {
avatars::create_custom_avatar(Path::new(&image_path))
})
.await
.map_err(|error| TwonlyError::Generic(format!("avatar creation task failed: {error}")))??;
avatars::store_own_custom_avatar(Context::get_static()?, avatar, accepted_contacts_only)
.await
}
pub async fn set_custom_avatar_audience(
accepted_contacts_only: bool,
) -> Result<avatars::CustomAvatarInfo> {
avatars::set_custom_avatar_audience(Context::get_static()?, accepted_contacts_only).await
}
pub async fn remove_custom_avatar() -> Result<avatars::CustomAvatarInfo> {
avatars::remove_own_custom_avatar(Context::get_static()?).await
}
/// Settles every background transfer this device believes is still in
/// flight and resumes any upload a terminated process left behind.
pub async fn finish_started_media_uploads() -> Result<()> {

View file

@ -0,0 +1,31 @@
CREATE TABLE own_custom_avatar (
singleton INTEGER NOT NULL PRIMARY KEY CHECK(singleton = 1),
webp BLOB CHECK(webp IS NULL OR length(webp) BETWEEN 1 AND 131072),
sha256 BLOB CHECK(sha256 IS NULL OR length(sha256) = 32),
width INTEGER CHECK(width IS NULL OR width BETWEEN 1 AND 300),
height INTEGER CHECK(height IS NULL OR height BETWEEN 1 AND 300),
accepted_contacts_only INTEGER NOT NULL DEFAULT 1 CHECK(accepted_contacts_only IN (0, 1)),
source_revision INTEGER NOT NULL DEFAULT 0 CHECK(source_revision >= 0),
CHECK((webp IS NULL AND sha256 IS NULL AND width IS NULL AND height IS NULL) OR
(webp IS NOT NULL AND sha256 IS NOT NULL AND width IS NOT NULL AND height IS NOT NULL))
);
INSERT INTO own_custom_avatar(singleton) VALUES (1);
CREATE TABLE custom_avatar_publications (
contact_id INTEGER NOT NULL PRIMARY KEY REFERENCES contacts(user_id) ON DELETE CASCADE,
publication_counter INTEGER NOT NULL DEFAULT 0 CHECK(publication_counter >= 0),
visible_source_revision INTEGER,
shows_photo INTEGER NOT NULL DEFAULT 0 CHECK(shows_photo IN (0, 1))
);
CREATE TABLE received_custom_avatars (
contact_id INTEGER NOT NULL PRIMARY KEY REFERENCES contacts(user_id) ON DELETE CASCADE,
applied_counter INTEGER NOT NULL DEFAULT 0 CHECK(applied_counter >= 0),
webp BLOB CHECK(webp IS NULL OR length(webp) BETWEEN 1 AND 131072),
sha256 BLOB CHECK(sha256 IS NULL OR length(sha256) = 32),
width INTEGER CHECK(width IS NULL OR width BETWEEN 1 AND 300),
height INTEGER CHECK(height IS NULL OR height BETWEEN 1 AND 300),
CHECK((webp IS NULL AND sha256 IS NULL AND width IS NULL AND height IS NULL) OR
(webp IS NOT NULL AND sha256 IS NOT NULL AND width IS NOT NULL AND height IS NOT NULL))
);

View file

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

View file

@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1901082359;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 926647182;
// Section: executor
@ -1880,6 +1880,47 @@ fn wire__crate__bridge__api__rust_api_connection_state_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_create_custom_avatar_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_custom_avatar",
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);
let api_accepted_contacts_only = <bool>::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_custom_avatar(
api_image_path,
api_accepted_contacts_only,
)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_create_sticker_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -2468,6 +2509,41 @@ fn wire__crate__bridge__api__rust_api_force_ipa_check_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_get_custom_avatar_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_get_custom_avatar",
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);
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::get_custom_avatar().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_get_memories_url_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -3798,6 +3874,41 @@ fn wire__crate__bridge__api__rust_api_remove_additional_user_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_remove_custom_avatar_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_remove_custom_avatar",
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);
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::remove_custom_avatar().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_remove_media_files_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -4548,6 +4659,45 @@ fn wire__crate__bridge__api__rust_api_set_background_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_set_custom_avatar_audience_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_set_custom_avatar_audience",
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_accepted_contacts_only = <bool>::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::set_custom_avatar_audience(
api_accepted_contacts_only,
)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -6908,6 +7058,26 @@ impl SseDecode for bool {
}
}
impl SseDecode for crate::services::avatars::CustomAvatarInfo {
// 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 = <Option<Vec<u8>>>::sse_decode(deserializer);
let mut var_sha256 = <Option<Vec<u8>>>::sse_decode(deserializer);
let mut var_width = <Option<u32>>::sse_decode(deserializer);
let mut var_height = <Option<u32>>::sse_decode(deserializer);
let mut var_acceptedContactsOnly = <bool>::sse_decode(deserializer);
let mut var_sourceRevision = <i64>::sse_decode(deserializer);
return crate::services::avatars::CustomAvatarInfo {
webp: var_webp,
sha256: var_sha256,
width: var_width,
height: var_height,
accepted_contacts_only: var_acceptedContactsOnly,
source_revision: var_sourceRevision,
};
}
}
impl SseDecode for f64 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -8245,134 +8415,138 @@ 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_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_contact_joined_dates_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
139 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
142 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
145 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
146 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
147 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
149 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
150 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
151 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
152 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
153 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
157 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
158 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
159 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
160 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
161 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
162 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
163 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
164 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len),
165 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
166 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
167 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len),
168 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
169 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
170 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
171 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
172 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len),
173 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
174 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
175 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
177 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
178 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
179 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
180 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
181 => 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_custom_avatar_impl(port, ptr, rust_vec_len, data_len),
53 => wire__crate__bridge__api__rust_api_create_sticker_impl(port, ptr, rust_vec_len, data_len),
54 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len),
55 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len),
57 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len),
58 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len),
59 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len),
60 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len),
61 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len),
62 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len),
63 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len),
64 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len),
65 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len),
66 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len),
67 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len),
68 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__bridge__api__rust_api_get_custom_avatar_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_initialize_memory_location_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_prewarm_memory_location_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_refresh_contact_joined_dates_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_remove_custom_avatar_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__api__rust_api_set_custom_avatar_audience_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
139 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
142 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
145 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
146 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
147 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
149 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
150 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
151 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
152 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
153 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
157 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
158 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
159 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
160 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
161 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
162 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
163 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
164 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
165 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
166 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
167 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
168 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len),
169 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
170 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
171 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len),
172 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
173 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
174 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
175 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
176 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len),
177 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
178 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
179 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
181 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
182 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
183 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
184 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
185 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -8388,15 +8562,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),
55 => {
56 => {
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len)
}
176 => wire__crate__bridge__user_config__user_config_api_clone_impl(
180 => wire__crate__bridge__user_config__user_config_api_clone_impl(
ptr,
rust_vec_len,
data_len,
),
182 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
186 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -8498,6 +8672,31 @@ impl flutter_rust_bridge::IntoIntoDart<crate::keys::backup_password_keys::Backup
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::services::avatars::CustomAvatarInfo {
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.width.into_into_dart().into_dart(),
self.height.into_into_dart().into_dart(),
self.accepted_contacts_only.into_into_dart().into_dart(),
self.source_revision.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::services::avatars::CustomAvatarInfo
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::services::avatars::CustomAvatarInfo>
for crate::services::avatars::CustomAvatarInfo
{
fn into_into_dart(self) -> crate::services::avatars::CustomAvatarInfo {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for FrbWrapper<crate::bridge::FlameState> {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@ -9677,6 +9876,18 @@ impl SseEncode for bool {
}
}
impl SseEncode for crate::services::avatars::CustomAvatarInfo {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<Option<Vec<u8>>>::sse_encode(self.webp, serializer);
<Option<Vec<u8>>>::sse_encode(self.sha256, serializer);
<Option<u32>>::sse_encode(self.width, serializer);
<Option<u32>>::sse_encode(self.height, serializer);
<bool>::sse_encode(self.accepted_contacts_only, serializer);
<i64>::sse_encode(self.source_revision, serializer);
}
}
impl SseEncode for f64 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {

View file

@ -2,10 +2,18 @@ use crate::context::Context;
use crate::error::{Result, TwonlyError};
use crate::user_config::UserConfig;
use flate2::read::GzDecoder;
use std::io::Read;
use image::imageops;
use image::{DynamicImage, ImageDecoder, Rgba, RgbaImage};
use prost::Message as _;
use sha2::{Digest, Sha256};
use sqlx::{Row, Sqlite, Transaction};
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::api::proto::client::encrypted_content;
use crate::services::{media_codec, stickers};
const DEFAULT_AVATAR_SVG: &str = r##"<!-- Taken from: https://getavataaars.com/ -->
<svg width="264px" height="280px" viewBox="0 0 264 280" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
@ -36,6 +44,367 @@ const DEFAULT_AVATAR_SVG: &str = r##"<!-- Taken from: https://getavataaars.com/
static AVATAR_RENDER_LOCK: Mutex<()> = Mutex::new(());
pub(crate) const CUSTOM_AVATAR_PROTOCOL_VERSION: u32 = 1;
#[derive(Clone, Debug)]
pub struct CustomAvatarInfo {
pub webp: Option<Vec<u8>>,
pub sha256: Option<Vec<u8>>,
pub width: Option<u32>,
pub height: Option<u32>,
pub accepted_contacts_only: bool,
pub source_revision: i64,
}
pub(crate) fn create_custom_avatar(source: &Path) -> Result<stickers::StickerOutput> {
let cutout = stickers::create(source)?;
let decoded = image::load_from_memory_with_format(&cutout.webp, image::ImageFormat::WebP)
.map_err(|error| TwonlyError::Generic(format!("invalid generated avatar: {error}")))?;
let fitted = decoded.thumbnail(270, 270).to_rgba8();
let mut canvas = RgbaImage::from_pixel(300, 300, Rgba([0, 0, 0, 0]));
imageops::overlay(
&mut canvas,
&fitted,
i64::from((300 - fitted.width()) / 2),
i64::from((300 - fitted.height()) / 2),
);
// Bake the circular preview into the payload, including a one-pixel soft
// edge. Consumers still clip defensively, but the file itself is circular.
let center = 149.5_f32;
for (x, y, pixel) in canvas.enumerate_pixels_mut() {
let distance = ((x as f32 - center).powi(2) + (y as f32 - center).powi(2)).sqrt();
let circle_alpha = (150.0 - distance).clamp(0.0, 1.0);
pixel[3] = (f32::from(pixel[3]) * circle_alpha).round() as u8;
}
let image = DynamicImage::ImageRgba8(canvas);
let mut webp = Vec::new();
for quality in [90.0_f32, 82.0, 74.0, 66.0, 58.0] {
webp = media_codec::encode_webp(&image, quality)?;
if webp.len() <= stickers::MAX_STICKER_BYTES {
break;
}
}
if webp.len() > stickers::MAX_STICKER_BYTES {
return Err(TwonlyError::Generic(
"custom avatar is too large after compression".into(),
));
}
let sha256 = Sha256::digest(&webp).to_vec();
Ok(stickers::StickerOutput {
content_hash: hex::encode(&sha256),
sha256,
webp,
width: 300,
height: 300,
})
}
pub(crate) async fn own_custom_avatar(ctx: &Context) -> Result<CustomAvatarInfo> {
let database = ctx.app_db.read().await.clone();
let row = sqlx::query(
"SELECT webp, sha256, width, height, accepted_contacts_only, source_revision \
FROM own_custom_avatar WHERE singleton = 1",
)
.fetch_one(&database.pool)
.await?;
Ok(CustomAvatarInfo {
webp: row.try_get("webp")?,
sha256: row.try_get("sha256")?,
width: row
.try_get::<Option<i64>, _>("width")?
.map(|value| value as u32),
height: row
.try_get::<Option<i64>, _>("height")?
.map(|value| value as u32),
accepted_contacts_only: row.try_get::<i64, _>("accepted_contacts_only")? != 0,
source_revision: row.try_get("source_revision")?,
})
}
pub(crate) async fn store_own_custom_avatar(
ctx: &Context,
avatar: stickers::StickerOutput,
accepted_contacts_only: bool,
) -> Result<CustomAvatarInfo> {
let database = ctx.app_db.read().await.clone();
sqlx::query(
"UPDATE own_custom_avatar SET webp = ?, sha256 = ?, width = ?, height = ?, \
accepted_contacts_only = ?, source_revision = source_revision + 1 WHERE singleton = 1",
)
.bind(avatar.webp)
.bind(avatar.sha256)
.bind(i64::from(avatar.width))
.bind(i64::from(avatar.height))
.bind(accepted_contacts_only)
.execute(&database.pool)
.await?;
own_custom_avatar(ctx).await
}
pub(crate) async fn set_custom_avatar_audience(
ctx: &Context,
accepted_contacts_only: bool,
) -> Result<CustomAvatarInfo> {
let database = ctx.app_db.read().await.clone();
sqlx::query(
"UPDATE own_custom_avatar SET accepted_contacts_only = ?, \
source_revision = source_revision + CASE WHEN accepted_contacts_only != ? THEN 1 ELSE 0 END \
WHERE singleton = 1",
)
.bind(accepted_contacts_only)
.bind(accepted_contacts_only)
.execute(&database.pool)
.await?;
own_custom_avatar(ctx).await
}
pub(crate) async fn remove_own_custom_avatar(ctx: &Context) -> Result<CustomAvatarInfo> {
let database = ctx.app_db.read().await.clone();
sqlx::query(
"UPDATE own_custom_avatar SET webp = NULL, sha256 = NULL, width = NULL, height = NULL, \
source_revision = source_revision + CASE WHEN webp IS NULL THEN 0 ELSE 1 END WHERE singleton = 1",
)
.execute(&database.pool)
.await?;
own_custom_avatar(ctx).await
}
/// Materializes the avatar state visible to one recipient and returns its
/// counter. Private photo changes never advance excluded recipients' counters.
pub(crate) async fn prepare_publication(
t: &mut Transaction<'_, Sqlite>,
contact_id: i64,
) -> Result<i64> {
let own = sqlx::query(
"SELECT webp IS NOT NULL AS has_photo, accepted_contacts_only, source_revision \
FROM own_custom_avatar WHERE singleton = 1",
)
.fetch_one(&mut **t)
.await?;
let contact =
sqlx::query("SELECT accepted, blocked, deleted_by_user FROM contacts WHERE user_id = ?")
.bind(contact_id)
.fetch_optional(&mut **t)
.await?;
let accepted = contact
.as_ref()
.is_some_and(|row| row.get::<i64, _>("accepted") != 0);
let eligible = contact.as_ref().is_some_and(|row| {
row.get::<i64, _>("blocked") == 0 && row.get::<i64, _>("deleted_by_user") == 0
});
let accepted_only = own.get::<i64, _>("accepted_contacts_only") != 0;
let shows_photo =
own.get::<i64, _>("has_photo") != 0 && eligible && (!accepted_only || accepted);
let visible_revision = shows_photo.then(|| own.get::<i64, _>("source_revision"));
let current = sqlx::query(
"SELECT publication_counter, visible_source_revision, shows_photo \
FROM custom_avatar_publications WHERE contact_id = ?",
)
.bind(contact_id)
.fetch_optional(&mut **t)
.await?;
if let Some(row) = current {
let old_photo = row.get::<i64, _>("shows_photo") != 0;
let old_revision: Option<i64> = row.try_get("visible_source_revision")?;
let counter: i64 = row.get("publication_counter");
if old_photo == shows_photo && old_revision == visible_revision {
return Ok(counter);
}
let counter = counter + 1;
sqlx::query(
"UPDATE custom_avatar_publications SET publication_counter = ?, \
visible_source_revision = ?, shows_photo = ? WHERE contact_id = ?",
)
.bind(counter)
.bind(visible_revision)
.bind(shows_photo)
.bind(contact_id)
.execute(&mut **t)
.await?;
return Ok(counter);
}
let counter = i64::from(shows_photo);
sqlx::query(
"INSERT INTO custom_avatar_publications(contact_id, publication_counter, \
visible_source_revision, shows_photo) VALUES (?, ?, ?, ?)",
)
.bind(contact_id)
.bind(counter)
.bind(visible_revision)
.bind(shows_photo)
.execute(&mut **t)
.await?;
Ok(counter)
}
pub(crate) async fn publication_payload(
t: &mut Transaction<'_, Sqlite>,
contact_id: i64,
) -> Result<encrypted_content::CustomAvatar> {
let counter = prepare_publication(t, contact_id).await?;
let publication =
sqlx::query("SELECT shows_photo FROM custom_avatar_publications WHERE contact_id = ?")
.bind(contact_id)
.fetch_one(&mut **t)
.await?;
let shows_photo = publication.get::<i64, _>("shows_photo") != 0;
if !shows_photo {
return Ok(encrypted_content::CustomAvatar {
version: CUSTOM_AVATAR_PROTOCOL_VERSION,
publication_counter: counter,
state: encrypted_content::custom_avatar::State::SvgOnly as i32,
webp: None,
sha256: None,
width: None,
height: None,
});
}
let own = sqlx::query(
"SELECT webp, sha256, width, height FROM own_custom_avatar WHERE singleton = 1",
)
.fetch_one(&mut **t)
.await?;
Ok(encrypted_content::CustomAvatar {
version: CUSTOM_AVATAR_PROTOCOL_VERSION,
publication_counter: counter,
state: encrypted_content::custom_avatar::State::Photo as i32,
webp: own.try_get("webp")?,
sha256: own.try_get("sha256")?,
width: own
.try_get::<Option<i64>, _>("width")?
.map(|value| value as u32),
height: own
.try_get::<Option<i64>, _>("height")?
.map(|value| value as u32),
})
}
pub(crate) async fn applied_counter(
t: &mut Transaction<'_, Sqlite>,
contact_id: i64,
) -> Result<i64> {
Ok(sqlx::query_scalar::<_, i64>(
"SELECT applied_counter FROM received_custom_avatars WHERE contact_id = ?",
)
.bind(contact_id)
.fetch_optional(&mut **t)
.await?
.unwrap_or(0))
}
pub(crate) async fn apply_received(
t: &mut Transaction<'_, Sqlite>,
contact_id: i64,
avatar: encrypted_content::CustomAvatar,
) -> Result<bool> {
if avatar.version != CUSTOM_AVATAR_PROTOCOL_VERSION || avatar.publication_counter < 0 {
return Err(TwonlyError::Generic(
"invalid custom avatar version or counter".into(),
));
}
if avatar.publication_counter <= applied_counter(t, contact_id).await? {
return Ok(false);
}
let state = encrypted_content::custom_avatar::State::try_from(avatar.state)?;
let (webp, sha256, width, height) = match state {
encrypted_content::custom_avatar::State::SvgOnly => (None, None, None, None),
encrypted_content::custom_avatar::State::Photo => {
let webp = avatar
.webp
.ok_or_else(|| TwonlyError::Generic("custom avatar has no image".into()))?;
let sha256 = avatar
.sha256
.ok_or_else(|| TwonlyError::Generic("custom avatar has no digest".into()))?;
let width = avatar.width.unwrap_or_default();
let height = avatar.height.unwrap_or_default();
validate_custom_webp(&webp, &sha256, width, height)?;
(Some(webp), Some(sha256), Some(width), Some(height))
}
};
sqlx::query(
"INSERT INTO received_custom_avatars(contact_id, applied_counter, webp, sha256, width, height) \
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(contact_id) DO UPDATE SET \
applied_counter = excluded.applied_counter, webp = excluded.webp, sha256 = excluded.sha256, \
width = excluded.width, height = excluded.height",
)
.bind(contact_id)
.bind(avatar.publication_counter)
.bind(webp)
.bind(sha256)
.bind(width.map(i64::from))
.bind(height.map(i64::from))
.execute(&mut **t)
.await?;
// Notify existing contact streams without adding protocol fields to Drift.
sqlx::query(
"UPDATE contacts SET sender_profile_counter = sender_profile_counter WHERE user_id = ?",
)
.bind(contact_id)
.execute(&mut **t)
.await?;
Ok(true)
}
/// Rebuilds an avatar response immediately before encryption. A photo queued
/// while a contact was authorized can therefore become an SVG-only withdrawal
/// if acceptance or audience changed before transmission.
pub(crate) async fn refresh_queued_response(
ctx: &Context,
contact_id: i64,
bytes: Vec<u8>,
) -> Result<Vec<u8>> {
let mut content = crate::api::proto::client::EncryptedContent::decode(bytes.as_slice())?;
let is_avatar_response = content
.contact_update
.as_ref()
.is_some_and(|update| update.custom_avatar.is_some());
if !is_avatar_response {
return Ok(bytes);
}
let database = ctx.app_db.read().await.clone();
let mut transaction = database.pool.begin().await?;
let avatar = publication_payload(&mut transaction, contact_id).await?;
content.sender_custom_avatar_counter = Some(avatar.publication_counter);
content.custom_avatar_protocol_version = Some(CUSTOM_AVATAR_PROTOCOL_VERSION);
if let Some(update) = content.contact_update.as_mut() {
update.custom_avatar = Some(avatar);
}
transaction.commit().await?;
Ok(content.encode_to_vec())
}
fn validate_custom_webp(webp: &[u8], digest: &[u8], width: u32, height: u32) -> Result<()> {
if webp.is_empty()
|| webp.len() > stickers::MAX_STICKER_BYTES
|| digest.len() != 32
|| Sha256::digest(webp).as_slice() != digest
|| width != 300
|| height != 300
|| image::guess_format(webp).ok() != Some(image::ImageFormat::WebP)
{
return Err(TwonlyError::Generic("invalid custom avatar payload".into()));
}
let decoder = image::codecs::webp::WebPDecoder::new(Cursor::new(webp))
.map_err(|error| TwonlyError::Generic(format!("invalid custom avatar WebP: {error}")))?;
if decoder.dimensions() != (width, height) || decoder.has_animation() {
return Err(TwonlyError::Generic(
"invalid or animated custom avatar WebP".into(),
));
}
let decoded = DynamicImage::from_decoder(decoder)
.map_err(|error| TwonlyError::Generic(format!("invalid custom avatar WebP: {error}")))?;
if !decoded.color().has_alpha() || !decoded.to_rgba8().pixels().any(|pixel| pixel[3] > 0) {
return Err(TwonlyError::Generic(
"invalid custom avatar dimensions".into(),
));
}
Ok(())
}
fn decode_svg_bytes(bytes: &[u8]) -> Result<Vec<u8>> {
if bytes.starts_with(&[0x1f, 0x8b]) {
let mut decoded = Vec::new();
@ -85,6 +454,22 @@ pub(crate) async fn ensure_contact_avatar_png(
contact_id: i64,
) -> Result<Option<PathBuf>> {
let database = ctx.app_db.read().await.clone();
if let Some(custom) = sqlx::query(
"SELECT applied_counter, webp FROM received_custom_avatars \
WHERE contact_id = ? AND webp IS NOT NULL",
)
.bind(contact_id)
.fetch_optional(&database.pool)
.await?
{
let counter: i64 = custom.get("applied_counter");
let webp: Vec<u8> = custom.get("webp");
let output = Path::new(&ctx.config.data_dir)
.join("avatars")
.join(format!("contact_{contact_id}_custom_{counter}.webp"));
write_cached_bytes(&output, &webp)?;
return Ok(Some(output));
}
let row = sqlx::query!(
r#"SELECT avatar_svg_compressed, sender_profile_counter FROM contacts WHERE user_id = ?"#,
contact_id,
@ -115,6 +500,35 @@ pub(crate) async fn ensure_contact_avatar_png(
Ok(Some(output))
}
pub(crate) async fn current_custom_avatar_path(ctx: &Context) -> Result<Option<PathBuf>> {
let avatar = own_custom_avatar(ctx).await?;
let Some(webp) = avatar.webp else {
return Ok(None);
};
let output = Path::new(&ctx.config.data_dir)
.join("avatars")
.join(format!("user_custom_{}.webp", avatar.source_revision));
write_cached_bytes(&output, &webp)?;
Ok(Some(output))
}
fn write_cached_bytes(output: &Path, bytes: &[u8]) -> Result<()> {
let _guard = AVATAR_RENDER_LOCK
.lock()
.map_err(|_| TwonlyError::Generic("avatar render lock was poisoned".into()))?;
if output.exists() {
return Ok(());
}
let directory = output
.parent()
.ok_or_else(|| TwonlyError::Generic("avatar output has no parent directory".into()))?;
std::fs::create_dir_all(directory)?;
let temporary = output.with_extension("tmp");
std::fs::write(&temporary, bytes)?;
std::fs::rename(temporary, output)?;
Ok(())
}
pub(crate) fn notification_avatar_path(
ctx: &Context,
sender_id: i64,
@ -281,4 +695,113 @@ mod tests {
let image = image::load_from_memory(&png).unwrap();
assert_eq!((image.width(), image.height()), (270, 300));
}
#[tokio::test]
async fn publication_counter_only_changes_when_the_recipient_view_changes() {
let temporary_directory = tempfile::tempdir().unwrap();
let context = Context::init_for_testing(
temporary_directory.path().join("database"),
temporary_directory.path().join("data"),
)
.await
.unwrap();
let database = context.app_db.read().await.clone();
sqlx::query("INSERT INTO contacts(user_id, username) VALUES (7, 'peer')")
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
"UPDATE own_custom_avatar SET webp = x'01', sha256 = zeroblob(32), width = 300, \
height = 300, source_revision = 1, accepted_contacts_only = 1 WHERE singleton = 1",
)
.execute(&database.pool)
.await
.unwrap();
let mut transaction = database.pool.begin().await.unwrap();
assert_eq!(prepare_publication(&mut transaction, 7).await.unwrap(), 0);
transaction.commit().await.unwrap();
// A new private photo is invisible to an unaccepted peer.
sqlx::query("UPDATE own_custom_avatar SET source_revision = 2 WHERE singleton = 1")
.execute(&database.pool)
.await
.unwrap();
let mut transaction = database.pool.begin().await.unwrap();
assert_eq!(prepare_publication(&mut transaction, 7).await.unwrap(), 0);
transaction.commit().await.unwrap();
sqlx::query("UPDATE contacts SET accepted = 1 WHERE user_id = 7")
.execute(&database.pool)
.await
.unwrap();
let mut transaction = database.pool.begin().await.unwrap();
assert_eq!(prepare_publication(&mut transaction, 7).await.unwrap(), 1);
transaction.commit().await.unwrap();
// Revoking acceptance creates a versioned SVG-only withdrawal.
sqlx::query("UPDATE contacts SET accepted = 0 WHERE user_id = 7")
.execute(&database.pool)
.await
.unwrap();
let mut transaction = database.pool.begin().await.unwrap();
assert_eq!(prepare_publication(&mut transaction, 7).await.unwrap(), 2);
let payload = publication_payload(&mut transaction, 7).await.unwrap();
assert_eq!(
encrypted_content::custom_avatar::State::try_from(payload.state).unwrap(),
encrypted_content::custom_avatar::State::SvgOnly
);
transaction.commit().await.unwrap();
}
#[tokio::test]
async fn received_avatar_is_validated_and_ordered_by_publication_counter() {
let temporary_directory = tempfile::tempdir().unwrap();
let context = Context::init_for_testing(
temporary_directory.path().join("database"),
temporary_directory.path().join("data"),
)
.await
.unwrap();
let database = context.app_db.read().await.clone();
sqlx::query("INSERT INTO contacts(user_id, username) VALUES (9, 'peer')")
.execute(&database.pool)
.await
.unwrap();
let image = DynamicImage::ImageRgba8(RgbaImage::from_fn(300, 300, |x, y| {
if x < 4 || y < 4 {
Rgba([0, 0, 0, 0])
} else {
Rgba([10, 20, 30, 255])
}
}));
let webp = media_codec::encode_webp(&image, 80.0).unwrap();
let digest = Sha256::digest(&webp).to_vec();
let photo = encrypted_content::CustomAvatar {
version: CUSTOM_AVATAR_PROTOCOL_VERSION,
publication_counter: 2,
state: encrypted_content::custom_avatar::State::Photo as i32,
webp: Some(webp),
sha256: Some(digest),
width: Some(300),
height: Some(300),
};
let mut transaction = database.pool.begin().await.unwrap();
assert!(apply_received(&mut transaction, 9, photo).await.unwrap());
transaction.commit().await.unwrap();
let stale = encrypted_content::CustomAvatar {
version: CUSTOM_AVATAR_PROTOCOL_VERSION,
publication_counter: 1,
state: encrypted_content::custom_avatar::State::SvgOnly as i32,
webp: None,
sha256: None,
width: None,
height: None,
};
let mut transaction = database.pool.begin().await.unwrap();
assert!(!apply_received(&mut transaction, 9, stale).await.unwrap());
assert_eq!(applied_counter(&mut transaction, 9).await.unwrap(), 2);
transaction.commit().await.unwrap();
}
}

View file

@ -305,6 +305,8 @@ impl ContactService {
username: Some(config.username),
display_name: Some(config.display_name),
avatar_svg_compressed,
custom_avatar_requested: None,
custom_avatar: None,
}),
..Default::default()
}

View file

@ -571,7 +571,6 @@ pub async fn pending_batch(ctx: &Arc<Context>, locale: &str) -> Result<Notificat
r#"
SELECT n.event_id, n.notification_id, n.conversation_id, n.sender_id,
COALESCE(c.display_name, c.username) AS "sender_name!: String",
c.avatar_svg_compressed, c.sender_profile_counter,
g.group_name AS conversation_name,
COALESCE(g.is_direct_chat, 0) AS "is_direct_chat!: i64",
n.message_id, n.kind, n.content, n.created_at,
@ -602,12 +601,8 @@ pub async fn pending_batch(ctx: &Arc<Context>, locale: &str) -> Result<Notificat
let title = row.sender_name.clone();
let body = localized_body(locale, &row);
let is_group = row.conversation_id.is_some() && row.is_direct_chat == 0;
let avatar_path = match notification_avatar_path(
ctx,
row.sender_id,
row.sender_profile_counter,
row.avatar_svg_compressed.as_deref(),
) {
let avatar_path =
match crate::services::avatars::ensure_contact_avatar_png(ctx, row.sender_id).await {
Ok(path) => path.map(|path| path.display().to_string()),
Err(error) => {
tracing::warn!(
@ -873,8 +868,6 @@ struct PendingRow {
conversation_id: Option<String>,
sender_id: i64,
sender_name: String,
avatar_svg_compressed: Option<Vec<u8>>,
sender_profile_counter: i64,
conversation_name: Option<String>,
is_direct_chat: i64,
message_id: Option<String>,
@ -1091,8 +1084,6 @@ mod tests {
conversation_id: None,
sender_id: 7,
sender_name: "Alice".into(),
avatar_svg_compressed: None,
sender_profile_counter: 0,
conversation_name: None,
is_direct_chat: 1,
message_id: None,

View file

@ -0,0 +1,29 @@
import 'package:avatar_maker/avatar_maker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('detaches from an external controller when disposed', (
tester,
) async {
final controller = NonPersistentAvatarMakerController();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: AvatarMakerCustomizer(
controller: controller,
scaffoldHeight: 500,
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
controller.updatePreview(newAvatarMakerSVG: '<svg></svg>');
await tester.pump();
expect(tester.takeException(), isNull);
});
}