improve voice messages
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-09-02 23:30:22 +02:00
parent a44e6347c9
commit df838de125
8 changed files with 369 additions and 153 deletions

View file

@ -7013,8 +7013,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
UserConfig dco_decode_user_config(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 60)
throw Exception('unexpected arr length: expect 60 but see ${arr.length}');
if (arr.length != 61)
throw Exception('unexpected arr length: expect 61 but see ${arr.length}');
return UserConfig(
userId: dco_decode_i_64(arr[0]),
username: dco_decode_String(arr[1]),
@ -7069,18 +7069,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
lastChangeLogHash: dco_decode_opt_list_prim_u_8_strict(arr[46]),
hideChangeLog: dco_decode_bool(arr[47]),
hideMemoriesBackupPromo: dco_decode_bool(arr[48]),
updateFcmToken: dco_decode_bool(arr[49]),
canUseLoginTokenForAuth: dco_decode_bool(arr[50]),
twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[51]),
isBackupEnabled: dco_decode_bool(arr[52]),
hideWidgetShareExplainer: dco_decode_bool(arr[49]),
updateFcmToken: dco_decode_bool(arr[50]),
canUseLoginTokenForAuth: dco_decode_bool(arr[51]),
twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[52]),
isBackupEnabled: dco_decode_bool(arr[53]),
passwordLessRecovery:
dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[53]),
fcmToken: dco_decode_opt_String(arr[54]),
lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[55]),
lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[56]),
currentSetupPage: dco_decode_opt_String(arr[57]),
skipSetupPages: dco_decode_bool(arr[58]),
hasZoomed: dco_decode_bool(arr[59]),
dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[54]),
fcmToken: dco_decode_opt_String(arr[55]),
lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[56]),
lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[57]),
currentSetupPage: dco_decode_opt_String(arr[58]),
skipSetupPages: dco_decode_bool(arr[59]),
hasZoomed: dco_decode_bool(arr[60]),
);
}
@ -8189,6 +8190,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
var var_hideChangeLog = sse_decode_bool(deserializer);
var var_hideMemoriesBackupPromo = sse_decode_bool(deserializer);
var var_hideWidgetShareExplainer = sse_decode_bool(deserializer);
var var_updateFcmToken = sse_decode_bool(deserializer);
var var_canUseLoginTokenForAuth = sse_decode_bool(deserializer);
var var_twonlySafeBackup = sse_decode_opt_box_autoadd_twonly_safe_backup(
@ -8256,6 +8258,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
lastChangeLogHash: var_lastChangeLogHash,
hideChangeLog: var_hideChangeLog,
hideMemoriesBackupPromo: var_hideMemoriesBackupPromo,
hideWidgetShareExplainer: var_hideWidgetShareExplainer,
updateFcmToken: var_updateFcmToken,
canUseLoginTokenForAuth: var_canUseLoginTokenForAuth,
twonlySafeBackup: var_twonlySafeBackup,
@ -9338,6 +9341,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_opt_list_prim_u_8_strict(self.lastChangeLogHash, serializer);
sse_encode_bool(self.hideChangeLog, serializer);
sse_encode_bool(self.hideMemoriesBackupPromo, serializer);
sse_encode_bool(self.hideWidgetShareExplainer, serializer);
sse_encode_bool(self.updateFcmToken, serializer);
sse_encode_bool(self.canUseLoginTokenForAuth, serializer);
sse_encode_opt_box_autoadd_twonly_safe_backup(

View file

@ -152,6 +152,7 @@ class UserConfig {
Uint8List? lastChangeLogHash;
bool hideChangeLog;
bool hideMemoriesBackupPromo;
bool hideWidgetShareExplainer;
bool updateFcmToken;
bool canUseLoginTokenForAuth;
TwonlySafeBackup? twonlySafeBackup;
@ -222,6 +223,7 @@ class UserConfig {
this.lastChangeLogHash,
required this.hideChangeLog,
required this.hideMemoriesBackupPromo,
required this.hideWidgetShareExplainer,
required this.updateFcmToken,
required this.canUseLoginTokenForAuth,
this.twonlySafeBackup,
@ -286,6 +288,7 @@ class UserConfig {
lastChangeLogHash.hashCode ^
hideChangeLog.hashCode ^
hideMemoriesBackupPromo.hashCode ^
hideWidgetShareExplainer.hashCode ^
updateFcmToken.hashCode ^
canUseLoginTokenForAuth.hashCode ^
twonlySafeBackup.hashCode ^
@ -359,6 +362,7 @@ class UserConfig {
lastChangeLogHash == other.lastChangeLogHash &&
hideChangeLog == other.hideChangeLog &&
hideMemoriesBackupPromo == other.hideMemoriesBackupPromo &&
hideWidgetShareExplainer == other.hideWidgetShareExplainer &&
updateFcmToken == other.updateFcmToken &&
canUseLoginTokenForAuth == other.canUseLoginTokenForAuth &&
twonlySafeBackup == other.twonlySafeBackup &&

View file

@ -2,6 +2,4 @@ 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

@ -0,0 +1,104 @@
import 'dart:async';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:twonly/src/utils/log.dart';
/// Keeps the [PlayerController] of a chat audio message alive across widget
/// rebuilds.
///
/// The message list recreates the state of all of its items whenever it scrolls
/// to a new position (sending a message does that), so a controller owned by
/// the widget itself would be disposed and re-prepared in the middle of the
/// playback. Controllers are cached by file path here and are only released
/// once no widget uses them any more.
class AudioPlaybackService {
AudioPlaybackService._();
static final AudioPlaybackService instance = AudioPlaybackService._();
final Map<String, AudioPlayback> _playbacks = {};
/// Waveforms of already released players so a re-created widget can draw the
/// waveform right away instead of waiting for the extraction again.
final Map<String, List<double>> _waveformCache = {};
/// Returns the playback for [path], preparing the player on first use.
///
/// Every [attach] must be paired with a [detach].
AudioPlayback attach(String path, {required int noOfSamples}) {
final playback = _playbacks.putIfAbsent(
path,
() => AudioPlayback._(path, _waveformCache[path] ?? const []),
);
playback._refCount++;
unawaited(playback._prepare(noOfSamples));
return playback;
}
/// Releases the playback again once nothing uses it any more.
void detach(AudioPlayback playback) {
playback._refCount--;
if (playback._refCount > 0) return;
// A rebuilding message list detaches and attaches within the same frame,
// so only release the player when it was not picked up again.
Timer(const Duration(milliseconds: 500), () {
if (playback._refCount > 0) return;
if (_playbacks[playback.path] != playback) return;
_playbacks.remove(playback.path);
if (playback.waveformData.isNotEmpty) {
_waveformCache[playback.path] = playback.waveformData;
}
playback.controller.dispose();
});
}
/// Starts [playback] and pauses any other audio message.
Future<void> play(AudioPlayback playback) async {
for (final other in _playbacks.values) {
if (other == playback) continue;
if (other.controller.playerState == PlayerState.playing) {
await other.controller.pausePlayer();
}
}
await playback.controller.startPlayer();
}
}
class AudioPlayback {
AudioPlayback._(this.path, List<double> cachedWaveform)
: waveformData = List<double>.of(cachedWaveform);
final String path;
final PlayerController controller = PlayerController();
/// Extracted waveform, empty until the extraction finished.
List<double> waveformData;
int _refCount = 0;
Future<void>? _preparing;
bool get isPlaying => controller.playerState == PlayerState.playing;
/// Resolves once the player is prepared and the waveform is extracted.
Future<void> get ready => _preparing ?? Future<void>.value();
Future<void> _prepare(int noOfSamples) {
return _preparing ??= () async {
try {
await controller.preparePlayer(
path: path,
// The waveform is extracted below so the extraction can be awaited.
shouldExtractWaveform: false,
);
await controller.setFinishMode(finishMode: FinishMode.pause);
if (waveformData.isEmpty) {
waveformData = await controller.waveformExtraction
.extractWaveformData(path: path, noOfSamples: noOfSamples);
AudioPlaybackService.instance._waveformCache[path] = waveformData;
}
} catch (e) {
Log.error('Could not prepare the audio player for $path: $e');
}
}();
}
}

View file

@ -5,12 +5,11 @@ 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/utils/keyvalue.dart';
import 'package:twonly/src/services/user.service.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';
@ -95,16 +94,14 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
bool _widgetRecipientAvailable = false;
bool _sendToWidget = false;
bool _updatingWidgetMode = false;
bool _widgetExplainerPreferenceLoaded = false;
bool _widgetExplainerDismissed = false;
bool _widgetExplainerDismissed =
userService.currentUser.hideWidgetShareExplainer;
bool _previousMediaSettingsCaptured = false;
int? _displayLimitBeforeWidget;
bool _requiresAuthBeforeWidget = false;
bool get _showWidgetExplainer =>
_widgetRecipientAvailable &&
_widgetExplainerPreferenceLoaded &&
!_widgetExplainerDismissed;
_widgetRecipientAvailable && !_widgetExplainerDismissed;
MediaFileService get mediaService => widget.mediaFileService;
MediaFile get media => widget.mediaFileService.mediaFile;
@ -139,8 +136,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
_widgetGroupsSubscription = twonlyDB.groupsDao
.watchGroupsAllowedForWidgetShare()
.listen(_updateWidgetRecipientAvailability);
unawaited(_loadWidgetExplainerPreference());
if (media.type == MediaType.image || media.type == MediaType.gif) {
_loadInitialImage();
}
@ -165,23 +160,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
super.dispose();
}
Future<void> _loadWidgetExplainerPreference() async {
final preference = await KeyValueStore.get(
KeyValueKeys.shareImageWidgetExplainer,
);
if (!mounted) return;
setState(() {
_widgetExplainerDismissed = preference?['dismissed'] == true;
_widgetExplainerPreferenceLoaded = true;
});
}
Future<void> _dismissWidgetExplainer() async {
setState(() => _widgetExplainerDismissed = true);
await KeyValueStore.put(
KeyValueKeys.shareImageWidgetExplainer,
const {'dismissed': true},
);
await UserService.update((u) => u.hideWidgetShareExplainer = true);
}
void _updateWidgetRecipientAvailability(List<Group> widgetGroups) {

View file

@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/audio_playback.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/visual/elements/better_text.element.dart';
@ -75,35 +76,29 @@ class _ChatAudioEntryState extends State<ChatAudioEntry> {
}
final showTime = info.displayTime || message.modifiedAt != null;
final messageTime = showTime ? FriendlyMessageTime(message: message) : null;
final isDownloaded =
mediaService.mediaFile.downloadState == DownloadState.ready ||
mediaService.mediaFile.downloadState == null;
// The player places the time below the waveform itself.
final showsPlayer = info.text == '' && isDownloaded && _hasTempFile;
return IntrinsicWidth(
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
minWidth: 280,
// The player brings its own width, only the reactions below the
// bubble may need more room.
minWidth: info.minWidth,
),
padding: info.padding,
// Voice messages need a bit more room than the tightly padded media
// bubbles.
padding: info.padding.copyWith(top: 8, bottom: 8),
decoration: BoxDecoration(
color: info.color,
borderRadius: borderRadius,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (info.displayUserName != '')
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
info.displayUserName,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
Row(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
@ -116,14 +111,12 @@ class _ChatAudioEntryState extends State<ChatAudioEntry> {
)
else
Expanded(
child:
mediaService.mediaFile.downloadState ==
DownloadState.ready ||
mediaService.mediaFile.downloadState == null
child: isDownloaded
? (_hasTempFile
? InChatAudioPlayer(
path: mediaService.tempPath.path,
message: message,
trailing: messageTime,
)
: Container())
: MessageSendStateIcon(
@ -131,9 +124,7 @@ class _ChatAudioEntryState extends State<ChatAudioEntry> {
[mediaService.mediaFile],
),
),
if (showTime) FriendlyMessageTime(message: message),
],
),
if (!showsPlayer && messageTime != null) messageTime,
],
),
),
@ -145,116 +136,241 @@ class InChatAudioPlayer extends StatefulWidget {
const InChatAudioPlayer({
required this.path,
required this.message,
this.trailing,
super.key,
});
final String path;
final Message message;
/// Shown below the waveform, next to the remaining playback time.
final Widget? trailing;
@override
State<InChatAudioPlayer> createState() => _InChatAudioPlayerState();
}
class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
final PlayerController _playerController = PlayerController();
int _displayDuration = 0;
int _maxDuration = 0;
static const double _maxWaveWidth = 170;
static const double _waveHeight = 36;
static const double _cursorWidth = 2.5;
static const double _cursorHeight = 20;
static const double _playSlotWidth = 34;
static const _waveStyle = PlayerWaveStyle(
spacing: 4,
waveThickness: 2.5,
scaleFactor: 80,
showSeekLine: false,
);
AudioPlayback? _playback;
final List<StreamSubscription<dynamic>> _subscriptions = [];
/// Width available for the waveform next to the play button and the time.
double _waveWidth = _maxWaveWidth;
int _position = 0;
int _duration = 0;
bool _isPlaying = false;
/// The cursor is only shown once the message was started.
bool get _showCursor => _isPlaying || _position > 0;
double get _progress =>
_duration <= 0 ? 0 : (_position / _duration).clamp(0.0, 1.0);
@override
void initState() {
super.initState();
_playerController
..preparePlayer(path: widget.path)
..setFinishMode(finishMode: FinishMode.pause);
void didChangeDependencies() {
super.didChangeDependencies();
// Leaves room for the play button and the bubble padding.
_waveWidth = (MediaQuery.sizeOf(context).width * 0.8 - 80).clamp(
80.0,
_maxWaveWidth,
);
if (_playback != null) return;
_playerController.onCompletion.listen((_) {
if (mounted) {
// The player outlives this widget: the message list recreates its items
// when it scrolls (e.g. after sending a message) and the playback must not
// be interrupted by that.
final playback = AudioPlaybackService.instance.attach(
widget.path,
noOfSamples: _waveStyle.getSamplesForWidth(_waveWidth) - 1,
);
_playback = playback;
_isPlaying = playback.isPlaying;
_duration = playback.controller.maxDuration;
_subscriptions.add(
playback.controller.onPlayerStateChanged.listen((state) {
if (!mounted) return;
setState(() => _isPlaying = state == PlayerState.playing);
}),
);
_subscriptions.add(
playback.controller.onCurrentDurationChanged.listen((duration) {
if (!mounted) return;
setState(() => _position = duration);
}),
);
_subscriptions.add(
playback.controller.onCompletion.listen((_) {
if (!mounted) return;
setState(() {
_isPlaying = false;
_playerController.seekTo(0);
_position = 0;
});
}
});
_playerController.onPlayerStateChanged.listen((a) async {
if (a == PlayerState.initialized) {
_displayDuration = await _playerController.getDuration(
DurationType.max,
unawaited(
playback.controller.seekTo(0).then((_) {
// A last duration update may still arrive after the completion.
if (mounted) setState(() => _position = 0);
}),
);
}),
);
_maxDuration = _displayDuration;
if (mounted) setState(() {});
}
});
_playerController.onCurrentDurationChanged.listen((duration) async {
if (mounted) {
setState(() {
_displayDuration = _maxDuration - duration;
});
unawaited(_restoreState(playback));
}
/// Restores duration and position, which are already known when the player
/// was prepared by a previous instance of this widget.
Future<void> _restoreState(AudioPlayback playback) async {
await playback.ready;
if (!mounted) return;
final position = await playback.controller.getDuration(
DurationType.current,
);
if (!mounted) return;
final started =
playback.isPlaying ||
playback.controller.playerState == PlayerState.paused;
setState(() {
_duration = playback.controller.maxDuration;
_position = started && position > 0 ? position : 0;
_isPlaying = playback.isPlaying;
});
}
@override
void dispose() {
_playerController.dispose();
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
final playback = _playback;
if (playback != null) AudioPlaybackService.instance.detach(playback);
super.dispose();
}
bool _isPlaying = false;
@override
Widget build(BuildContext context) {
return Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
if (_isPlaying) {
_playerController.pausePlayer();
} else {
_playerController.startPlayer();
if (widget.message.senderId != null &&
widget.message.openedAt == null) {
Future<void> _togglePlayback() async {
final playback = _playback;
if (playback == null) return;
if (playback.isPlaying) {
await playback.controller.pausePlayer();
return;
}
await playback.ready;
if (!mounted) return;
await AudioPlaybackService.instance.play(playback);
if (widget.message.senderId != null && widget.message.openedAt == null) {
unawaited(_notifyMessageOpened());
}
}
setState(() {
_isPlaying = !_isPlaying;
});
},
child: Container(
padding: EdgeInsets.only(
left: _isPlaying ? 2 : 0,
top: 4,
bottom: 4,
),
@override
Widget build(BuildContext context) {
final remaining = _duration <= 0 ? 0 : _duration - _position;
final playback = _playback;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: _playSlotWidth,
child: GestureDetector(
onTap: () => unawaited(_togglePlayback()),
child: ColoredBox(
color: Colors.transparent,
child: FaIcon(
_isPlaying ? FontAwesomeIcons.pause : FontAwesomeIcons.play,
size: 20,
_isPlaying
? FontAwesomeIcons.solidCirclePause
: FontAwesomeIcons.solidCirclePlay,
size: 28,
color: Colors.white,
),
),
),
Text(
formatMsToMinSec(_displayDuration),
style: const TextStyle(
),
const SizedBox(width: 10),
SizedBox(
width: _waveWidth,
height: _waveHeight,
child: Stack(
children: [
if (playback != null)
AudioFileWaveforms(
// Rebuilds the waveform once the extraction finished so
// the complete waveform appears at once.
key: ValueKey(playback.waveformData.isNotEmpty),
playerController: playback.controller,
waveformData: playback.waveformData,
playerWaveStyle: _waveStyle,
// The whole waveform stays in place, only the already
// played part changes its color.
waveformType: WaveformType.fitWidth,
// Show the waveform right away instead of growing it in
// wave by wave while it is extracted.
continuousWaveform: false,
animationDuration: Duration.zero,
size: Size(_waveWidth, _waveHeight),
),
if (_showCursor)
Positioned(
top: (_waveHeight - _cursorHeight) / 2,
height: _cursorHeight,
left: (_progress * (_waveWidth - _cursorWidth)).clamp(
0.0,
_waveWidth - _cursorWidth,
),
child: IgnorePointer(
child: Container(
width: _cursorWidth,
decoration: BoxDecoration(
color: Colors.white,
fontSize: 12,
borderRadius: BorderRadius.circular(2),
),
),
),
),
],
),
),
],
),
Row(
children: [
SizedBox(
width: _playSlotWidth,
child: Text(
formatMsToMinSec(remaining),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
const SizedBox(width: 10),
AudioFileWaveforms(
playerController: _playerController,
size: const Size(150, 40),
if (widget.trailing != null)
SizedBox(
width: _waveWidth,
child: Align(
alignment: Alignment.centerRight,
child: widget.trailing,
),
),
],
),
],
);

View file

@ -6774,6 +6774,7 @@ impl SseDecode for crate::user_config::UserConfig {
let mut var_lastChangeLogHash = <Option<Vec<u8>>>::sse_decode(deserializer);
let mut var_hideChangeLog = <bool>::sse_decode(deserializer);
let mut var_hideMemoriesBackupPromo = <bool>::sse_decode(deserializer);
let mut var_hideWidgetShareExplainer = <bool>::sse_decode(deserializer);
let mut var_updateFcmToken = <bool>::sse_decode(deserializer);
let mut var_canUseLoginTokenForAuth = <bool>::sse_decode(deserializer);
let mut var_twonlySafeBackup =
@ -6838,6 +6839,7 @@ impl SseDecode for crate::user_config::UserConfig {
last_change_log_hash: var_lastChangeLogHash,
hide_change_log: var_hideChangeLog,
hide_memories_backup_promo: var_hideMemoriesBackupPromo,
hide_widget_share_explainer: var_hideWidgetShareExplainer,
update_fcm_token: var_updateFcmToken,
can_use_login_token_for_auth: var_canUseLoginTokenForAuth,
twonly_safe_backup: var_twonlySafeBackup,
@ -7922,6 +7924,9 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig {
self.last_change_log_hash.into_into_dart().into_dart(),
self.hide_change_log.into_into_dart().into_dart(),
self.hide_memories_backup_promo.into_into_dart().into_dart(),
self.hide_widget_share_explainer
.into_into_dart()
.into_dart(),
self.update_fcm_token.into_into_dart().into_dart(),
self.can_use_login_token_for_auth
.into_into_dart()
@ -8850,6 +8855,7 @@ impl SseEncode for crate::user_config::UserConfig {
<Option<Vec<u8>>>::sse_encode(self.last_change_log_hash, serializer);
<bool>::sse_encode(self.hide_change_log, serializer);
<bool>::sse_encode(self.hide_memories_backup_promo, serializer);
<bool>::sse_encode(self.hide_widget_share_explainer, serializer);
<bool>::sse_encode(self.update_fcm_token, serializer);
<bool>::sse_encode(self.can_use_login_token_for_auth, serializer);
<Option<crate::user_config::TwonlySafeBackup>>::sse_encode(

View file

@ -305,6 +305,9 @@ pub struct UserConfig {
#[serde(default)]
#[frb(non_final)]
pub hide_memories_backup_promo: bool,
#[serde(default)]
#[frb(non_final)]
pub hide_widget_share_explainer: bool,
#[serde(default = "defaults::true_value")]
#[frb(non_final)]
pub update_fcm_token: bool,